also looking at this
fix(ui): align component theme and login step visibility
#39
4 files
+163
-77
| @@ -33,6 +33,11 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind' | ||
| 33 | 33 | export default { |
| 34 | 34 | theme: { |
| 35 | 35 | extend: { |
| 36 | borderRadius: { | |
| 37 | control: '8px', | |
| 38 | panel: '0.75rem', | |
| 39 | pill: '999px', | |
| 40 | }, | |
| 36 | 41 | colors: { |
| 37 | 42 | canvas: 'var(--bg)', |
| 38 | 43 | panel: 'var(--panel)', |
| @@ -68,28 +73,67 @@ export default { | ||
| 68 | 73 | grid: 'var(--grid)', |
| 69 | 74 | axis: 'var(--axis)', |
| 70 | 75 | |
| 71 | // --- @stacksjs/components' token vocabulary ------------------------ | |
| 72 | // The shipped components are written against their own semantic names | |
| 73 | // (text-fg, bg-surface, border-line-strong, ...) used 300+ times across | |
| 74 | // the 102 components. This palette already satisfied `line` and | |
| 75 | // `accent`; the rest resolved to nothing, so a component that imposes | |
| 76 | // no colour of its own still rendered half-styled - text fell back to | |
| 77 | // inherit and panels had no background. | |
| 78 | // | |
| 79 | // Purely additive: none of these names appear in this app's markup, so | |
| 80 | // no existing element changes. Does NOT fix <Button variant="primary">, | |
| 81 | // which hard-codes bg-blue-500 - that is stacksjs/stx#1993. | |
| 82 | // See statushqorg/status#20. | |
| 83 | surface: 'var(--panel)', | |
| 76 | // Complete component roles follow the same palette as the reports. | |
| 77 | // Keep content as a surface and ink paired with its solid/soft fill. | |
| 78 | // Theme entries override library defaults without cascade-layer tricks. | |
| 79 | 'surface': 'var(--panel)', | |
| 84 | 80 | 'surface-sunken': 'var(--bg)', |
| 85 | 81 | 'surface-raised': 'color-mix(in srgb, var(--accent) 8%, var(--panel))', |
| 86 | fg: 'var(--text)', | |
| 82 | 'surface-hover': 'color-mix(in srgb, var(--text) 6%, var(--panel))', | |
| 83 | 'surface-raised-hover': 'color-mix(in srgb, var(--accent) 14%, var(--panel))', | |
| 84 | 'surface-sunken-hover': 'color-mix(in srgb, var(--text) 6%, var(--bg))', | |
| 85 | 'page': 'var(--bg)', | |
| 86 | 'content': 'var(--panel)', | |
| 87 | 'field': 'var(--panel)', | |
| 88 | 'field-hover': 'color-mix(in srgb, var(--text) 6%, var(--panel))', | |
| 89 | 'fg': 'var(--text)', | |
| 87 | 90 | 'fg-strong': 'var(--text)', |
| 88 | 91 | 'fg-muted': 'var(--text-2)', |
| 89 | 92 | 'fg-soft': 'var(--text-2)', |
| 90 | 93 | 'fg-subtle': 'var(--text-3)', |
| 91 | 94 | 'line-strong': 'color-mix(in srgb, var(--text-3) 55%, var(--border))', |
| 95 | 'line-hover': 'color-mix(in srgb, var(--text-2) 70%, var(--border))', | |
| 96 | 'link': 'var(--accent)', | |
| 97 | 'link-hover': 'color-mix(in srgb, var(--accent) 85%, var(--text))', | |
| 92 | 98 | 'accent-solid': 'var(--accent)', |
| 99 | 'accent-solid-hover': 'color-mix(in srgb, var(--accent) 92%, var(--text))', | |
| 100 | 'accent-soft-ink': 'color-mix(in srgb, var(--accent) 85%, var(--text))', | |
| 101 | 'success': 'var(--pos)', | |
| 102 | 'success-solid': 'var(--pos)', | |
| 103 | 'success-solid-hover': 'color-mix(in srgb, var(--pos) 92%, var(--text))', | |
| 104 | 'success-ink': 'var(--accent-ink)', | |
| 105 | 'success-soft': 'color-mix(in srgb, var(--pos) 8%, var(--panel))', | |
| 106 | 'success-soft-ink': 'color-mix(in srgb, var(--pos) 85%, var(--text))', | |
| 107 | 'danger': 'var(--neg)', | |
| 108 | 'danger-solid': 'var(--neg)', | |
| 109 | 'danger-solid-hover': 'color-mix(in srgb, var(--neg) 92%, var(--text))', | |
| 110 | 'danger-ink': 'var(--accent-ink)', | |
| 111 | 'danger-soft': 'color-mix(in srgb, var(--neg) 8%, var(--panel))', | |
| 112 | 'danger-soft-ink': 'color-mix(in srgb, var(--neg) 85%, var(--text))', | |
| 113 | 'warning': 'var(--warn)', | |
| 114 | 'warning-solid': 'var(--warn)', | |
| 115 | 'warning-solid-hover': 'color-mix(in srgb, var(--warn) 92%, var(--text))', | |
| 116 | 'warning-ink': 'var(--accent-ink)', | |
| 117 | 'warning-soft': 'color-mix(in srgb, var(--warn) 8%, var(--panel))', | |
| 118 | 'warning-soft-ink': 'color-mix(in srgb, var(--warn) 85%, var(--text))', | |
| 119 | 'info': 'var(--text-2)', | |
| 120 | 'info-solid': 'var(--text-2)', | |
| 121 | 'info-solid-hover': 'color-mix(in srgb, var(--text-2) 92%, var(--text))', | |
| 122 | 'info-ink': 'var(--accent-ink)', | |
| 123 | 'info-soft': 'color-mix(in srgb, var(--text-2) 8%, var(--panel))', | |
| 124 | 'info-soft-ink': 'color-mix(in srgb, var(--text-2) 85%, var(--text))', | |
| 125 | 'secondary': 'var(--text-2)', | |
| 126 | 'secondary-solid': 'var(--text-2)', | |
| 127 | 'secondary-solid-hover': 'color-mix(in srgb, var(--text-2) 92%, var(--text))', | |
| 128 | 'secondary-ink': 'var(--accent-ink)', | |
| 129 | 'secondary-soft': 'color-mix(in srgb, var(--text-2) 8%, var(--panel))', | |
| 130 | 'secondary-soft-ink': 'color-mix(in srgb, var(--text-2) 85%, var(--text))', | |
| 131 | 'danger-fg': 'var(--neg)', | |
| 132 | 'danger-fg-subtle': 'var(--neg)', | |
| 133 | 'danger-line': 'var(--neg)', | |
| 134 | 'danger-focus': 'var(--neg)', | |
| 135 | 'inverse': 'var(--text)', | |
| 136 | 'inverse-ink': 'var(--bg)', | |
| 93 | 137 | }, |
| 94 | 138 | // Arrays, not strings: Crosswind joins the entries into the font stack. |
| 95 | 139 | // The whole stack already lives in the custom property, so each is a |
| @@ -37,74 +37,78 @@ const signedIn = session.user !== null | ||
| 37 | 37 | {{-- A real form, so a password manager recognises it and the enter key |
| 38 | 38 | submits. The script intercepts it; without the script it still |
| 39 | 39 | posts somewhere sensible rather than nowhere. --}} |
| 40 | <form id="signin" method="post" action="/api/auth/login" class="grid gap-4"> | |
| 41 | <label class="block"> | |
| 42 | <span class="block mb-1 text-muted text-sm">Email</span> | |
| 43 | <input | |
| 44 | id="email" | |
| 45 | name="email" | |
| 46 | type="email" | |
| 47 | autocomplete="username" | |
| 48 | required | |
| 49 | class="px-3 py-2 w-full text-ink text-sm bg-panel border border-line rounded-lg" aria-describedby="error" | |
| 50 | /> | |
| 51 | </label> | |
| 52 | ||
| 53 | <label class="block"> | |
| 54 | <span class="block mb-1 text-muted text-sm">Password</span> | |
| 55 | <input | |
| 56 | id="password" | |
| 57 | name="password" | |
| 58 | type="password" | |
| 59 | autocomplete="current-password" | |
| 60 | required | |
| 61 | class="px-3 py-2 w-full text-ink text-sm bg-panel border border-line rounded-lg" aria-describedby="error" | |
| 62 | /> | |
| 63 | </label> | |
| 64 | ||
| 65 | {{-- Off by default: a shared machine should not opt into a month-long | |
| 66 | session by accident. Checked lengthens the session to 30 days; | |
| 67 | unchecked is the one-week baseline. --}} | |
| 68 | <label class="flex gap-2 items-center text-muted text-sm cursor-pointer select-none"> | |
| 69 | <input id="remember" name="remember" type="checkbox" class="h-4 w-4 accent-accent" /> | |
| 70 | <span>Keep me signed in for 30 days</span> | |
| 71 | </label> | |
| 72 | ||
| 73 | <p id="error" class="text-sm text-warn" role="alert" hidden></p> | |
| 74 | ||
| 75 | <button | |
| 76 | id="submit" | |
| 77 | type="submit" | |
| 78 | class="px-4 py-2.5 font-medium text-accent-ink text-sm bg-accent rounded-lg disabled:opacity-60" | |
| 79 | >Sign in</button> | |
| 40 | <form id="signin" method="post" action="/api/auth/login"> | |
| 41 | <div class="grid gap-4"> | |
| 42 | <label class="block"> | |
| 43 | <span class="block mb-1 text-muted text-sm">Email</span> | |
| 44 | <input | |
| 45 | id="email" | |
| 46 | name="email" | |
| 47 | type="email" | |
| 48 | autocomplete="username" | |
| 49 | required | |
| 50 | class="px-3 py-2 w-full text-ink text-sm bg-panel border border-line rounded-lg" aria-describedby="error" | |
| 51 | /> | |
| 52 | </label> | |
| 53 | ||
| 54 | <label class="block"> | |
| 55 | <span class="block mb-1 text-muted text-sm">Password</span> | |
| 56 | <input | |
| 57 | id="password" | |
| 58 | name="password" | |
| 59 | type="password" | |
| 60 | autocomplete="current-password" | |
| 61 | required | |
| 62 | class="px-3 py-2 w-full text-ink text-sm bg-panel border border-line rounded-lg" aria-describedby="error" | |
| 63 | /> | |
| 64 | </label> | |
| 65 | ||
| 66 | {{-- Off by default: a shared machine should not opt into a month-long | |
| 67 | session by accident. Checked lengthens the session to 30 days; | |
| 68 | unchecked is the one-week baseline. --}} | |
| 69 | <label class="flex gap-2 items-center text-muted text-sm cursor-pointer select-none"> | |
| 70 | <input id="remember" name="remember" type="checkbox" class="h-4 w-4 accent-accent" /> | |
| 71 | <span>Keep me signed in for 30 days</span> | |
| 72 | </label> | |
| 73 | ||
| 74 | <p id="error" class="text-sm text-warn" role="alert" hidden></p> | |
| 75 | ||
| 76 | <button | |
| 77 | id="submit" | |
| 78 | type="submit" | |
| 79 | class="px-4 py-2.5 font-medium text-accent-ink text-sm bg-accent rounded-lg disabled:opacity-60" | |
| 80 | >Sign in</button> | |
| 81 | </div> | |
| 80 | 82 | </form> |
| 81 | 83 | |
| 82 | 84 | {{-- Second step, revealed only when the account has 2FA enabled. Dormant |
| 83 | 85 | until an enrollment surface ships, but present so a 2FA account can |
| 84 | 86 | never sign in halfway and stall. --}} |
| 85 | <form id="twofa" method="post" action="/api/auth/verify-two-factor-login" class="grid gap-4" hidden> | |
| 86 | <h2 class="font-semibold text-lg tracking-tight">Two-factor code</h2> | |
| 87 | <p class="-mt-2 text-muted text-sm">Enter the 6-digit code from your authenticator app.</p> | |
| 88 | <label class="block"> | |
| 89 | <span class="block mb-1 text-muted text-sm">Authentication code</span> | |
| 90 | <input | |
| 91 | id="code" | |
| 92 | name="code" | |
| 93 | type="text" | |
| 94 | inputmode="numeric" | |
| 95 | autocomplete="one-time-code" | |
| 96 | maxlength="6" | |
| 97 | class="px-3 py-2 w-full text-ink text-sm bg-panel border border-line rounded-lg" | |
| 98 | /> | |
| 99 | </label> | |
| 100 | ||
| 101 | <p id="twofa-error" class="text-sm text-warn" role="alert" hidden></p> | |
| 102 | ||
| 103 | <button | |
| 104 | id="twofa-submit" | |
| 105 | type="submit" | |
| 106 | class="px-4 py-2.5 font-medium text-accent-ink text-sm bg-accent rounded-lg disabled:opacity-60" | |
| 107 | >Verify and sign in</button> | |
| 87 | <form id="twofa" method="post" action="/api/auth/verify-two-factor-login" hidden> | |
| 88 | <div class="grid gap-4"> | |
| 89 | <h2 class="font-semibold text-lg tracking-tight">Two-factor code</h2> | |
| 90 | <p class="-mt-2 text-muted text-sm">Enter the 6-digit code from your authenticator app.</p> | |
| 91 | <label class="block"> | |
| 92 | <span class="block mb-1 text-muted text-sm">Authentication code</span> | |
| 93 | <input | |
| 94 | id="code" | |
| 95 | name="code" | |
| 96 | type="text" | |
| 97 | inputmode="numeric" | |
| 98 | autocomplete="one-time-code" | |
| 99 | maxlength="6" | |
| 100 | class="px-3 py-2 w-full text-ink text-sm bg-panel border border-line rounded-lg" | |
| 101 | /> | |
| 102 | </label> | |
| 103 | ||
| 104 | <p id="twofa-error" class="text-sm text-warn" role="alert" hidden></p> | |
| 105 | ||
| 106 | <button | |
| 107 | id="twofa-submit" | |
| 108 | type="submit" | |
| 109 | class="px-4 py-2.5 font-medium text-accent-ink text-sm bg-accent rounded-lg disabled:opacity-60" | |
| 110 | >Verify and sign in</button> | |
| 111 | </div> | |
| 108 | 112 | </form> |
| 109 | 113 | |
| 110 | 114 | <p class="mt-6 text-muted text-sm"> |
| @@ -0,0 +1,24 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import theme from '../../config/crosswind' | |
| 3 | // STX exposes its selected CSS engine through these installed files. | |
| 4 | import { loadCssEngine } from '../../node_modules/@stacksjs/stx/dist/dev-server/ts-css.js' | |
| 5 | import { SEMANTIC_TOKENS, SHAPE_TOKENS } from '../../node_modules/@stacksjs/stx/dist/theme-tokens.js' | |
| 6 | import { mergeCssConfig } from '../../node_modules/@stacksjs/stx/dist/ts-css-config.js' | |
| 7 | ||
| 8 | describe('component palette contract', () => { | |
| 9 | test('every component color and radius emits an app-owned utility', async () => { | |
| 10 | const engine = await loadCssEngine() | |
| 11 | if (!engine) throw new Error('The installed STX CSS engine is required') | |
| 12 | const generator = new engine.CSSGenerator(mergeCssConfig(engine.defaultConfig || engine.config, theme).config) | |
| 13 | for (const role of Object.keys(SEMANTIC_TOKENS)) generator.generate(`bg-${role}`) | |
| 14 | for (const role of Object.keys(SHAPE_TOKENS)) generator.generate(`rounded-${role}`) | |
| 15 | const css = generator.toCSS(false, false) | |
| 16 | expect(css).not.toContain('var(--stx-') | |
| 17 | for (const role of Object.keys(SEMANTIC_TOKENS)) expect(css).toContain(`.bg-${role} {`) | |
| 18 | for (const role of Object.keys(SHAPE_TOKENS)) expect(css).toContain(`.rounded-${role} {`) | |
| 19 | expect(css).toContain('.bg-content {\n background-color: var(--panel);') | |
| 20 | expect(css).toContain('.bg-accent-solid {\n background-color: var(--accent);') | |
| 21 | expect(css).toContain('.rounded-control {\n border-radius: 8px;') | |
| 22 | expect(css).toContain('.rounded-panel {\n border-radius: 0.75rem;') | |
| 23 | }) | |
| 24 | }) | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | ||
| 4 | const login = readFileSync(new URL('../../resources/views/login.stx', import.meta.url), 'utf8') | |
| 5 | ||
| 6 | test('both login steps leave display on the hidden-capable form to the browser', () => { | |
| 7 | for (const id of ['signin', 'twofa']) { | |
| 8 | const opening = login.match(new RegExp(`<form id="${id}"[^>]*>`))?.[0] ?? '' | |
| 9 | expect(opening).not.toBe('') | |
| 10 | expect(opening).not.toMatch(/class="[^"]*\b(?:grid|flex|block)\b/) | |
| 11 | expect(login).toContain(`${opening}\n <div class="grid gap-4">`) | |
| 12 | } | |
| 13 | expect(login).toMatch(/<form id="twofa"[^>]* hidden>/) | |
| 14 | }) | |