also looking at this
Promote main to production: signed-out chrome and the subscriber tables
#17
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.
| @@ -203,7 +203,7 @@ async function subscribe() { | ||
| 203 | 203 | required |
| 204 | 204 | x-model="email" |
| 205 | 205 | > |
| 206 | <button class="btn primary" type="submit" :disabled="subscribeMutation.loading()" :text="subscribeMutation.loading() ? 'Sending' : 'Keep me posted'">Keep me posted</button> | |
| 206 | <button class="btn primary" type="submit" :disabled="subscribeMutation.loading()" :aria-busy="subscribeMutation.loading()" :text="subscribeMutation.loading() ? 'Sending' : 'Keep me posted'">Keep me posted</button> | |
| 207 | 207 | </form> |
| 208 | 208 | <p class="sub-status" role="status" aria-live="polite" :data-state="failed() ? 'error' : ''" :text="status()"></p> |
| 209 | 209 | </div> |
| @@ -163,7 +163,7 @@ async function onVerify(e: Event): Promise<void> { | ||
| 163 | 163 | <span>Keep me signed in for 30 days</span> |
| 164 | 164 | </label> |
| 165 | 165 | |
| 166 | <Button type="submit" size="sm" fullWidth :disabled="loading()" :label="loading() ? 'Signing in…' : 'Sign in'" className="auth-submit">Sign in</Button> | |
| 166 | <Button type="submit" size="sm" fullWidth :loading="loading()" :label="loading() ? 'Signing in…' : 'Sign in'" className="auth-submit">Sign in</Button> | |
| 167 | 167 | </form> |
| 168 | 168 | |
| 169 | 169 | {{-- Step 2: TOTP, shown only when the account has 2FA enabled. --}} |
| @@ -173,7 +173,7 @@ async function onVerify(e: Event): Promise<void> { | ||
| 173 | 173 | <label for="login-code" class="block mb-1.5 font-medium text-muted text-xs">Authentication code</label> |
| 174 | 174 | <input id="login-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="123456" :model="code" class="px-3.5 py-2.5 w-full text-sm field" :aria-invalid="error() ? 'true' : 'false'" :aria-describedby="error() ? 'login-error' : ''"> |
| 175 | 175 | </div> |
| 176 | <Button type="submit" size="sm" fullWidth :disabled="loading()" :label="loading() ? 'Verifying…' : 'Verify and sign in'" className="auth-submit">Verify and sign in</Button> | |
| 176 | <Button type="submit" size="sm" fullWidth :loading="loading()" :label="loading() ? 'Verifying…' : 'Verify and sign in'" className="auth-submit">Verify and sign in</Button> | |
| 177 | 177 | </form> |
| 178 | 178 | |
| 179 | 179 | @if (oauthError) |
| @@ -125,7 +125,7 @@ async function upgrade(): Promise<void> { | ||
| 125 | 125 | <li class="flex gap-2 items-center"><span class="text-accent">✓</span> Full retention</li> |
| 126 | 126 | <li class="flex gap-2 items-center"><span class="text-accent">✓</span> Priority support</li> |
| 127 | 127 | </ul> |
| 128 | <button @click="upgrade" :disabled="checkout.loading()" class="mt-7 py-2.5 w-full font-semibold text-accent-ink text-sm bg-accent rounded-[10px] disabled:opacity-60 active:translate-y-px disabled:cursor-default [transition:transform_0.12s_ease,opacity_0.15s_ease]" :text="checkout.loading() ? 'Redirecting…' : 'Upgrade to Pro'">Upgrade to Pro</button> | |
| 128 | <button @click="upgrade" :disabled="checkout.loading()" :aria-busy="checkout.loading()" class="mt-7 py-2.5 w-full font-semibold text-accent-ink text-sm bg-accent rounded-[10px] disabled:opacity-60 active:translate-y-px disabled:cursor-default [transition:transform_0.12s_ease,opacity_0.15s_ease]" :text="checkout.loading() ? 'Redirecting…' : 'Upgrade to Pro'">Upgrade to Pro</button> | |
| 129 | 129 | @if (err()) |
| 130 | 130 | <p class="mt-3 text-[#ef4444] text-center text-xs">{{ err() }}</p> |
| 131 | 131 | @endif |
| @@ -112,7 +112,7 @@ async function onRegister(e: Event): Promise<void> { | ||
| 112 | 112 | <input id="register-password" name="password" type="password" required autocomplete="new-password" placeholder="At least 8 characters" :model="password" class="px-3.5 py-2.5 w-full text-sm field" :aria-invalid="error() ? 'true' : 'false'" :aria-describedby="error() ? 'form-error' : ''"> |
| 113 | 113 | </div> |
| 114 | 114 | |
| 115 | <Button type="submit" size="sm" fullWidth :disabled="loading()" :label="loading() ? 'Creating account…' : 'Create account'" className="auth-submit">Create account</Button> | |
| 115 | <Button type="submit" size="sm" fullWidth :loading="loading()" :label="loading() ? 'Creating account…' : 'Create account'" className="auth-submit">Create account</Button> | |
| 116 | 116 | </form> |
| 117 | 117 | |
| 118 | 118 | @if (anyOauth) |
| @@ -722,6 +722,14 @@ const repo = state<any>(repository || null) | ||
| 722 | 722 | const repoRef = state('') |
| 723 | 723 | const repoToken = state('') |
| 724 | 724 | const repoBusy = state(false) |
| 725 | /* | |
| 726 | * Which repository button is working. `repoBusy` gates all three, which is right | |
| 727 | * for :disabled -- none of them should be clickable while any runs -- and wrong | |
| 728 | * for aria-busy, which names ONE element as updating. Verify and Disconnect sit | |
| 729 | * in the same `:show="!!repo()"` block, so with the shared signal on both, | |
| 730 | * verifying would announce Disconnect as busy too. | |
| 731 | */ | |
| 732 | const repoAction = state('') | |
| 725 | 733 | const repoNote = state('') |
| 726 | 734 | const repoNoteOk = state(true) |
| 727 | 735 | |
| @@ -733,10 +741,12 @@ async function connectRepo(): Promise<void> { | ||
| 733 | 741 | return |
| 734 | 742 | } |
| 735 | 743 | repoBusy.set(true) |
| 744 | repoAction.set('connect') | |
| 736 | 745 | try { |
| 737 | 746 | const res = await apiFetch(`${projUrl}/repository`, { method: 'POST', body: JSON.stringify({ repository: ref, token }) }) |
| 738 | 747 | const body = await readJson(res) |
| 739 | 748 | repoBusy.set(false) |
| 749 | repoAction.set('') | |
| 740 | 750 | if (!res.ok) { |
| 741 | 751 | note(repoNote, repoNoteOk, body.error || 'Could not connect.', false) |
| 742 | 752 | return |
| @@ -749,16 +759,19 @@ async function connectRepo(): Promise<void> { | ||
| 749 | 759 | } |
| 750 | 760 | catch { |
| 751 | 761 | repoBusy.set(false) |
| 762 | repoAction.set('') | |
| 752 | 763 | note(repoNote, repoNoteOk, 'Network error. Try again.', false) |
| 753 | 764 | } |
| 754 | 765 | } |
| 755 | 766 | |
| 756 | 767 | async function verifyRepo(): Promise<void> { |
| 757 | 768 | repoBusy.set(true) |
| 769 | repoAction.set('verify') | |
| 758 | 770 | try { |
| 759 | 771 | const res = await apiFetch(`${projUrl}/repository/verify`, { method: 'POST' }) |
| 760 | 772 | const body = await readJson(res) |
| 761 | 773 | repoBusy.set(false) |
| 774 | repoAction.set('') | |
| 762 | 775 | if (!res.ok) { |
| 763 | 776 | note(repoNote, repoNoteOk, body.error || 'Verification failed.', false) |
| 764 | 777 | return |
| @@ -768,6 +781,7 @@ async function verifyRepo(): Promise<void> { | ||
| 768 | 781 | } |
| 769 | 782 | catch { |
| 770 | 783 | repoBusy.set(false) |
| 784 | repoAction.set('') | |
| 771 | 785 | note(repoNote, repoNoteOk, 'Network error. Try again.', false) |
| 772 | 786 | } |
| 773 | 787 | } |
| @@ -779,9 +793,11 @@ async function disconnectRepo(): Promise<void> { | ||
| 779 | 793 | if (!(await Promise.resolve(ask))) |
| 780 | 794 | return |
| 781 | 795 | repoBusy.set(true) |
| 796 | repoAction.set('disconnect') | |
| 782 | 797 | try { |
| 783 | 798 | const res = await apiFetch(`${projUrl}/repository`, { method: 'DELETE' }) |
| 784 | 799 | repoBusy.set(false) |
| 800 | repoAction.set('') | |
| 785 | 801 | if (!res.ok) { |
| 786 | 802 | const body = await readJson(res) |
| 787 | 803 | note(repoNote, repoNoteOk, body.error || 'Could not disconnect.', false) |
| @@ -792,6 +808,7 @@ async function disconnectRepo(): Promise<void> { | ||
| 792 | 808 | } |
| 793 | 809 | catch { |
| 794 | 810 | repoBusy.set(false) |
| 811 | repoAction.set('') | |
| 795 | 812 | note(repoNote, repoNoteOk, 'Network error. Try again.', false) |
| 796 | 813 | } |
| 797 | 814 | } |
| @@ -1460,8 +1477,8 @@ onMount(() => { | ||
| 1460 | 1477 | <div :show="!!repo()"> |
| 1461 | 1478 | <div class="flex flex-wrap gap-2 items-center mb-3.5"> |
| 1462 | 1479 | <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> |
| 1463 | <button @click="verifyRepo" :disabled="repoBusy()" class="btn-quiet btn-sm">Verify</button> | |
| 1464 | <button @click="disconnectRepo" :disabled="repoBusy()" class="btn-danger btn-sm">Disconnect</button> | |
| 1480 | <button @click="verifyRepo" :disabled="repoBusy()" :aria-busy="repoAction() === 'verify'" class="btn-quiet btn-sm">Verify</button> | |
| 1481 | <button @click="disconnectRepo" :disabled="repoBusy()" :aria-busy="repoAction() === 'disconnect'" class="btn-danger btn-sm">Disconnect</button> | |
| 1465 | 1482 | </div> |
| 1466 | 1483 | <dl class="grid gap-y-1.5 grid-cols-2 sm:grid-cols-3 text-xs"> |
| 1467 | 1484 | <div><dt class="text-subtle">Default branch</dt><dd class="font-mono text-muted" :text="repo()?.default_branch || 'unknown'">.</dd></div> |
| @@ -1478,7 +1495,7 @@ onMount(() => { | ||
| 1478 | 1495 | <label class="block mb-1.5 font-medium text-muted text-xs" for="repo-token">GitHub token</label> |
| 1479 | 1496 | <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"> |
| 1480 | 1497 | |
| 1481 | <button @click="connectRepo" :disabled="repoBusy()" class="btn-cta btn-solid">Connect repository</button> | |
| 1498 | <button @click="connectRepo" :disabled="repoBusy()" :aria-busy="repoAction() === 'connect'" class="btn-cta btn-solid">Connect repository</button> | |
| 1482 | 1499 | |
| 1483 | 1500 | <p class="mt-3.5 leading-relaxed text-subtle text-xs"> |
| 1484 | 1501 | Use a <span class="text-muted">fine-grained</span> personal access token scoped to this one |
| @@ -1538,7 +1555,7 @@ onMount(() => { | ||
| 1538 | 1555 | when it lands. |
| 1539 | 1556 | tests/unit/button-vocabulary.test.ts fails if a <Button> here |
| 1540 | 1557 | ever gets an x-tooltip. --}} |
| 1541 | <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="btn-danger btn-xs" x-tooltip="Remove">Remove</button> | |
| 1558 | <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" :aria-busy="removingMember() === ''" class="btn-danger btn-xs" x-tooltip="Remove">Remove</button> | |
| 1542 | 1559 | </div> |
| 1543 | 1560 | @endforeach |
| 1544 | 1561 | @foreach (inviteView as i) |
| @@ -1550,7 +1567,7 @@ onMount(() => { | ||
| 1550 | 1567 | </div> |
| 1551 | 1568 | <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> |
| 1552 | 1569 | <button @click="copyJoin('', '')" :text="joinBtnLabel('')" class="btn-quiet btn-xs" x-tooltip="Copy join link">Copy link</button> |
| 1553 | <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="btn-danger btn-xs" x-tooltip="Revoke invite">Remove</button> | |
| 1570 | <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" :aria-busy="removingMember() === ''" class="btn-danger btn-xs" x-tooltip="Revoke invite">Remove</button> | |
| 1554 | 1571 | </div> |
| 1555 | 1572 | @endforeach |
| 1556 | 1573 | </div> |
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 2 | * A control that is working has to SAY it is working. | |
| 3 | * | |
| 4 | * Every async button here gates itself on a busy signal through `:disabled`, | |
| 5 | * which stops a second click and greys the control. For a screen reader that is | |
| 6 | * not the same thing: `disabled` removes the button from the focus order, so | |
| 7 | * someone who just activated it loses their place and hears nothing about why. | |
| 8 | * | |
| 9 | * Two vehicles, and which one is right depends on the element: | |
| 10 | * | |
| 11 | * - A plain `<button>` takes `:aria-busy`. An ATTRIBUTE binding, deliberately, | |
| 12 | * because a reactive mustache in a button's own text makes stx cloak the | |
| 13 | * element until hydration. stx special-cases `aria-*` in dist/signals.js | |
| 14 | * (`attrName.startsWith('aria-') && typeof v === 'boolean'` writes `String(v)`) | |
| 15 | * so a boolean emits `aria-busy="true"` rather than the bare `aria-busy=""` | |
| 16 | * the ordinary boolean path writes, and an empty value reads as FALSE. | |
| 17 | * - A `<Button>` takes `:loading`. The component writes `aria-busy` and shows a | |
| 18 | * spinner from that prop and computes `isDisabled = disabled || loading`, so | |
| 19 | * passing `:aria-busy` from here would fight its own binding. | |
| 20 | * | |
| 21 | * The pairing with the signal matters as much as the attribute. `aria-busy` | |
| 22 | * means "this element is being updated", so one signal cannot drive two buttons | |
| 23 | * that are on screen together: verifying a repository would otherwise announce | |
| 24 | * Disconnect as busy too. That is what `repoAction` is for. | |
| 25 | */ | |
| 26 | import { describe, expect, test } from 'bun:test' | |
| 27 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 28 | import { join } from 'node:path' | |
| 29 | ||
| 30 | const ROOT = join(import.meta.dir, '../..') | |
| 31 | const BUSY = /busy|loading|saving|sending|submitting|removing|deleting/i | |
| 32 | ||
| 33 | function views(dir: string): string[] { | |
| 34 | const out: string[] = [] | |
| 35 | for (const entry of readdirSync(dir, { withFileTypes: true })) { | |
| 36 | const path = join(dir, entry.name) | |
| 37 | let directory = entry.isDirectory() | |
| 38 | if (entry.isSymbolicLink()) { | |
| 39 | try { directory = statSync(path).isDirectory() } | |
| 40 | catch { continue } | |
| 41 | } | |
| 42 | if (directory) out.push(...views(path)) | |
| 43 | else if (entry.name.endsWith('.stx')) out.push(path) | |
| 44 | } | |
| 45 | return out | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Comments, all three kinds, because prose about a shape matches a grep for it. | |
| 50 | * The block above `repoAction` says "repoBusy gates all three", and that | |
| 51 | * sentence is exactly what the mutation-count assertion would miscount. | |
| 52 | */ | |
| 53 | function code(source: string): string { | |
| 54 | return source | |
| 55 | .replace(/\{\{--[\s\S]*?--\}\}/g, '') | |
| 56 | .replace(/\/\*[\s\S]*?\*\//g, '') | |
| 57 | .replace(/(^|[^:])\/\/[^\n]*/g, '$1') | |
| 58 | } | |
| 59 | ||
| 60 | /** Open tags only, with quoted values kept whole so a `>` inside one cannot end the tag. */ | |
| 61 | const OPEN_TAG = /<(button|Button)\b((?:[^>"]|"[^"]*")*)>/g | |
| 62 | ||
| 63 | function asyncButtons(source: string): { tag: string, attrs: string }[] { | |
| 64 | const found: { tag: string, attrs: string }[] = [] | |
| 65 | for (const match of code(source).matchAll(OPEN_TAG)) { | |
| 66 | const attrs = match[2] | |
| 67 | const disabled = /:disabled="([^"]*)"/.exec(attrs) | |
| 68 | const gated = (disabled && BUSY.test(disabled[1])) || /:loading="/.test(attrs) | |
| 69 | if (gated) | |
| 70 | found.push({ tag: match[1], attrs }) | |
| 71 | } | |
| 72 | return found | |
| 73 | } | |
| 74 | ||
| 75 | const files = views(join(ROOT, 'resources')) | |
| 76 | const settings = () => readFileSync(join(ROOT, 'resources/views/settings.stx'), 'utf8') | |
| 77 | ||
| 78 | describe('async buttons announce that they are working', () => { | |
| 79 | test('every button gated on a busy signal announces it', () => { | |
| 80 | const missing: string[] = [] | |
| 81 | for (const file of files) { | |
| 82 | for (const button of asyncButtons(readFileSync(file, 'utf8'))) { | |
| 83 | const announced = button.tag === 'Button' | |
| 84 | ? /:loading="/.test(button.attrs) | |
| 85 | : /:aria-busy="/.test(button.attrs) | |
| 86 | if (!announced) | |
| 87 | missing.push(`${file.slice(ROOT.length + 1)}: <${button.tag}${button.attrs.trimEnd().slice(0, 80)}>`) | |
| 88 | } | |
| 89 | } | |
| 90 | expect(missing).toEqual([]) | |
| 91 | }) | |
| 92 | ||
| 93 | test('the count does not quietly fall to zero', () => { | |
| 94 | const total = files.reduce((n, f) => n + asyncButtons(readFileSync(f, 'utf8')).length, 0) | |
| 95 | // 16 at the time of writing. A regex that stops matching would pass the test | |
| 96 | // above by finding nothing at all, which is the failure mode of every gate in | |
| 97 | // this repo that greps for a shape. | |
| 98 | expect(total).toBeGreaterThanOrEqual(16) | |
| 99 | }) | |
| 100 | ||
| 101 | test('the three repository buttons do not share one aria-busy source', () => { | |
| 102 | const found = asyncButtons(settings()).filter(b => /Repo"/.test(b.attrs) || /Repo'/.test(b.attrs) || /"(verify|disconnect|connect)Repo"/.test(b.attrs)) | |
| 103 | expect(found).toHaveLength(3) | |
| 104 | const sources = found.map(b => /:aria-busy="([^"]*)"/.exec(b.attrs)?.[1]) | |
| 105 | expect(new Set(sources).size).toBe(3) | |
| 106 | for (const source of sources) | |
| 107 | expect(source).toContain('repoAction()') | |
| 108 | }) | |
| 109 | ||
| 110 | test('repoAction is cleared wherever repoBusy is', () => { | |
| 111 | // A discriminator left set after the work finishes announces a button as busy | |
| 112 | // forever, which is worse than not announcing it at all. | |
| 113 | const body = code(settings()) | |
| 114 | expect((body.match(/repoBusy\.set\(/g) ?? []).length) | |
| 115 | .toBe((body.match(/repoAction\.set\(/g) ?? []).length) | |
| 116 | }) | |
| 117 | ||
| 118 | test('the per-member buttons scope their busy state to one row', () => { | |
| 119 | // removingMember() holds an id, so each row already answers for itself and | |
| 120 | // needs no discriminator; the binding just has to use the same expression. | |
| 121 | const found = asyncButtons(settings()).filter(b => /removingMember\(\)/.test(b.attrs)) | |
| 122 | expect(found).toHaveLength(2) | |
| 123 | for (const button of found) { | |
| 124 | const disabled = /:disabled="([^"]*)"/.exec(button.attrs)?.[1] | |
| 125 | const busy = /:aria-busy="([^"]*)"/.exec(button.attrs)?.[1] | |
| 126 | expect(busy).toBe(disabled) | |
| 127 | } | |
| 128 | }) | |
| 129 | }) | |