ReviewOS

also looking at this

stacks/reportshq

fix(ui): align component theme and login step visibility

#39
Merged glennmichael123 wants to merge fix/reportshq-component-theme into main
4 files +163 -77
config/crosswind.tsmodified+58-14
Changes to config/crosswind.ts
@@ -33,6 +33,11 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind'
3333export default {
3434 theme: {
3535 extend: {
36 borderRadius: {
37 control: '8px',
38 panel: '0.75rem',
39 pill: '999px',
40 },
3641 colors: {
3742 canvas: 'var(--bg)',
3843 panel: 'var(--panel)',
@@ -68,28 +73,67 @@ export default {
6873 grid: 'var(--grid)',
6974 axis: 'var(--axis)',
7075
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)',
8480 'surface-sunken': 'var(--bg)',
8581 '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)',
8790 'fg-strong': 'var(--text)',
8891 'fg-muted': 'var(--text-2)',
8992 'fg-soft': 'var(--text-2)',
9093 'fg-subtle': 'var(--text-3)',
9194 '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))',
9298 '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)',
93137 },
94138 // Arrays, not strings: Crosswind joins the entries into the font stack.
95139 // The whole stack already lives in the custom property, so each is a
resources/views/login.stxmodified+67-63
Changes to resources/views/login.stx
@@ -37,74 +37,78 @@ const signedIn = session.user !== null
3737 {{-- A real form, so a password manager recognises it and the enter key
3838 submits. The script intercepts it; without the script it still
3939 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>
8082 </form>
8183
8284 {{-- Second step, revealed only when the account has 2FA enabled. Dormant
8385 until an enrollment surface ships, but present so a 2FA account can
8486 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>
108112 </form>
109113
110114 <p class="mt-6 text-muted text-sm">
tests/unit/component-theme.test.tsadded+24-0
Changes to tests/unit/component-theme.test.ts
@@ -0,0 +1,24 @@
1import { describe, expect, test } from 'bun:test'
2import theme from '../../config/crosswind'
3// STX exposes its selected CSS engine through these installed files.
4import { loadCssEngine } from '../../node_modules/@stacksjs/stx/dist/dev-server/ts-css.js'
5import { SEMANTIC_TOKENS, SHAPE_TOKENS } from '../../node_modules/@stacksjs/stx/dist/theme-tokens.js'
6import { mergeCssConfig } from '../../node_modules/@stacksjs/stx/dist/ts-css-config.js'
7
8describe('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})
tests/unit/login-layout.test.tsadded+14-0
Changes to tests/unit/login-layout.test.ts
@@ -0,0 +1,14 @@
1import { expect, test } from 'bun:test'
2import { readFileSync } from 'node:fs'
3
4const login = readFileSync(new URL('../../resources/views/login.stx', import.meta.url), 'utf8')
5
6test('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})