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+62-3
Changes to config/crosswind.ts
@@ -1095,11 +1095,71 @@ export default {
10951095 + 'focus:outline-none focus:border-accent '
10961096 + 'focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_16%,transparent)]',
10971097
1098 /*
1099 * The union of the transition lists, not this app's first one.
1100 *
1101 * This entry animated transform and opacity only, so the accent fill it
1102 * sets on :hover SNAPPED. settings.stx's two solid buttons declared
1103 * `background 0.15s ease, opacity 0.15s ease` instead and eased it, which
1104 * is how the omission surfaced when they were folded in here. Each copy had
1105 * named the properties its own call site happened to animate; the union is
1106 * what all of them meant. bughq's btn-solid took the same union for the
1107 * same reason.
1108 */
10981109 'btn-solid': 'bg-accent text-accent-ink rounded-[10px] '
1099 + '[transition:transform_0.12s_ease,opacity_0.15s_ease] '
1110 + '[transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease] '
11001111 + 'hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] '
11011112 + 'active:translate-y-px disabled:opacity-60 disabled:cursor-default',
11021113
1114 /*
1115 * ## settings.stx's button vocabulary
1116 *
1117 * Three paints and four sizes, which is what its 23 raw `<button>`
1118 * elements actually were. Measured class string by class string: 14
1119 * distinct strings collapsed to these, with no string left over and no
1120 * paint invented. The four controls that are NOT ordinary buttons -- the
1121 * theme toggle (`icon-btn`), the channel switch (`tgl`) and the two
1122 * segment buttons (`seg`) -- keep their own shortcuts and are untouched.
1123 *
1124 * Marked `!important` throughout, and this is the reason rather than a
1125 * habit. The next step puts `<Button variant="ghost">` behind these, and
1126 * the component's own `variantClasses.ghost` and `sizeClasses.md` are
1127 * single-class selectors exactly like a shortcut: specificity ties, and
1128 * crosswind sorts its output by category rather than by generation order,
1129 * so an unmarked override lands PARTIALLY. Half an override is worse than
1130 * none, because it reads as working. bughq marked its family for the same
1131 * reason and the comment there has the measurement.
1132 *
1133 * Marked across the whole family, including the `:hover` steps: a marked
1134 * base outranks its own unmarked hover.
1135 *
1136 * The weight lives with the size, not the paint, because the two sizes
1137 * that exist in both weights differ ONLY in weight: `btn-md` is the
1138 * modal's Cancel and `btn-cta` is every primary action, and both are
1139 * px-4 py-2.5 text-sm.
1140 */
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))]',
1144
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] '
1147 + 'hover:!text-accent-ink hover:!bg-warn hover:!border-warn',
1148
1149 // The copy button that sits over a code block. Its own entry rather than a
1150 // size of btn-quiet: it is positioned, uses the sans stack against the
1151 // 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 '
1154 + '!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))]',
1157
1158 'btn-xs': '!px-2.5 !py-1.5 !text-xs !font-medium',
1159 'btn-sm': '!px-3 !py-2 !text-sm !font-medium',
1160 'btn-md': '!px-4 !py-2.5 !text-sm !font-medium',
1161 'btn-cta': '!px-4 !py-2.5 !text-sm !font-semibold',
1162
11031163 // Shared Button owns semantics and reactive state. These explicit
11041164 // overrides preserve the existing auth controls without depending on
11051165 // utility order or copying the component into the app.
@@ -1137,8 +1197,7 @@ export default {
11371197 *
11381198 * Kept apart from err-box and ok-box rather than folded into them, so the
11391199 * composite reads the same way in all three apps that have it: colour is
1140 * one class, geometry is another, and `class="mb-4 py-2 err-box
1141 * notice-box"` says which is which. It also means the confirmation and the
1200 * one class, geometry is another, and `class="mb-4 py-2 * err-box notice-box"` says which is which. It also means the confirmation and the
11421201 * error share a box instead of each carrying their own copy of it.
11431202 *
11441203 * The vertical padding stays at the call site: errors sit at py-2 and
resources/views/settings.stxmodified+19-19
Changes to resources/views/settings.stx
@@ -1276,12 +1276,12 @@ onMount(() => {
12761276 @if (guide.install)
12771277 <div class="relative mb-2">
12781278 <div class="overflow-x-auto pl-4 pr-16 py-3 font-mono leading-[1.5] text-[12px] text-muted bg-canvas border border-line rounded-[10px]">{{ guide.install }}</div>
1279 <button type="button" @click="copyInstall" class="absolute right-2 top-2 px-[9px] py-[5px] font-medium font-sans leading-none text-[11px] text-subtle hover:text-ink bg-panel border border-line rounded-[7px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] cursor-pointer [transition:color_0.15s_ease,border-color_0.15s_ease]" :text="copiedInstall() ? 'Copied' : 'Copy'">Copy</button>
1279 <button type="button" @click="copyInstall" class="btn-copy" :text="copiedInstall() ? 'Copied' : 'Copy'">Copy</button>
12801280 </div>
12811281 @endif
12821282 <div class="relative">
12831283 <div class="overflow-x-auto pl-4 pr-16 py-3 font-mono leading-[1.5] text-[12px] text-muted whitespace-pre bg-canvas border border-line rounded-[10px]">{{ guide.code }}</div>
1284 <button type="button" @click="copyCode" class="absolute right-2 top-2 px-[9px] py-[5px] font-medium font-sans leading-none text-[11px] text-subtle hover:text-ink bg-panel border border-line rounded-[7px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] cursor-pointer [transition:color_0.15s_ease,border-color_0.15s_ease]" :text="copiedCode() ? 'Copied' : 'Copy'">Copy</button>
1284 <button type="button" @click="copyCode" class="btn-copy" :text="copiedCode() ? 'Copied' : 'Copy'">Copy</button>
12851285 </div>
12861286 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
12871287 @if (guide.native)
@@ -1305,9 +1305,9 @@ onMount(() => {
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>
1308 <button @click="keyRevealed.set(!keyRevealed())" class="px-3 py-2 font-medium text-muted text-sm hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]" :text="keyRevealed() ? 'Hide' : 'Reveal'">Reveal</button>
1309 <button @click="copyKey" class="px-3 py-2 font-medium text-muted text-sm hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]" :text="copiedKey() ? 'Copied' : 'Copy'">Copy</button>
1310 <button @click="rotateKey" :disabled="rotating()" class="px-3 py-2 font-medium text-sm text-warn hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">Rotate</button>
1308 <button @click="keyRevealed.set(!keyRevealed())" class="btn-quiet btn-sm" :text="keyRevealed() ? 'Hide' : 'Reveal'">Reveal</button>
1309 <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>
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>).
@@ -1330,8 +1330,8 @@ onMount(() => {
13301330 <div :show="!!repo()">
13311331 <div class="flex flex-wrap gap-2 items-center mb-3.5">
13321332 <code class="flex-1 px-3.5 py-2.5 min-w-[220px] font-mono text-ink text-sm truncate bg-canvas border border-line rounded-[10px]" :text="repo()?.full_name">.</code>
1333 <button @click="verifyRepo" :disabled="repoBusy()" class="px-3 py-2 font-medium text-muted text-sm hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Verify</button>
1334 <button @click="disconnectRepo" :disabled="repoBusy()" class="px-3 py-2 font-medium text-sm text-warn hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">Disconnect</button>
1333 <button @click="verifyRepo" :disabled="repoBusy()" class="btn-quiet btn-sm">Verify</button>
1334 <button @click="disconnectRepo" :disabled="repoBusy()" class="btn-danger btn-sm">Disconnect</button>
13351335 </div>
13361336 <dl class="grid gap-y-1.5 grid-cols-2 sm:grid-cols-3 text-xs">
13371337 <div><dt class="text-subtle">Default branch</dt><dd class="font-mono text-muted" :text="repo()?.default_branch || 'unknown'">.</dd></div>
@@ -1348,7 +1348,7 @@ onMount(() => {
13481348 <label class="block mb-1.5 font-medium text-muted text-xs" for="repo-token">GitHub token</label>
13491349 <input id="repo-token" :model="repoToken" type="password" autocomplete="off" placeholder="github_pat_..." class="mb-3 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">
13501350
1351 <button @click="connectRepo" :disabled="repoBusy()" class="px-3.5 py-2.5 font-semibold text-sm btn-solid">Connect repository</button>
1351 <button @click="connectRepo" :disabled="repoBusy()" class="btn-cta btn-solid">Connect repository</button>
13521352
13531353 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
13541354 Use a <span class="text-muted">fine-grained</span> personal access token scoped to this one
@@ -1384,7 +1384,7 @@ 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 <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="px-2.5 py-1.5 font-medium text-warn text-xs hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]" x-tooltip="Remove">Remove</button>
1387 <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="btn-danger btn-xs" x-tooltip="Remove">Remove</button>
13881388 </div>
13891389 @endforeach
13901390 @foreach (inviteView as i)
@@ -1395,8 +1395,8 @@ onMount(() => {
13951395 <div class="mt-0.5 text-subtle text-xs">{{ i.registered ? 'Has an account — pending accept' : 'Invite sent — awaiting sign-up' }}</div>
13961396 </div>
13971397 <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>
1398 <button @click="copyJoin('', '')" :text="joinBtnLabel('')" class="px-2.5 py-1.5 font-medium text-muted text-xs hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]" x-tooltip="Copy join link">Copy link</button>
1399 <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="px-2.5 py-1.5 font-medium text-warn text-xs hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]" x-tooltip="Revoke invite">Remove</button>
1398 <button @click="copyJoin('', '')" :text="joinBtnLabel('')" class="btn-quiet btn-xs" x-tooltip="Copy join link">Copy link</button>
1399 <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="btn-danger btn-xs" x-tooltip="Revoke invite">Remove</button>
14001400 </div>
14011401 @endforeach
14021402 </div>
@@ -1404,7 +1404,7 @@ onMount(() => {
14041404 <div class="mb-3 font-semibold text-sm">Invite a teammate</div>
14051405 <div class="flex flex-wrap gap-2 items-center">
14061406 <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="px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px] disabled:opacity-55 disabled:cursor-default [transition:background_0.15s_ease,opacity_0.15s_ease]" :text="inviting() ? 'Inviting…' : 'Invite'">Invite</button>
1407 <button @click="addInvite" :disabled="inviting()" class="btn-cta btn-solid" :text="inviting() ? 'Inviting…' : 'Invite'">Invite</button>
14081408 </div>
14091409 <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>
14101410 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
@@ -1435,7 +1435,7 @@ onMount(() => {
14351435 <div class="font-medium text-sm truncate">{{ c.label }}</div>
14361436 <div class="mt-0.5 font-mono text-subtle text-xs truncate">{{ c.mask }}</div>
14371437 </div>
1438 <button @click="testChannel('')" :text="testLabel('')" class="px-2.5 py-1.5 font-medium text-muted text-xs hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]" x-tooltip="Send a test message">Test</button>
1438 <button @click="testChannel('')" :text="testLabel('')" class="btn-quiet btn-xs" x-tooltip="Send a test message">Test</button>
14391439 {{-- A real <button>, not a <div role="switch">. As a div this had
14401440 no tabindex, no key handler and no accessible name: measured
14411441 in a browser it reported focusable:false and ariaLabel:null,
@@ -1452,7 +1452,7 @@ onMount(() => {
14521452 match the component instead of the product. The a11y gap was
14531453 the defect; the platform element closes it. --}}
14541454 <button type="button" role="switch" aria-label="Enable " data-on="" aria-checked="" class="relative flex-none p-0 h-[22px] w-[38px] bg-line border-none rounded-[999px] appearance-none cursor-pointer [transition:background_0.15s_ease] tgl" @click="toggleChannel('', )" :data-on="isChannelOn('', ) ? '1' : '0'" :aria-checked="isChannelOn('', ) ? 'true' : 'false'" x-tooltip="Enable / disable"></button>
1455 <button @click="deleteChannel('')" :text="deleteLabel('')" class="px-2.5 py-1.5 font-medium text-warn text-xs hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]" x-tooltip="Remove">Remove</button>
1455 <button @click="deleteChannel('')" :text="deleteLabel('')" class="btn-danger btn-xs" x-tooltip="Remove">Remove</button>
14561456 </div>
14571457 @empty
14581458 <EmptyState title="No chat alerts yet" description="Add a Slack or Discord webhook below to start getting pinged." />
@@ -1467,7 +1467,7 @@ onMount(() => {
14671467 <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">
14681468 <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">
14691469 <div class="flex gap-3 items-center">
1470 <button @click="addChannel" :disabled="adding()" class="px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px] disabled:opacity-55 disabled:cursor-default [transition:background_0.15s_ease,opacity_0.15s_ease]" :text="adding() ? 'Adding…' : 'Add channel'">Add channel</button>
1470 <button @click="addChannel" :disabled="adding()" class="btn-cta btn-solid" :text="adding() ? 'Adding…' : 'Add channel'">Add channel</button>
14711471 <span class="text-ok text-xs" :show="!!whNote()" :class="whNoteOk() ? 'text-xs text-ok' : 'text-xs text-accent'">{{ whNote() }}</span>
14721472 </div>
14731473 <p :text="whHelp()" class="mt-3.5 leading-relaxed text-subtle text-xs">
@@ -1560,7 +1560,7 @@ onMount(() => {
15601560 <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>
15611561 @endif
15621562 </div>
1563 <button @click="openArchive" :disabled="archiving()" class="flex-none px-4 py-2.5 font-semibold text-sm text-warn whitespace-nowrap hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">{{ activeProject?.is_active === false ? 'Unarchive' : 'Archive' }}</button>
1563 <button @click="openArchive" :disabled="archiving()" class="flex-none whitespace-nowrap btn-cta btn-danger">{{ activeProject?.is_active === false ? 'Unarchive' : 'Archive' }}</button>
15641564 </div>
15651565 <div class="h-px bg-[color-mix(in_srgb,var(--warn)_20%,var(--border))]"></div>
15661566 {{-- Delete --}}
@@ -1569,7 +1569,7 @@ onMount(() => {
15691569 <div class="mb-0.5 font-semibold text-sm">Delete this project</div>
15701570 <p class="text-sm text-subtle">Permanently deletes <span class="font-mono text-muted">{{ activeProject?.name }}</span> and all of its log entries, alert channels, members, and invites. This cannot be undone.</p>
15711571 </div>
1572 <button @click="openDelete" class="flex-none px-4 py-2.5 font-semibold text-sm text-warn whitespace-nowrap hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">Delete</button>
1572 <button @click="openDelete" class="flex-none whitespace-nowrap btn-cta btn-danger">Delete</button>
15731573 </div>
15741574 </div>
15751575 </section>
@@ -1592,8 +1592,8 @@ onMount(() => {
15921592 <label class="block mb-1.5 text-subtle text-xs">Type <span class="font-mono text-muted">{{ projectName }}</span> to confirm</label>
15931593 <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">
15941594 <div class="flex gap-2 items-center justify-end">
1595 <button @click="closeModal" type="button" class="px-4 py-2.5 font-medium text-muted text-sm hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Cancel</button>
1596 <button @click="confirmModal" :disabled="!modalReady()" :text="modalConfirmLabel()" type="button" class="px-4 py-2.5 font-semibold text-sm text-warn hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">Confirm</button>
1595 <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>
15971597 </div>
15981598 <span class="mt-2.5 text-accent text-xs" :show="!!modalNote()">{{ modalNote() }}</span>
15991599 </div>
tests/unit/button-vocabulary.test.tsadded+133-0
Changes to tests/unit/button-vocabulary.test.ts
@@ -0,0 +1,133 @@
1/**
2 * settings.stx's button vocabulary: three paints, four sizes, one definition each.
3 *
4 * The page had 23 raw `<button>` elements carrying 14 distinct class strings,
5 * and those 14 were the same three paints at four sizes with nothing left over.
6 * They are `btn-quiet` / `btn-danger` / `btn-solid` plus `btn-xs` / `btn-sm` /
7 * `btn-md` / `btn-cta` in config/crosswind.ts now, which is what lets
8 * `<Button>` sit behind them without each call site re-stating its own paint.
9 *
10 * Two things this file pins, because neither fails loudly on its own.
11 *
12 * ## The paints must keep generating
13 *
14 * A shortcut that stops expanding emits nothing at all -- no error, no warning,
15 * an unpainted button. So the declarations are read back out of the engine the
16 * app actually renders with (stx's loadCssEngine + mergeCssConfig), not matched
17 * against the config's source text. A rule present in the config proves nothing
18 * about the sheet.
19 *
20 * ## The `!important` marks must stay
21 *
22 * `<Button variant="ghost">` brings its own `variantClasses.ghost` and
23 * `sizeClasses.md`, which are single-class selectors exactly like a shortcut.
24 * Specificity ties, and crosswind sorts its output by category rather than by
25 * generation order, so an unmarked override lands PARTIALLY -- the padding
26 * arrives and the colour does not, or the reverse. Half an override reads as
27 * working, which is why it is asserted rather than left to review.
28 *
29 * The four controls that are NOT ordinary buttons keep their own shortcuts and
30 * are listed here so the count below cannot quietly absorb them: the theme
31 * toggle (`icon-btn theme-btn`), the alert-channel switch (`tgl`) and the two
32 * webhook segment buttons (`seg seg-slack` / `seg seg-discord`). See the
33 * comments at those call sites for why the shipped `<Switch>` was not adopted.
34 */
35import { describe, expect, test } from 'bun:test'
36import { readFileSync } from 'node:fs'
37import { join } from 'node:path'
38import theme from '../../config/crosswind'
39// The installed STX package does not export these subpaths by bare specifier.
40import { loadCssEngine } from '../../node_modules/@stacksjs/stx/dist/dev-server/ts-css.js'
41import { mergeCssConfig } from '../../node_modules/@stacksjs/stx/dist/ts-css-config.js'
42
43const ROOT = join(import.meta.dir, '../..')
44const SETTINGS = readFileSync(join(ROOT, 'resources/views/settings.stx'), 'utf8')
45
46/** The markup, with prose stripped: comments here discuss classes they do not use. */
47const markup = SETTINGS
48 .replace(/\{\{--[\s\S]*?--\}\}/g, '')
49 .replace(/\/\*[\s\S]*?\*\//g, '')
50 .replace(/^[ \t]*\/\/.*$/gm, '')
51
52/** The controls that are deliberately not part of the vocabulary. */
53const NOT_BUTTONS = ['icon-btn', 'tgl', 'seg']
54
55async function generate(classes: string): Promise<string> {
56 const engine = await loadCssEngine()
57 if (!engine)
58 throw new Error('the installed STX CSS engine is required')
59 const merged = mergeCssConfig(engine.defaultConfig || engine.config, theme)
60 const generator = new engine.CSSGenerator(merged.config)
61 for (const className of classes.split(/\s+/).filter(Boolean))
62 generator.generate(className)
63 return generator.toCSS(false, false)
64}
65
66/** Whitespace-insensitive: stx pins minify false, so spacing varies. */
67const has = (css: string, needle: string): boolean =>
68 css.replace(/\s+/g, '').includes(needle.replace(/\s+/g, ''))
69
70describe('settings button vocabulary', () => {
71 test('every button in the page uses it, or is one of the four that do not', () => {
72 const offenders: string[] = []
73 for (const m of markup.matchAll(/<button\b([^>]*)>/g)) {
74 const classes = /\bclass="([^"]*)"/.exec(m[1])?.[1] ?? ''
75 if (NOT_BUTTONS.some(n => classes.split(/\s+/).includes(n)))
76 continue
77 // A paint and a size, and no re-stated padding or colour beside them.
78 const paint = /\bbtn-(quiet|danger|solid|copy)\b/.test(classes)
79 const inline = /\b(px-|py-|text-(xs|sm|base)\b|bg-panel|bg-accent\b|text-warn|text-muted)/.test(classes)
80 if (!paint || inline)
81 offenders.push(`line ${markup.slice(0, m.index).split('\n').length}: ${classes}`)
82 }
83 expect(offenders).toEqual([])
84 })
85
86 test('the three paints and the copy button generate their declarations', async () => {
87 // `text-muted` resolves through the theme to --text-2, and `text-ink` to
88 // --text: the token names in the markup are not the ones in the output, so
89 // these are the generated values rather than the authored ones.
90 const quiet = await generate('btn-quiet')
91 expect(has(quiet, 'color:var(--text-2)!important')).toBe(true)
92 expect(has(quiet, 'color:var(--text)!important')).toBe(true)
93 expect(has(quiet, 'border-radius:9px!important')).toBe(true)
94 expect(has(quiet, 'background-color:var(--panel)')).toBe(true)
95
96 const danger = await generate('btn-danger')
97 expect(has(danger, 'color:var(--warn)!important')).toBe(true)
98 expect(has(danger, 'border-radius:9px!important')).toBe(true)
99
100 const solid = await generate('btn-solid')
101 expect(has(solid, 'background-color:var(--accent)')).toBe(true)
102 expect(has(solid, 'color:var(--accent-ink)')).toBe(true)
103 // The union of the transition lists. Without background-color the accent
104 // fill this shortcut sets on :hover snaps instead of easing, which is what
105 // settings.stx's own two solid buttons had declared and this had not.
106 expect(has(solid, 'background-color0.15s')).toBe(true)
107
108 const copy = await generate('btn-copy')
109 expect(has(copy, 'border-radius:7px!important')).toBe(true)
110 expect(has(copy, 'position:absolute')).toBe(true)
111 })
112
113 test('the four sizes are padding plus type size plus weight, all marked', async () => {
114 const sizes: [string, string, string][] = [
115 ['btn-xs', '0.625rem', '0.375rem'],
116 ['btn-sm', '0.75rem', '0.5rem'],
117 ['btn-md', '1rem', '0.625rem'],
118 ['btn-cta', '1rem', '0.625rem'],
119 ]
120 for (const [name, px, py] of sizes) {
121 const css = await generate(name)
122 expect(`${name} px`).toBe(has(css, `padding-left:${px}!important`) ? `${name} px` : `${name} MISSING padding-left:${px}!important -> ${css}`)
123 expect(`${name} py`).toBe(has(css, `padding-top:${py}!important`) ? `${name} py` : `${name} MISSING padding-top:${py}!important`)
124 expect(`${name} weight`).toBe(/font-weight:\s*\d+\s*!important/.test(css) ? `${name} weight` : `${name} MISSING marked font-weight`)
125 }
126 // btn-md and btn-cta are the same box and differ ONLY in weight, which is
127 // why the weight lives with the size rather than with the paint.
128 const md = await generate('btn-md')
129 const cta = await generate('btn-cta')
130 expect(has(md, 'font-weight:500!important')).toBe(true)
131 expect(has(cta, 'font-weight:600!important')).toBe(true)
132 })
133})