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.
| @@ -242,6 +242,23 @@ export default { | ||
| 242 | 242 | 'err-box': 'bg-[color-mix(in_srgb,#ef4444_12%,transparent)] text-[#ef4444]', |
| 243 | 243 | 'ok-box': 'bg-[color-mix(in_srgb,#22c55e_12%,transparent)] text-[#16a34a]', |
| 244 | 244 | |
| 245 | /* | |
| 246 | * The box those two sit in. Ten sites across six views spelled | |
| 247 | * `px-3 text-sm rounded-lg` out as three separate utilities, identically in | |
| 248 | * every one, beside whichever of the pair gave it a colour. | |
| 249 | * | |
| 250 | * Kept apart from err-box and ok-box rather than folded into them, so the | |
| 251 | * composite reads the same way in all three apps that have it: colour is | |
| 252 | * one class, geometry is another, and `class="mb-4 py-2 err-box | |
| 253 | * notice-box"` says which is which. It also means the confirmation and the | |
| 254 | * error share a box instead of each carrying their own copy of it. | |
| 255 | * | |
| 256 | * The vertical padding stays at the call site: errors sit at py-2 and | |
| 257 | * confirmations at py-3 here, in bughq and in analyticshq alike, so it is a | |
| 258 | * real axis rather than an oversight. | |
| 259 | */ | |
| 260 | 'notice-box': 'px-3 text-sm rounded-lg', | |
| 261 | ||
| 245 | 262 | // SiteFooter's repeated link and column heading. Four columns and ~28 |
| 246 | 263 | // links, so these earn a name rather than being repeated on each element. |
| 247 | 264 | 'footer-head': 'm-0 mb-[0.35rem] font-mono text-[0.7rem] font-semibold ' |
| @@ -217,7 +217,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 217 | 217 | </section> |
| 218 | 218 | |
| 219 | 219 | @if (formError) |
| 220 | <div role="alert" class="mb-6 px-3 py-2 text-sm rounded-lg err-box">{{ formError }}</div> | |
| 220 | <div role="alert" class="mb-6 py-2 err-box notice-box">{{ formError }}</div> | |
| 221 | 221 | @endif |
| 222 | 222 | |
| 223 | 223 | @if (!enabled) |
| @@ -232,7 +232,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 232 | 232 | @endif |
| 233 | 233 | |
| 234 | 234 | @if (run && run.status === 'failed') |
| 235 | <div class="mb-6 px-3 py-3 text-sm rounded-lg err-box"> | |
| 235 | <div class="mb-6 py-3 err-box notice-box"> | |
| 236 | 236 | The last analysis stopped. {{ run.error || 'No further detail was recorded.' }} |
| 237 | 237 | </div> |
| 238 | 238 | @endif |
| @@ -53,7 +53,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) => | ||
| 53 | 53 | </div> |
| 54 | 54 | |
| 55 | 55 | @if (sent) |
| 56 | <div role="status" class="px-3 py-3 text-sm rounded-lg ok-box"> | |
| 56 | <div role="status" class="py-3 notice-box ok-box"> | |
| 57 | 57 | If an account exists for that email, a reset link is on its way. It expires in 60 minutes. |
| 58 | 58 | </div> |
| 59 | 59 | <p class="mt-6 text-center text-sm text-subtle"> |
| @@ -63,7 +63,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) => | ||
| 63 | 63 | {{-- Posts to itself; the action is in the server block. --}} |
| 64 | 64 | <form action="/forgot-password" method="POST" class="space-y-4"> |
| 65 | 65 | @if (formError) |
| 66 | <div class="px-3 py-2 text-sm rounded-lg err-box">{{ formError }}</div> | |
| 66 | <div class="py-2 err-box notice-box">{{ formError }}</div> | |
| 67 | 67 | @endif |
| 68 | 68 | |
| 69 | 69 | <p class="text-muted text-sm">Enter the email you signed up with and we will send you a link to reset your password.</p> |
| @@ -139,7 +139,7 @@ async function onVerify(e: Event): Promise<void> { | ||
| 139 | 139 | |
| 140 | 140 | {{-- The shared error box for both steps. `:text` overwrites the (empty) |
| 141 | 141 | static label after hydration and never cloaks. --}} |
| 142 | <div id="login-error" role="alert" :show="!!error()" :text="error()" class="mb-4 px-3 py-2 text-sm rounded-lg err-box"></div> | |
| 142 | <div id="login-error" role="alert" :show="!!error()" :text="error()" class="mb-4 py-2 err-box notice-box"></div> | |
| 143 | 143 | |
| 144 | 144 | {{-- Step 1: credentials. A same-origin fetch, not a native POST — the |
| 145 | 145 | submit is intercepted so the browser never navigates to /login itself. --}} |
| @@ -177,7 +177,7 @@ async function onVerify(e: Event): Promise<void> { | ||
| 177 | 177 | </form> |
| 178 | 178 | |
| 179 | 179 | @if (oauthError) |
| 180 | <div role="alert" class="mt-4 px-3 py-2 text-sm rounded-lg err-box">{{ oauthError }}</div> | |
| 180 | <div role="alert" class="mt-4 py-2 err-box notice-box">{{ oauthError }}</div> | |
| 181 | 181 | @endif |
| 182 | 182 | |
| 183 | 183 | @if (anyOauth) |
| @@ -106,7 +106,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 106 | 106 | {{-- Posts to itself; the action is in the server block. --}} |
| 107 | 107 | <form action="/projects/new" method="POST" class="space-y-4"> |
| 108 | 108 | @if (formError) |
| 109 | <div id="form-error" role="alert" class="px-3 py-2 text-sm rounded-lg err-box">{{ formError }}</div> | |
| 109 | <div id="form-error" role="alert" class="py-2 err-box notice-box">{{ formError }}</div> | |
| 110 | 110 | @endif |
| 111 | 111 | |
| 112 | 112 | <div> |
| @@ -95,7 +95,7 @@ async function onRegister(e: Event): Promise<void> { | ||
| 95 | 95 | <p class="mb-3.5 invite-hint">You've been invited to a project — sign up to join it.</p> |
| 96 | 96 | @endif |
| 97 | 97 | |
| 98 | <div id="form-error" role="alert" :show="!!error()" :text="error()" class="mb-4 px-3 py-2 text-sm rounded-lg err-box"></div> | |
| 98 | <div id="form-error" role="alert" :show="!!error()" :text="error()" class="mb-4 py-2 err-box notice-box"></div> | |
| 99 | 99 | |
| 100 | 100 | {{-- A same-origin fetch, not a native POST — the submit is intercepted. --}} |
| 101 | 101 | <form action="/api/auth/register" method="POST" @submit="onRegister" class="space-y-4"> |
| @@ -72,7 +72,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) => | ||
| 72 | 72 | </div> |
| 73 | 73 | |
| 74 | 74 | @if (done) |
| 75 | <div class="px-3 py-3 text-sm rounded-lg ok-box"> | |
| 75 | <div class="py-3 notice-box ok-box"> | |
| 76 | 76 | Your password has been reset. You have been signed out everywhere; sign in with the new password. |
| 77 | 77 | </div> |
| 78 | 78 | <p class="mt-6 text-center text-sm text-subtle"> |
| @@ -85,7 +85,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) => | ||
| 85 | 85 | <input type="hidden" name="token" value=""> |
| 86 | 86 | <input type="hidden" name="email" value=""> |
| 87 | 87 | @if (formError) |
| 88 | <div id="form-error" role="alert" class="px-3 py-2 text-sm rounded-lg err-box">{{ formError }}</div> | |
| 88 | <div id="form-error" role="alert" class="py-2 err-box notice-box">{{ formError }}</div> | |
| 89 | 89 | @endif |
| 90 | 90 | |
| 91 | 91 | <div> |
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * The inline form notice: the error banner above a form, and the confirmation | |
| 3 | * that replaces it. | |
| 4 | * | |
| 5 | * Ten sites across six views spelled `px-3 text-sm | |
| 6 | * rounded-lg` out as three separate utilities, identically in every one. They | |
| 7 | * carry `notice-box` now, composed with the colour class (`err-box` / `ok-box`, both | |
| 8 | * shortcuts of their own) and the vertical padding, which genuinely varies. | |
| 9 | * | |
| 10 | * Why this is a test and not just a config entry: a shortcut reaches the page | |
| 11 | * only through the Crosswind merge. Rename or drop the entry and every one of | |
| 12 | * these banners loses its padding, its type size and its corners while keeping | |
| 13 | * its red tint, and nothing else fails. So the assertions are on the GENERATED | |
| 14 | * declarations, driven from markup through the engine's own extractor, which is | |
| 15 | * the path `buddy serve` takes per request. | |
| 16 | */ | |
| 17 | import { describe, expect, test } from 'bun:test' | |
| 18 | import { readFileSync } from 'node:fs' | |
| 19 | import { join } from 'node:path' | |
| 20 | import theme from '../../config/crosswind' | |
| 21 | // The installed STX package does not export these subpaths by bare specifier. | |
| 22 | import { loadCssEngine } from '../../node_modules/@stacksjs/stx/dist/dev-server/ts-css.js' | |
| 23 | import { mergeCssConfig } from '../../node_modules/@stacksjs/stx/dist/ts-css-config.js' | |
| 24 | ||
| 25 | const ROOT = join(import.meta.dir, '../..') | |
| 26 | const read = (p: string) => readFileSync(join(ROOT, p), 'utf8') | |
| 27 | ||
| 28 | const CALLERS = [ | |
| 29 | 'resources/views/login.stx', | |
| 30 | 'resources/views/register.stx', | |
| 31 | 'resources/views/reset-password.stx', | |
| 32 | 'resources/views/forgot-password.stx', | |
| 33 | 'resources/views/projects/new.stx', | |
| 34 | 'resources/views/fix.stx', | |
| 35 | ] | |
| 36 | ||
| 37 | describe('the shared inline form notice', () => { | |
| 38 | test('every caller uses the shortcut and none spells the utilities out', () => { | |
| 39 | let total = 0 | |
| 40 | for (const path of CALLERS) { | |
| 41 | const source = read(path) | |
| 42 | total += (source.match(/\bnotice-box\b/g) ?? []).length | |
| 43 | expect(source).not.toContain('px-3 py-2 text-sm rounded-lg') | |
| 44 | expect(source).not.toContain('px-3 py-3 text-sm rounded-lg') | |
| 45 | } | |
| 46 | expect(total).toBe(10) | |
| 47 | }) | |
| 48 | ||
| 49 | test('the colour and the vertical padding stay at the call site', () => { | |
| 50 | /* | |
| 51 | * notice-box is geometry only. `err-box` and `ok-box` are shortcuts of their own and | |
| 52 | * carry the tint, so a notice that lost its colour class would be a plain | |
| 53 | * grey box; and errors sit at py-2 while confirmations sit at py-3, which | |
| 54 | * is a real axis rather than something to fold in. | |
| 55 | */ | |
| 56 | for (const path of CALLERS) { | |
| 57 | for (const match of read(path).matchAll(/class="([^"]*\bnotice-box\b[^"]*)"/g)) { | |
| 58 | expect(match[1]).toMatch(/\bpy-[23]\b/) | |
| 59 | expect(match[1]).toMatch(/\b(err-box|ok-box)\b/) | |
| 60 | } | |
| 61 | } | |
| 62 | }) | |
| 63 | ||
| 64 | test('the generated rule is the three declarations the call sites carried', async () => { | |
| 65 | const engine = await loadCssEngine() | |
| 66 | if (!engine) | |
| 67 | throw new Error('The installed STX CSS engine is required') | |
| 68 | const config = mergeCssConfig(engine.defaultConfig || engine.config, theme).config | |
| 69 | ||
| 70 | const extract = engine.extractClasses | |
| 71 | if (!extract) | |
| 72 | throw new Error('The installed STX CSS engine is required') | |
| 73 | const extracted = [...extract('<div class="mb-4 py-2 err-box notice-box">x</div>')] | |
| 74 | expect(extracted).toContain('notice-box') | |
| 75 | ||
| 76 | const generator = new engine.CSSGenerator(config) | |
| 77 | for (const name of extracted) | |
| 78 | generator.generate(name) | |
| 79 | const css = generator.toCSS(false, false) | |
| 80 | ||
| 81 | expect(css).toContain('.notice-box {') | |
| 82 | // px-3 | |
| 83 | expect(css).toContain('padding-left: 0.75rem;') | |
| 84 | expect(css).toContain('padding-right: 0.75rem;') | |
| 85 | // text-sm, both halves: a hand-written composite loses the line-height | |
| 86 | expect(css).toContain('font-size: 0.875rem;') | |
| 87 | expect(css).toContain('line-height: 1.25rem;') | |
| 88 | // rounded-lg | |
| 89 | expect(css).toContain('border-radius: 0.5rem;') | |
| 90 | // and NOT the colour, which belongs to .err / .ok | |
| 91 | expect(css).not.toMatch(/\.notice-box \{[^}]*background/) | |
| 92 | }) | |
| 93 | }) | |