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.

config/crosswind.tsmodified+54-13
Changes to config/crosswind.ts
@@ -1106,10 +1106,10 @@ export default {
11061106 * what all of them meant. bughq's btn-solid took the same union for the
11071107 * same reason.
11081108 */
1109 'btn-solid': 'bg-accent text-accent-ink rounded-[10px] '
1110 + '[transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease] '
1111 + 'hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] '
1112 + 'active:translate-y-px disabled:opacity-60 disabled:cursor-default',
1109 'btn-solid': 'btn-focus bg-accent !text-accent-ink !rounded-[10px] '
1110 + '![transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease] '
1111 + 'hover:!bg-[color-mix(in_srgb,var(--accent)_88%,#000)] '
1112 + 'active:translate-y-px disabled:!opacity-60 disabled:!cursor-default',
11131113
11141114 /*
11151115 * ## settings.stx's button vocabulary
@@ -1138,22 +1138,63 @@ export default {
11381138 * modal's Cancel and `btn-cta` is every primary action, and both are
11391139 * px-4 py-2.5 text-sm.
11401140 */
1141 'btn-quiet': 'bg-panel border border-line !text-muted '
1142 + '!rounded-[9px] [transition:color_0.15s_ease,border-color_0.15s_ease] '
1143 + 'hover:!text-ink hover:!border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]',
1141 'btn-quiet': 'btn-focus btn-disabled bg-panel border border-line !text-muted '
1142 + '!rounded-[9px] ![transition:color_0.15s_ease,border-color_0.15s_ease] '
1143 + 'hover:!bg-panel hover:!text-ink '
1144 + 'hover:!border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]',
11441145
1145 'btn-danger': 'bg-panel border !border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] '
1146 + '!text-warn !rounded-[9px] [transition:all_0.15s_ease] '
1146 'btn-danger': 'btn-focus btn-disabled bg-panel border !border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] '
1147 + '!text-warn !rounded-[9px] ![transition:all_0.15s_ease] '
11471148 + 'hover:!text-accent-ink hover:!bg-warn hover:!border-warn',
11481149
11491150 // The copy button that sits over a code block. Its own entry rather than a
11501151 // size of btn-quiet: it is positioned, uses the sans stack against the
11511152 // mono block it overlays, and is the only control in the file at 11px.
1152 'btn-copy': 'absolute right-2 top-2 bg-panel border border-line '
1153 + '!px-[9px] !py-[5px] !text-[11px] !font-medium font-sans leading-none '
1153 'btn-copy': 'btn-focus btn-disabled absolute right-2 top-2 bg-panel border border-line '
1154 + '!px-[9px] !py-[5px] !text-[11px] !font-medium font-sans !leading-none '
11541155 + '!text-subtle cursor-pointer !rounded-[7px] '
1155 + '[transition:color_0.15s_ease,border-color_0.15s_ease] '
1156 + 'hover:!text-ink hover:!border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]',
1156 + '![transition:color_0.15s_ease,border-color_0.15s_ease] '
1157 + 'hover:!bg-panel hover:!text-ink '
1158 + 'hover:!border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]',
1159
1160 /*
1161 * The app's own focus treatment, restored over <Button>'s.
1162 *
1163 * The component declares `focus:outline-none focus:ring-2 ...`, and a
1164 * utility is unlayered while this app's `button:focus-visible { outline:
1165 * 2px solid var(--accent) }` is a preflight in @layer tc-base -- so the
1166 * component's ring wins at any specificity and the app's outline simply
1167 * stops arriving. The two look nearly identical, but the component's fires
1168 * on `:focus` and the app's on `:focus-visible`, so adopting <Button>
1169 * would have put a ring on nine buttons after a MOUSE click while every
1170 * other control in the app rings only for the keyboard.
1171 *
1172 * `focus:!ring-0` cancels the component's ring and the `:focus-visible`
1173 * pair restores the preflight's rule at utility level, where it outranks
1174 * the component.
1175 *
1176 * This spelling -- a variant on an arbitrary property -- emitted NOTHING
1177 * until ts-css 0.3.16 (stacksjs/ts-css#36 for the generator, #37 for the
1178 * extractor). It is the first place in this app that needed it, and it was
1179 * measured through this app's own loader before being relied on.
1180 */
1181 'btn-focus': 'focus:!ring-0 '
1182 + 'focus-visible:![outline:2px_solid_var(--accent)] '
1183 + 'focus-visible:![outline-offset:2px]',
1184
1185 /*
1186 * One disabled appearance for every button.
1187 *
1188 * <Button> brings `disabled:opacity-50 disabled:cursor-not-allowed`, and
1189 * btn-solid already declared 60% with a default cursor. Rather than let
1190 * three paints disagree, all of them take the 60% that btn-solid's call
1191 * sites have used all along.
1192 *
1193 * This is also a gain on the quiet and danger paints, which previously had
1194 * no disabled styling at all: `:disabled="rotating()"` dimmed nothing, so a
1195 * disabled Rotate button looked exactly like an enabled one.
1196 */
1197 'btn-disabled': 'disabled:!opacity-60 disabled:!cursor-default',
11571198
11581199 'btn-xs': '!px-2.5 !py-1.5 !text-xs !font-medium',
11591200 'btn-sm': '!px-3 !py-2 !text-sm !font-medium',
resources/views/settings.stxmodified+28-5
Changes to resources/views/settings.stx
@@ -1307,7 +1307,7 @@ onMount(() => {
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>
13081308 <button @click="keyRevealed.set(!keyRevealed())" class="btn-quiet btn-sm" :text="keyRevealed() ? 'Hide' : 'Reveal'">Reveal</button>
13091309 <button @click="copyKey" class="btn-quiet btn-sm" :text="copiedKey() ? 'Copied' : 'Copy'">Copy</button>
1310 <button @click="rotateKey" :disabled="rotating()" class="btn-danger btn-sm">Rotate</button>
1310 <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">
13131313 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>).
@@ -1384,6 +1384,29 @@ onMount(() => {
13841384 <div class="mt-0.5 text-subtle text-xs">Can read the log stream</div>
13851385 </div>
13861386 <span class="px-[9px] py-0.5 font-semibold text-[11px] text-accent tracking-[0.02em] bg-[color-mix(in_srgb,var(--accent)_14%,var(--panel))] rounded-[999px]">Member</span>
1387 {{-- STILL A PLAIN BUTTON, and the reason is x-tooltip.
1388
1389 This is one of four buttons in the file that hand-roll busy
1390 state and would otherwise take <Button> like the five that
1391 did: these two Removes, the channel Test and the channel
1392 Remove. An `x-tooltip` on a COMPONENT never reaches the DOM,
1393 so adopting here would delete the tooltip without any sign.
1394
1395 Measured on the installed 0.2.532, not read off a changelog.
1396 `x-tooltip` IS in COMPONENT_PASSTHROUGH_X_ATTRS now, so it is
1397 no longer rewritten into a `:tooltip` signal binding -- that
1398 is the crash dashboard.stx documents, and it is fixed. What
1399 remains is quieter: processComponents emits the attribute only
1400 inside `data-stx-props`, while the tooltip runtime reads
1401 `el.getAttribute('x-tooltip')`. `title` and `aria-label` both
1402 forward to the concrete root correctly (stx#2063);
1403 `x-tooltip` falls in the gap between the two.
1404
1405 `title` instead would work and would be a different tooltip --
1406 the browser's, not the app's. Not worth trading a consistent
1407 surface for a spinner. Filed upstream; revisit when it lands.
1408 tests/unit/button-vocabulary.test.ts fails if a <Button> here
1409 ever gets an x-tooltip. --}}
13871410 <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="btn-danger btn-xs" x-tooltip="Remove">Remove</button>
13881411 </div>
13891412 @endforeach
@@ -1404,7 +1427,7 @@ onMount(() => {
14041427 <div class="mb-3 font-semibold text-sm">Invite a teammate</div>
14051428 <div class="flex flex-wrap gap-2 items-center">
14061429 <input :model="inviteEmail" type="email" aria-label="Invite a teammate by email" class="flex-1 px-3.5 py-2.5 min-w-[220px] 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="teammate@company.com" autocomplete="off">
1407 <button @click="addInvite" :disabled="inviting()" class="btn-cta btn-solid" :text="inviting() ? 'Inviting…' : 'Invite'">Invite</button>
1430 <Button variant="ghost" @click="addInvite" :loading="inviting()" className="btn-cta btn-solid">Invite</Button>
14081431 </div>
14091432 <span class="mt-2.5 text-ok text-xs" :show="!!inviteNote()" :class="inviteNoteOk() ? 'mt-2.5 text-xs text-ok' : 'mt-2.5 text-xs text-accent'">{{ inviteNote() }}</span>
14101433 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
@@ -1467,7 +1490,7 @@ onMount(() => {
14671490 <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">
14681491 <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">
14691492 <div class="flex gap-3 items-center">
1470 <button @click="addChannel" :disabled="adding()" class="btn-cta btn-solid" :text="adding() ? 'Adding…' : 'Add channel'">Add channel</button>
1493 <Button variant="ghost" @click="addChannel" :loading="adding()" className="btn-cta btn-solid">Add channel</Button>
14711494 <span class="text-ok text-xs" :show="!!whNote()" :class="whNoteOk() ? 'text-xs text-ok' : 'text-xs text-accent'">{{ whNote() }}</span>
14721495 </div>
14731496 <p :text="whHelp()" class="mt-3.5 leading-relaxed text-subtle text-xs">
@@ -1560,7 +1583,7 @@ onMount(() => {
15601583 <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>
15611584 @endif
15621585 </div>
1563 <button @click="openArchive" :disabled="archiving()" class="flex-none whitespace-nowrap btn-cta btn-danger">{{ activeProject?.is_active === false ? 'Unarchive' : 'Archive' }}</button>
1586 <Button variant="ghost" @click="openArchive" :loading="archiving()" className="flex-none whitespace-nowrap btn-cta btn-danger">{{ activeProject?.is_active === false ? 'Unarchive' : 'Archive' }}</Button>
15641587 </div>
15651588 <div class="h-px bg-[color-mix(in_srgb,var(--warn)_20%,var(--border))]"></div>
15661589 {{-- Delete --}}
@@ -1593,7 +1616,7 @@ onMount(() => {
15931616 <input :model="modalInput" type="text" :aria-label="modalTitle()" aria-label="Type the project name to confirm" class="mb-4 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" autocomplete="off" autocapitalize="off" spellcheck="false">
15941617 <div class="flex gap-2 items-center justify-end">
15951618 <button @click="closeModal" type="button" class="btn-md btn-quiet">Cancel</button>
1596 <button @click="confirmModal" :disabled="!modalReady()" :text="modalConfirmLabel()" type="button" class="btn-cta btn-danger">Confirm</button>
1619 <Button type="button" variant="ghost" @click="confirmModal" :disabled="!modalReady()" :loading="modalBusy()" :label="modalConfirmLabel()" className="btn-cta btn-danger">Confirm</Button>
15971620 </div>
15981621 <span class="mt-2.5 text-accent text-xs" :show="!!modalNote()">{{ modalNote() }}</span>
15991622 </div>
tests/unit/button-vocabulary.test.tsmodified+95-1
Changes to tests/unit/button-vocabulary.test.ts
@@ -34,9 +34,10 @@
3434 */
3535import { describe, expect, test } from 'bun:test'
3636import { readFileSync } from 'node:fs'
37import { join } from 'node:path'
37import { join, resolve } from 'node:path'
3838import theme from '../../config/crosswind'
3939// The installed STX package does not export these subpaths by bare specifier.
40import { processComponents } from '../../node_modules/@stacksjs/stx/dist/component-renderer.js'
4041import { loadCssEngine } from '../../node_modules/@stacksjs/stx/dist/dev-server/ts-css.js'
4142import { mergeCssConfig } from '../../node_modules/@stacksjs/stx/dist/ts-css-config.js'
4243
@@ -131,3 +132,96 @@ describe('settings button vocabulary', () => {
131132 expect(has(cta, 'font-weight:600!important')).toBe(true)
132133 })
133134})
135
136/**
137 * The five call sites that took <Button>, and what each one gained.
138 *
139 * All five hand-rolled `:disabled="someBusy()"` and announced nothing: the
140 * served markup carried no aria-busy, and three of them also swapped their own
141 * label through `:text`, which is the spelling Button's `:label` prop exists to
142 * replace (stacksjs/stx#1997 -- `:text` writes textContent and would erase the
143 * spinner).
144 *
145 * Rendered through stx's own component renderer rather than asserted from the
146 * source, because the failure mode is not a thrown error. A component that
147 * cannot resolve ships its RAW TEMPLATE to the browser while every source-level
148 * check stays green (see [[stx-build-vs-serve-divergence]]), so the only honest
149 * assertion is on the HTML.
150 */
151describe('settings Button adoption', () => {
152 const SITES = [
153 { paint: 'btn-danger btn-sm', name: 'Rotate', bindings: ['loading'] },
154 { paint: 'btn-cta btn-solid', name: 'Invite', bindings: ['loading'] },
155 { paint: 'btn-cta btn-solid', name: 'Add channel', bindings: ['loading'] },
156 { paint: 'flex-none whitespace-nowrap btn-cta btn-danger', name: '', bindings: ['loading'] },
157 // The type-to-confirm gate is independent of busy state, so this one is the
158 // documented three-prop combination: a reactive label, a reactive loading
159 // flag, and a `disabled` that the gate owns.
160 { paint: 'btn-cta btn-danger', name: 'Confirm', bindings: ['disabled', 'loading', 'label'] },
161 ]
162
163 const FILE = join(ROOT, 'resources/views/settings.stx')
164 // `markup`, not the raw source: the comment above the members-list Remove
165 // button explains why that button is NOT a <Button>, and writing the tag in
166 // prose made this match start inside the comment and run to the first real
167 // </Button>, swallowing half the file. Strip comments before matching markup.
168 const calls = markup.match(/<Button\b[\s\S]*?<\/Button>/g) ?? []
169
170 test('there are exactly five, and none of them re-states a paint', () => {
171 expect(calls).toHaveLength(SITES.length)
172 for (const [i, call] of calls.entries()) {
173 expect(/className="([^"]*)"/.exec(call)?.[1]).toBe(SITES[i].paint)
174 // The paint comes from the vocabulary, so a call site has no utilities of
175 // its own beyond the layout ones the modal row needs.
176 expect(call).not.toMatch(/className="[^"]*\b(px-|py-|bg-panel|text-warn)/)
177 // `:text` on the root would destroy the spinner and the label span.
178 expect(call).not.toContain(':text=')
179 }
180 })
181
182 test('each renders a real button that announces its busy state', async () => {
183 for (const [i, call] of calls.entries()) {
184 const dependencies = new Set<string>()
185 const html = await processComponents(call, {
186 __stx_client_signal_names: ['rotating', 'inviting', 'adding', 'archiving', 'modalBusy', 'modalReady', 'modalConfirmLabel'],
187 __importedComponents: new Map([['button', resolve(ROOT, 'node_modules/@stacksjs/components/src/ui/button/Button.stx')]]),
188 }, FILE, { componentsDir: resolve(ROOT, 'resources/components') }, dependencies)
189
190 const where = `${SITES[i].paint}:`
191 expect(`${where} resolved`).toBe(html.includes('<Button') || html.includes('[Component Error') ? `${where} RAW TEMPLATE SHIPPED` : `${where} resolved`)
192 expect(`${where} aria-busy`).toBe(/aria-busy="(?:true|false)"/.test(html) ? `${where} aria-busy` : `${where} MISSING aria-busy`)
193 expect(`${where} spinner`).toBe(html.includes('animate-spin') ? `${where} spinner` : `${where} MISSING spinner`)
194 // The paint must reach the concrete root, not sit in data-stx-props.
195 const rootClass = /<button[^>]*class="([^"]*)"/.exec(html)?.[1] ?? ''
196 for (const token of SITES[i].paint.split(' '))
197 expect(`${where} ${token} on root`).toBe(rootClass.includes(token) ? `${where} ${token} on root` : `${where} ${token} NOT on root`)
198 // And the live bindings must be recorded, or the prop is a server snapshot.
199 const bindings = (/data-stx-parent-bindings="([^"]*)"/.exec(html)?.[1] ?? '').split(/\s+/).filter(Boolean)
200 expect(bindings.sort()).toEqual([...SITES[i].bindings].sort())
201 if (SITES[i].name)
202 expect(html).toContain(SITES[i].name)
203 }
204 })
205
206 test('<Button> is not used where it would silently drop an x-tooltip', () => {
207 /*
208 * Four more sites hand-roll busy state and are deliberately still plain
209 * buttons: the two Remove buttons in the members list, the channel Test
210 * button and the channel Remove button. Each carries `x-tooltip`, and an
211 * `x-tooltip` on a COMPONENT never reaches the DOM.
212 *
213 * Measured on the installed 0.2.532 rather than read: `x-tooltip` IS in
214 * COMPONENT_PASSTHROUGH_X_ATTRS now, so it is no longer rewritten into a
215 * `:tooltip` signal binding (the old crash dashboard.stx documents). But
216 * processComponents still emits it only inside `data-stx-props`, and the
217 * tooltip runtime reads `el.getAttribute('x-tooltip')`, so the tooltip just
218 * disappears. `title` and `aria-label` forward correctly; `x-tooltip` sits
219 * in the gap.
220 *
221 * This test exists so the four are not "finished" by someone who does not
222 * hit the silence. Filed upstream.
223 */
224 const tooltipped = [...markup.matchAll(/<Button\b[^>]*>/g)].filter(m => m[0].includes('x-tooltip'))
225 expect(tooltipped.map(m => m[0])).toEqual([])
226 })
227})