also looking at this
fix(ui): align component theme and mobile subscription layout
#10
4 files
+90
-15
| @@ -47,6 +47,8 @@ export default { | ||
| 47 | 47 | theme: { |
| 48 | 48 | extend: { |
| 49 | 49 | borderRadius: { |
| 50 | control: '10px', | |
| 51 | pill: '999px', | |
| 50 | 52 | /* |
| 51 | 53 | * `rounded-panel` is @stacksjs/components' radius role name: |
| 52 | 54 | * <EmptyState variant="panel"> renders `bg-panel rounded-panel |
| @@ -83,28 +85,67 @@ export default { | ||
| 83 | 85 | // right in both. See public/tokens.css. |
| 84 | 86 | 'accent-ink': 'var(--accent-ink)', |
| 85 | 87 | |
| 86 | // --- @stacksjs/components' token vocabulary ------------------------ | |
| 87 | // The shipped components are written against their own semantic names | |
| 88 | // (text-fg, bg-surface, border-line-strong, ...) used 300+ times across | |
| 89 | // the 102 components. This palette already satisfied `line` and | |
| 90 | // `accent`; the rest resolved to nothing, so a component that imposes | |
| 91 | // no colour of its own still rendered half-styled - text fell back to | |
| 92 | // inherit and panels had no background. | |
| 93 | // | |
| 94 | // Purely additive: none of these names appear in this app's markup, so | |
| 95 | // no existing element changes. Does NOT fix <Button variant="primary">, | |
| 96 | // which hard-codes bg-blue-500 - that is stacksjs/stx#1993. | |
| 97 | // See statushqorg/status#20. | |
| 98 | surface: 'var(--panel)', | |
| 88 | // Complete component roles follow the app palette, not stock library | |
| 89 | // colors. Theme entries also avoid layered preflight precedence issues. | |
| 90 | // --ok alone is too bright for white small labels in light mode. | |
| 91 | 'surface': 'var(--panel)', | |
| 99 | 92 | 'surface-sunken': 'var(--bg)', |
| 100 | 93 | 'surface-raised': 'color-mix(in srgb, var(--accent) 8%, var(--panel))', |
| 101 | fg: 'var(--text)', | |
| 94 | 'surface-hover': 'color-mix(in srgb, var(--text) 6%, var(--panel))', | |
| 95 | 'surface-raised-hover': 'color-mix(in srgb, var(--accent) 14%, var(--panel))', | |
| 96 | 'surface-sunken-hover': 'color-mix(in srgb, var(--text) 6%, var(--bg))', | |
| 97 | 'page': 'var(--bg)', | |
| 98 | 'content': 'var(--panel)', | |
| 99 | 'field': 'var(--panel)', | |
| 100 | 'field-hover': 'color-mix(in srgb, var(--text) 6%, var(--panel))', | |
| 101 | 'fg': 'var(--text)', | |
| 102 | 102 | 'fg-strong': 'var(--text)', |
| 103 | 103 | 'fg-muted': 'var(--text-2)', |
| 104 | 104 | 'fg-soft': 'var(--text-2)', |
| 105 | 105 | 'fg-subtle': 'var(--text-3)', |
| 106 | 106 | 'line-strong': 'color-mix(in srgb, var(--text-3) 55%, var(--border))', |
| 107 | 'line-hover': 'color-mix(in srgb, var(--text-2) 70%, var(--border))', | |
| 108 | 'link': 'var(--accent)', | |
| 109 | 'link-hover': 'color-mix(in srgb, var(--accent) 85%, var(--text))', | |
| 107 | 110 | 'accent-solid': 'var(--accent)', |
| 111 | 'accent-solid-hover': 'color-mix(in srgb, var(--accent) 92%, var(--text))', | |
| 112 | 'accent-soft-ink': 'color-mix(in srgb, var(--accent) 85%, var(--text))', | |
| 113 | 'success': 'color-mix(in srgb, var(--ok) 75%, var(--text))', | |
| 114 | 'success-solid': 'color-mix(in srgb, var(--ok) 75%, var(--text))', | |
| 115 | 'success-solid-hover': 'color-mix(in srgb, color-mix(in srgb, var(--ok) 75%, var(--text)) 92%, var(--text))', | |
| 116 | 'success-ink': 'var(--accent-ink)', | |
| 117 | 'success-soft': 'color-mix(in srgb, color-mix(in srgb, var(--ok) 75%, var(--text)) 8%, var(--panel))', | |
| 118 | 'success-soft-ink': 'color-mix(in srgb, color-mix(in srgb, var(--ok) 75%, var(--text)) 85%, var(--text))', | |
| 119 | 'danger': 'var(--warn)', | |
| 120 | 'danger-solid': 'var(--warn)', | |
| 121 | 'danger-solid-hover': 'color-mix(in srgb, var(--warn) 92%, var(--text))', | |
| 122 | 'danger-ink': 'var(--accent-ink)', | |
| 123 | 'danger-soft': 'color-mix(in srgb, var(--warn) 8%, var(--panel))', | |
| 124 | 'danger-soft-ink': 'color-mix(in srgb, var(--warn) 85%, var(--text))', | |
| 125 | 'warning': 'var(--sev-warning)', | |
| 126 | 'warning-solid': 'var(--sev-warning)', | |
| 127 | 'warning-solid-hover': 'color-mix(in srgb, var(--sev-warning) 92%, var(--text))', | |
| 128 | 'warning-ink': 'var(--accent-ink)', | |
| 129 | 'warning-soft': 'color-mix(in srgb, var(--sev-warning) 8%, var(--panel))', | |
| 130 | 'warning-soft-ink': 'color-mix(in srgb, var(--sev-warning) 85%, var(--text))', | |
| 131 | 'info': 'var(--text-2)', | |
| 132 | 'info-solid': 'var(--text-2)', | |
| 133 | 'info-solid-hover': 'color-mix(in srgb, var(--text-2) 92%, var(--text))', | |
| 134 | 'info-ink': 'var(--accent-ink)', | |
| 135 | 'info-soft': 'color-mix(in srgb, var(--text-2) 8%, var(--panel))', | |
| 136 | 'info-soft-ink': 'color-mix(in srgb, var(--text-2) 85%, var(--text))', | |
| 137 | 'secondary': 'var(--text-2)', | |
| 138 | 'secondary-solid': 'var(--text-2)', | |
| 139 | 'secondary-solid-hover': 'color-mix(in srgb, var(--text-2) 92%, var(--text))', | |
| 140 | 'secondary-ink': 'var(--accent-ink)', | |
| 141 | 'secondary-soft': 'color-mix(in srgb, var(--text-2) 8%, var(--panel))', | |
| 142 | 'secondary-soft-ink': 'color-mix(in srgb, var(--text-2) 85%, var(--text))', | |
| 143 | 'danger-fg': 'var(--warn)', | |
| 144 | 'danger-fg-subtle': 'var(--warn)', | |
| 145 | 'danger-line': 'var(--warn)', | |
| 146 | 'danger-focus': 'var(--warn)', | |
| 147 | 'inverse': 'var(--text)', | |
| 148 | 'inverse-ink': 'var(--bg)', | |
| 108 | 149 | }, |
| 109 | 150 | // The site's own two breakpoints, as min-widths. public/marketing.css |
| 110 | 151 | // states them as `max-width: 560px` and `max-width: 900px`; Crosswind |
| @@ -281,7 +281,12 @@ a.cell:hover .cell-more { color: var(--accent); gap: 0.55rem; } | ||
| 281 | 281 | /* One line, reserved by min-height so a reply does not shift the section. */ |
| 282 | 282 | .sub-status { margin: 0.85rem auto 0; min-height: 1.25rem; color: var(--text-2); font-size: 0.88rem; } |
| 283 | 283 | .sub-status[data-state="error"] { color: var(--accent); } |
| 284 | @media (max-width: 30rem) { .sub-form { flex-direction: column; } .sub-form .btn { width: 100%; justify-content: center; } } | |
| 284 | @media (max-width: 30rem) { | |
| 285 | .sub-form { flex-direction: column; } | |
| 286 | /* A row's 15rem width basis becomes height when the form stacks. */ | |
| 287 | .sub-field { flex: none; } | |
| 288 | .sub-form .btn { width: 100%; justify-content: center; } | |
| 289 | } | |
| 285 | 290 | |
| 286 | 291 | /* footer */ |
| 287 | 292 | .footer { padding: 2.5rem 0; } |
| @@ -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: 10px;') | |
| 22 | expect(css).toContain('.rounded-panel {\n border-radius: 0.75rem;') | |
| 23 | }) | |
| 24 | }) | |
| @@ -9,6 +9,11 @@ const marketing = readFileSync(join(ROOT, 'public/marketing.css'), 'utf8') | ||
| 9 | 9 | // taller than an 812px viewport. The panel has to scroll inside the viewport |
| 10 | 10 | // and the page behind it has to stay put, or the last links are unreachable. |
| 11 | 11 | describe('mobile layout contracts', () => { |
| 12 | test('stacked subscription fields do not keep the desktop flex basis as height', () => { | |
| 13 | const mobile = marketing.slice(marketing.indexOf('@media (max-width: 30rem)')) | |
| 14 | expect(mobile).toMatch(/\.sub-field\s*\{\s*flex:\s*none;\s*\}/) | |
| 15 | }) | |
| 16 | ||
| 12 | 17 | test('marketing menu is bounded by the visible mobile viewport', () => { |
| 13 | 18 | const panel = marketing.match(/\.nav-menu-panel\s*\{[^}]*\}/)?.[0] ?? '' |
| 14 | 19 | expect(panel).toContain('max-height: calc(100dvh - 92px)') |