ReviewOS

also looking at this

loghqorg/loghq

Promote main to production: signed-out chrome and the subscriber tables

#17
Merged glennmichael123 wants to merge main into production
54 files +4252 -1353

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

resources/views/dashboard.stxmodified+1-1
Changes to resources/views/dashboard.stx
@@ -674,7 +674,7 @@ onMount(() => {
674674 @if (user && projectsUnavailable)
675675 <div class="py-24 text-center">
676676 <p class="text-muted text-sm"><span class="font-semibold text-ink">Can't reach your projects right now.</span> Make sure Postgres is provisioned.</p>
677 <p class="mt-2 text-subtle text-xs">Nothing has been lost — reload once the database is back.</p>
677 <p class="mt-2 text-subtle text-xs">Nothing has been lost. Reload once the database is back.</p>
678678 </div>
679679 @endif
680680
resources/views/docs.stxmodified+3-3
Changes to resources/views/docs.stx
@@ -29,7 +29,7 @@ const crumbs = [
2929<section class="feat-hero shell">
3030 <span class="kicker">Docs</span>
3131 <h1>One HTTP request. Nothing to install.</h1>
32 <p>loghq ingests structured logs over plain JSON. If your language can make an HTTP request, it can ship logs — no SDK, no build step, no lock-in. Start here, then dive into the topic you need.</p>
32 <p>loghq ingests structured logs over plain JSON. If your language can make an HTTP request, it can ship logs: no SDK, no build step, no lock-in. Start here, then dive into the topic you need.</p>
3333 <div class="hero-actions">
3434 <StxLink class="btn primary" to="/register">Create a project</StxLink>
3535 <a class="btn ghost" href="https://github.com/stacksjs/loghq/blob/main/docs/ingest.md">Read the ingest spec</a>
@@ -46,7 +46,7 @@ const crumbs = [
4646 <span class="flow-num">01</span>
4747 <div>
4848 <h3>Copy your ingest key</h3>
49 <p>Every project has one, in project settings. It is public by design — safe to ship in a client bundle — revocable, rotatable, and grants no read access.</p>
49 <p>Every project has one, in project settings. It is public by design, safe to ship in a client bundle, revocable, rotatable, and grants no read access.</p>
5050 </div>
5151 </div>
5252 <div class="flow-step">
@@ -69,7 +69,7 @@ const crumbs = [
6969<section class="reveal section shell" aria-label="Send your first log">
7070 <div class="max-w-[700px] code">
7171 <div class="code-bar"><span class="chart-cap">send your first log</span></div>
72 <pre class="code-body"><code># any language, any runtime — this is the whole integration
72 <pre class="code-body"><code># any language, any runtime: this is the whole integration
7373curl -X POST https://loghq.org/logs \
7474 -H 'Content-Type: application/json' \
7575 -H 'X-LogHQ-Key: loghq_your_project_key' \
resources/views/projects/index.stxmodified+1-1
Changes to resources/views/projects/index.stx
@@ -164,7 +164,7 @@ function logout(): void {
164164 @if (projectsUnavailable)
165165 <div class="px-5 py-16 text-center bg-panel border border-line rounded-xl">
166166 <div class="mb-1 font-medium text-base">Can't reach your apps right now</div>
167 <div class="text-sm text-subtle">Make sure Postgres is provisioned. Nothing has been lost — reload once the database is back.</div>
167 <div class="text-sm text-subtle">Make sure Postgres is provisioned. Nothing has been lost. Reload once the database is back.</div>
168168 </div>
169169 @else
170170 <EmptyState title="No apps yet" description="Create your first app to start capturing errors.">
resources/views/register.stxmodified+1-1
Changes to resources/views/register.stx
@@ -92,7 +92,7 @@ async function onRegister(e: Event): Promise<void> {
9292 </div>
9393
9494 @if (isInvite)
95 <p class="mb-3.5 invite-hint">You've been invited to a project — sign up to join it.</p>
95 <p class="mb-3.5 invite-hint">You've been invited to a project. Sign up to join it.</p>
9696 @endif
9797
9898 <div id="form-error" role="alert" :show="!!error()" :text="error()" class="mb-4 py-2 err-box notice-box"></div>
resources/views/settings.stxmodified+8-8
Changes to resources/views/settings.stx
@@ -1225,7 +1225,7 @@ onMount(() => {
12251225 @if (user && projectsUnavailable)
12261226 <div class="py-24 text-center">
12271227 <p class="text-muted text-sm"><span class="font-semibold text-ink">Can't reach your settings right now.</span> Make sure Postgres is provisioned.</p>
1228 <p class="mt-2 text-subtle text-xs">Nothing has been changed — reload once the database is back.</p>
1228 <p class="mt-2 text-subtle text-xs">Nothing has been changed. Reload once the database is back.</p>
12291229 </div>
12301230 @endif
12311231
@@ -1301,7 +1301,7 @@ onMount(() => {
13011301 <div class="px-5 py-5 app-panel">
13021302 <p class="mb-4 text-sm text-subtle">
13031303 This key authorizes log ingest for <span class="font-mono text-muted">{{ activeProject?.name }}</span>.
1304 It's a public identifier (safe to ship in client code), not a secret — it says <em>which</em> project an entry belongs to, and grants no read access.
1304 It's a public identifier (safe to ship in client code), not a secret: it says <em>which</em> project an entry belongs to, and grants no read access.
13051305 </p>
13061306 <div class="flex flex-wrap gap-2 items-center">
13071307 <code class="flex-1 px-3.5 py-2.5 min-w-[220px] font-mono text-ink text-sm tracking-[0.01em] truncate bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" :text="keyShown()">{{ keyMask }}</code>
@@ -1310,7 +1310,7 @@ onMount(() => {
13101310 <Button variant="ghost" @click="rotateKey" :loading="rotating()" className="btn-danger btn-sm">Rotate</Button>
13111311 </div>
13121312 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
1313 Rotating generates a new key and <span class="text-warn">invalidates the current one immediately</span> — anything still sending the old key will get 401 until you update it (<span class="font-mono">X-LogHQ-Key</span>).
1313 Rotating generates a new key and <span class="text-warn">invalidates the current one immediately</span>. Anything still sending the old key will get 401 until you update it (<span class="font-mono">X-LogHQ-Key</span>).
13141314 </p>
13151315 <span class="mt-2 text-ok text-xs" :show="!!keyNote()" :class="keyNoteOk() ? 'mt-2 text-xs text-ok' : 'mt-2 text-xs text-accent'">{{ keyNote() }}</span>
13161316 </div>
@@ -1416,9 +1416,9 @@ onMount(() => {
14161416 <span class="grid place-items-center shrink-0 h-8 w-8 font-semibold text-subtle bg-[color-mix(in_srgb,var(--text-3)_12%,var(--panel))] rounded-lg">{{ (i.email || '?').charAt(0).toUpperCase() }}</span>
14171417 <div class="flex-1 min-w-0">
14181418 <div class="font-medium text-sm truncate">{{ i.email }}</div>
1419 <div class="mt-0.5 text-subtle text-xs">{{ i.registered ? 'Has an account — pending accept' : 'Invite sent — awaiting sign-up' }}</div>
1419 <div class="mt-0.5 text-subtle text-xs">{{ i.registered ? 'Has an account, pending accept' : 'Invite sent, awaiting sign-up' }}</div>
14201420 </div>
1421 <span class="px-[9px] py-0.5 font-semibold text-[11px] text-muted tracking-[0.02em] bg-[color-mix(in_srgb,var(--text-3)_18%,var(--panel))] rounded-[999px]" x-tooltip="Pending — becomes a member once they accept">Invited</span>
1421 <span class="px-[9px] py-0.5 font-semibold text-[11px] text-muted tracking-[0.02em] bg-[color-mix(in_srgb,var(--text-3)_18%,var(--panel))] rounded-[999px]" x-tooltip="Pending: becomes a member once they accept">Invited</span>
14221422 <button @click="copyJoin('', '')" :text="joinBtnLabel('')" class="btn-quiet btn-xs" x-tooltip="Copy join link">Copy link</button>
14231423 <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="btn-danger btn-xs" x-tooltip="Revoke invite">Remove</button>
14241424 </div>
@@ -1441,7 +1441,7 @@ onMount(() => {
14411441 <section id="alerts" class="mb-10 scroll-mt-[84px]">
14421442 <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Alerts</div>
14431443 <p class="mb-4 text-sm text-subtle">
1444 Add a Slack or Discord webhook and send a test to verify it. Automatic delivery isn't live yet —
1444 Add a Slack or Discord webhook and send a test to verify it. Automatic delivery isn't live yet:
14451445 channels you add here are saved and testable, but loghq won't fire on its own until log-based alert rules ship.
14461446 </p>
14471447 <div class="flex flex-col gap-2.5 mb-4">
@@ -1489,7 +1489,7 @@ onMount(() => {
14891489 <button type="button" @click="chType.set('discord')" data-sel="0" :data-sel="chType() === 'discord' ? '1' : '0'" class="px-3 py-2.5 font-medium text-muted text-sm bg-panel border border-line rounded-[10px] [transition:all_0.12s_ease] seg seg-discord">Discord</button>
14901490 </div>
14911491 <input :model="whUrl" :placeholder="whPlaceholder()" type="url" aria-label="Webhook URL" class="mb-2 px-3.5 py-2.5 w-full font-mono text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" placeholder="https://hooks.slack.com/services/…" autocomplete="off" spellcheck="false">
1492 <input :model="whLabel" type="text" aria-label="Channel label" class="mb-3 px-3.5 py-2.5 w-full text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" placeholder="Label (optional) — e.g. #alerts" maxlength="255">
1492 <input :model="whLabel" type="text" aria-label="Channel label" class="mb-3 px-3.5 py-2.5 w-full text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" placeholder="Label (optional), e.g. #alerts" maxlength="255">
14931493 <div class="flex gap-3 items-center">
14941494 <Button variant="ghost" @click="addChannel" :loading="adding()" className="btn-cta btn-solid">Add channel</Button>
14951495 <span class="text-ok text-xs" :show="!!whNote()" :class="whNoteOk() ? 'text-xs text-ok' : 'text-xs text-accent'">{{ whNote() }}</span>
@@ -1581,7 +1581,7 @@ onMount(() => {
15811581 <p class="text-sm text-subtle"><span class="font-mono text-muted">{{ activeProject?.name }}</span> is archived and isn’t accepting new logs. Unarchiving resumes ingestion right away.</p>
15821582 @else
15831583 <div class="mb-0.5 font-semibold text-sm">Archive this project</div>
1584 <p class="text-sm text-subtle">Rejects new log entries for <span class="font-mono text-muted">{{ activeProject?.name }}</span> with a 403. Everything already stored is kept — you can unarchive anytime.</p>
1584 <p class="text-sm text-subtle">Rejects new log entries for <span class="font-mono text-muted">{{ activeProject?.name }}</span> with a 403. Everything already stored is kept, and you can unarchive anytime.</p>
15851585 @endif
15861586 </div>
15871587 <Button variant="ghost" @click="openArchive" :loading="archiving()" className="flex-none whitespace-nowrap btn-cta btn-danger">{{ activeProject?.is_active === false ? 'Unarchive' : 'Archive' }}</Button>
scripts/validate-ui.tsmodified+40-2
Changes to scripts/validate-ui.ts
@@ -5,10 +5,10 @@ const root = process.cwd()
55const resources = resolve(root, 'resources')
66const issues = new Set<string>()
77
8function filesIn(directory: string): string[] {
8function filesIn(directory: string, pattern = /\.(?:stx|html|css)$/): string[] {
99 return readdirSync(directory).flatMap((name) => {
1010 const path = join(directory, name)
11 return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : []
11 return statSync(path).isDirectory() ? filesIn(path, pattern) : pattern.test(path) ? [path] : []
1212 })
1313}
1414
@@ -92,6 +92,44 @@ for (const file of markupFiles) {
9292 }
9393}
9494
95/*
96 * Em-dashes in copy a user can read.
97 *
98 * The house rule is in AGENTS.md: never an em-dash or a separator en-dash in a
99 * headline, a body string, a label, a button, alt text, a tooltip or a
100 * placeholder. It is named there as the most common AI design tell and listed as
101 * a pre-flight item, and nothing enforced it -- 98 live ones across the six apps
102 * when this check was written, every one shipped past a green CI.
103 *
104 * Over a wider corpus than the markup checks above, because copy is not only in
105 * templates: resources/data/*.ts holds marketing tables and resources/emails/
106 * holds mail bodies, and both reach a reader. filesIn() takes the pattern for
107 * that reason; `combined` deliberately keeps the narrow one, since widening the
108 * corpus of a check that looks for a treatment can only add ways for it to pass.
109 *
110 * Two shapes are not separators and are exempt:
111 *
112 * '—' a lone dash standing in for a value that does not exist (a field the
113 * SDK never sent, a count we never measured). A typographic placeholder
114 * rather than punctuation between clauses, and no hyphen reads as
115 * "no value".
116 * 1–10 an en-dash between digits, which is a numeric range and the correct
117 * character for one. The rule bans the SEPARATOR en-dash, the one
118 * standing in for a comma or a full stop.
119 */
120for (const file of filesIn(resources, /\.(?:stx|html|ts)$/)) {
121 const source = withoutComments(readFileSync(file, 'utf8'))
122 for (const match of source.matchAll(/[\u2014\u2013]/g)) {
123 const before = source.slice(Math.max(0, match.index - 1), match.index)
124 const after = source.slice(match.index + 1, match.index + 2)
125 if (/['"`>]/.test(before) && /['"`<]/.test(after))
126 continue
127 if (match[0] === '\u2013' && /\d/.test(before) && /\d/.test(after))
128 continue
129 report(file, source, match.index, 'em-dash in user-visible copy: use a hyphen, a comma, or two sentences')
130 }
131}
132
95133if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined))
96134 issues.add('resources: no visible keyboard focus treatment found')
97135/*