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.
| @@ -0,0 +1,96 @@ | ||
| 1 | { | |
| 2 | "roles": { | |
| 3 | "base": { | |
| 4 | "light": { | |
| 5 | "fg": "#0b0f19", | |
| 6 | "fg-strong": "#0b0f19", | |
| 7 | "fg-muted": "#4b5565", | |
| 8 | "fg-soft": "#6b7280", | |
| 9 | "fg-subtle": "#6b7280", | |
| 10 | "page": "#fbfbfd", | |
| 11 | "panel": "#ffffff", | |
| 12 | "field": "#ffffff", | |
| 13 | "surface": "#ffffff", | |
| 14 | "surface-sunken": "#fbfbfd", | |
| 15 | "line": "rgba(15, 23, 42, 0.09)", | |
| 16 | "line-strong": "color-mix(in srgb, #6b7280 55%, rgba(15, 23, 42, 0.09))", | |
| 17 | "accent": "#047857", | |
| 18 | "accent-solid": "#047857", | |
| 19 | "accent-ink": "#ffffff", | |
| 20 | "danger": "#b91c1c", | |
| 21 | "success": "color-mix(in srgb, #16a34a 75%, #0b0f19)", | |
| 22 | "warning": "#b45309", | |
| 23 | "info": "#4b5565" | |
| 24 | }, | |
| 25 | "dark": { | |
| 26 | "fg": "#f3f5f9", | |
| 27 | "fg-strong": "#f3f5f9", | |
| 28 | "fg-muted": "#b3bccb", | |
| 29 | "fg-soft": "#79839a", | |
| 30 | "fg-subtle": "#79839a", | |
| 31 | "page": "#090b11", | |
| 32 | "panel": "#10131c", | |
| 33 | "field": "#10131c", | |
| 34 | "surface": "#10131c", | |
| 35 | "surface-sunken": "#090b11", | |
| 36 | "line": "rgba(148, 163, 184, 0.12)", | |
| 37 | "line-strong": "color-mix(in srgb, #79839a 55%, rgba(148, 163, 184, 0.12))", | |
| 38 | "accent": "#34d399", | |
| 39 | "accent-solid": "#34d399", | |
| 40 | "accent-ink": "#04140d", | |
| 41 | "danger": "#f87171", | |
| 42 | "success": "color-mix(in srgb, #4ade80 75%, #f3f5f9)", | |
| 43 | "warning": "#f59e0b", | |
| 44 | "info": "#b3bccb" | |
| 45 | } | |
| 46 | }, | |
| 47 | "dashboard": { | |
| 48 | "light": { | |
| 49 | "fg": "#0b0f19", | |
| 50 | "fg-strong": "#0b0f19", | |
| 51 | "fg-muted": "#4b5565", | |
| 52 | "fg-soft": "#6b7280", | |
| 53 | "fg-subtle": "#6b7280", | |
| 54 | "page": "#fbfbfd", | |
| 55 | "panel": "#ffffff", | |
| 56 | "field": "#ffffff", | |
| 57 | "surface": "#ffffff", | |
| 58 | "surface-sunken": "#fbfbfd", | |
| 59 | "line": "rgba(15, 23, 42, 0.09)", | |
| 60 | "line-strong": "color-mix(in srgb, #6b7280 55%, rgba(15, 23, 42, 0.09))", | |
| 61 | "accent": "#4f46e5", | |
| 62 | "accent-solid": "#4f46e5", | |
| 63 | "accent-ink": "#ffffff", | |
| 64 | "danger": "#b91c1c", | |
| 65 | "success": "color-mix(in srgb, #16a34a 75%, #0b0f19)", | |
| 66 | "warning": "#b45309", | |
| 67 | "info": "#4b5565" | |
| 68 | }, | |
| 69 | "dark": { | |
| 70 | "fg": "#f3f5f9", | |
| 71 | "fg-strong": "#f3f5f9", | |
| 72 | "fg-muted": "#b3bccb", | |
| 73 | "fg-soft": "#79839a", | |
| 74 | "fg-subtle": "#79839a", | |
| 75 | "page": "#090b11", | |
| 76 | "panel": "#10131c", | |
| 77 | "field": "#10131c", | |
| 78 | "surface": "#10131c", | |
| 79 | "surface-sunken": "#090b11", | |
| 80 | "line": "rgba(148, 163, 184, 0.12)", | |
| 81 | "line-strong": "color-mix(in srgb, #79839a 55%, rgba(148, 163, 184, 0.12))", | |
| 82 | "accent": "#818cf8", | |
| 83 | "accent-solid": "#818cf8", | |
| 84 | "accent-ink": "#04140d", | |
| 85 | "danger": "#f87171", | |
| 86 | "success": "color-mix(in srgb, #4ade80 75%, #f3f5f9)", | |
| 87 | "warning": "#f59e0b", | |
| 88 | "info": "#b3bccb" | |
| 89 | } | |
| 90 | } | |
| 91 | }, | |
| 92 | "lost": [ | |
| 93 | "fg = fg-strong", | |
| 94 | "panel = field" | |
| 95 | ] | |
| 96 | } | |
| @@ -0,0 +1,199 @@ | ||
| 1 | /** | |
| 2 | * The token layer: what this app's UI means, as values rather than as classes. | |
| 3 | * | |
| 4 | * stx ships 77 role tokens and `@stacksjs/components` is written against them -- | |
| 5 | * `text-fg` 95 times, `text-fg-strong` 51, `bg-field` 34, `bg-accent-solid` 22. | |
| 6 | * This app, like the other five, already maps those role names onto its own | |
| 7 | * custom properties in `theme.extend.colors`, so a shared component does pick up | |
| 8 | * this app's colours. That mapping is the token layer; it just was not written | |
| 9 | * down anywhere a non-Crosswind renderer could read it. | |
| 10 | * | |
| 11 | * This emits it. For each role a component actually uses, resolve the utility | |
| 12 | * through the real engine (authoritative for role -> var) and then resolve the | |
| 13 | * var chain through this config's own declarations (authoritative for var -> | |
| 14 | * value). The result is a plain role -> literal manifest, which is the form a | |
| 15 | * native shell needs: `fg-strong` means #0b0f19 here, whatever Crosswind spells | |
| 16 | * it as. | |
| 17 | * | |
| 18 | * It also reports LOST DISTINCTIONS: pairs the design system treats as different | |
| 19 | * that this app resolves to the same literal. Those are not failures. An app with | |
| 20 | * one muted grey genuinely cannot express `fg-muted` and `fg-soft` separately, | |
| 21 | * and the honest first step is to see it, not to invent a colour. So this script | |
| 22 | * reports and exits 0. | |
| 23 | * | |
| 24 | * SURFACES. This app does not have one theme, it has two. The base/marketing | |
| 25 | * surface is emerald (`--accent: #047857` light, `#34d399` dark) and the | |
| 26 | * dashboard overrides it to indigo under `:root[data-theme] .page-dashboard` | |
| 27 | * (`#4f46e5` / `#818cf8`). A manifest that ignores that silently mixes them -- | |
| 28 | * the first version of this script emitted the marketing accent for light and | |
| 29 | * the dashboard accent for dark, which is a value no page ever renders. So the | |
| 30 | * manifest is per surface, and the mobile app that mirrors the dashboard wants | |
| 31 | * the `dashboard` one. | |
| 32 | * | |
| 33 | * KNOWN LIMIT: a value built by string interpolation rather than written | |
| 34 | * literally cannot be resolved here; it is emitted as-is and flagged. | |
| 35 | */ | |
| 36 | import { readFileSync, writeFileSync } from 'node:fs' | |
| 37 | import { resolve } from 'node:path' | |
| 38 | import theme from '../config/crosswind' | |
| 39 | ||
| 40 | const ROOT = resolve(import.meta.dir, '..') | |
| 41 | ||
| 42 | /* | |
| 43 | * The roles `@stacksjs/components` actually reads, by measured usage in its | |
| 44 | * source: text-fg 95 times, text-fg-strong 51, text-fg-soft 48, text-fg-muted | |
| 45 | * 43, bg-panel 34, bg-field 34, border-line 33, text-danger 27, bg-accent-solid | |
| 46 | * 22, and so on. | |
| 47 | */ | |
| 48 | const ROLES = [ | |
| 49 | 'fg', 'fg-strong', 'fg-muted', 'fg-soft', 'fg-subtle', | |
| 50 | 'page', 'panel', 'field', 'surface', 'surface-sunken', | |
| 51 | 'line', 'line-strong', | |
| 52 | 'accent', 'accent-solid', 'accent-ink', | |
| 53 | 'danger', 'success', 'warning', 'info', | |
| 54 | ] | |
| 55 | ||
| 56 | /** Pairs a component can render side by side and expects to tell apart. */ | |
| 57 | const MUST_DIFFER: [string, string][] = [ | |
| 58 | ['fg', 'fg-strong'], | |
| 59 | ['fg-muted', 'fg-soft'], | |
| 60 | ['panel', 'field'], | |
| 61 | ['page', 'panel'], | |
| 62 | ['danger', 'warning'], | |
| 63 | ['success', 'warning'], | |
| 64 | ['accent', 'fg'], | |
| 65 | ['line', 'line-strong'], | |
| 66 | ] | |
| 67 | ||
| 68 | const source = readFileSync(resolve(ROOT, 'config/crosswind.ts'), 'utf8') | |
| 69 | ||
| 70 | /** | |
| 71 | * The custom properties in force for one surface and one colour mode. | |
| 72 | * | |
| 73 | * Source order with later winning, which is what the cascade does for equal | |
| 74 | * specificity, rather than "first wins" -- the earlier version mixed the two and | |
| 75 | * produced a light value from one surface and a dark value from another. | |
| 76 | */ | |
| 77 | const SURFACES: Record<string, RegExp | null> = { | |
| 78 | base: null, | |
| 79 | dashboard: /\.page-dashboard/, | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Walk the CSS with real brace matching, keeping the full selector stack. | |
| 84 | * | |
| 85 | * A regex over `selector { body }` cannot do this: an `@media` block contains | |
| 86 | * rules, so the match ends at the FIRST inner `}` and every nested selector is | |
| 87 | * swallowed into what looks like the media query's own body. This app has two | |
| 88 | * `@media (prefers-color-scheme: dark)` blocks, one of them holding the | |
| 89 | * `.page-dashboard` overrides -- so the naive version attributed the dashboard's | |
| 90 | * indigo to the base surface and reported an accent no base page renders. | |
| 91 | */ | |
| 92 | function* declarationsInContext(css: string): Generator<[string, string, string]> { | |
| 93 | const stack: string[] = [] | |
| 94 | let i = 0 | |
| 95 | let pending = '' | |
| 96 | while (i < css.length) { | |
| 97 | const ch = css[i] | |
| 98 | if (ch === '{') { | |
| 99 | stack.push(pending.trim().replace(/\s+/g, ' ')) | |
| 100 | pending = '' | |
| 101 | i++ | |
| 102 | } | |
| 103 | else if (ch === '}') { | |
| 104 | stack.pop() | |
| 105 | pending = '' | |
| 106 | i++ | |
| 107 | } | |
| 108 | else if (ch === ';') { | |
| 109 | const decl = /(--[a-z0-9][a-z0-9-]*)\s*:\s*(.+)/s.exec(pending.trim()) | |
| 110 | if (decl) | |
| 111 | yield [stack.join(' | '), decl[1], decl[2].trim()] | |
| 112 | pending = '' | |
| 113 | i++ | |
| 114 | } | |
| 115 | else { | |
| 116 | pending += ch | |
| 117 | i++ | |
| 118 | } | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | function declarations(surface: string, dark: boolean): Map<string, string> { | |
| 123 | const qualifier = SURFACES[surface] | |
| 124 | const out = new Map<string, string>() | |
| 125 | for (const [context, name, value] of declarationsInContext(source)) { | |
| 126 | const scoped = /\.page-dashboard|stx-layout-group/.test(context) | |
| 127 | if (qualifier ? (scoped && !qualifier.test(context)) : scoped) | |
| 128 | continue | |
| 129 | const isDark = /data-theme\s*=\s*["']dark["']|prefers-color-scheme:\s*dark/.test(context) | |
| 130 | const isLight = /data-theme\s*=\s*["']light["']/.test(context) | |
| 131 | if (dark ? isLight : isDark) | |
| 132 | continue | |
| 133 | out.set(name, value) | |
| 134 | } | |
| 135 | return out | |
| 136 | } | |
| 137 | ||
| 138 | function resolveValue(value: string, vars: Map<string, string>, depth = 0): string { | |
| 139 | if (depth > 8) | |
| 140 | return value | |
| 141 | return value.replace(/var\((--[a-z0-9-]+)(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (whole, name, fallback) => { | |
| 142 | const found = vars.get(name) | |
| 143 | if (found) return resolveValue(found, vars, depth + 1) | |
| 144 | if (fallback) return resolveValue(fallback.trim(), vars, depth + 1) | |
| 145 | return whole | |
| 146 | }) | |
| 147 | } | |
| 148 | ||
| 149 | /* | |
| 150 | * What each role is declared as, straight from `theme.extend.colors`. | |
| 151 | * | |
| 152 | * This is the same answer the CSS engine gives -- checked by generating the | |
| 153 | * utilities and comparing, e.g. `text-fg` emits `color: var(--text)` and | |
| 154 | * `colors.fg` is `var(--text)` -- but reading the theme needs no engine, no | |
| 155 | * `dist/` import, and no merge step. | |
| 156 | */ | |
| 157 | const colors = ((theme as { theme?: { extend?: { colors?: Record<string, string> } } }) | |
| 158 | .theme?.extend?.colors) ?? {} | |
| 159 | ||
| 160 | const manifest: Record<string, Record<string, Record<string, string>>> = {} | |
| 161 | const unresolved: string[] = [] | |
| 162 | const missing: string[] = [] | |
| 163 | ||
| 164 | for (const surface of Object.keys(SURFACES)) { | |
| 165 | manifest[surface] = { light: {}, dark: {} } | |
| 166 | for (const mode of ['light', 'dark'] as const) { | |
| 167 | const vars = declarations(surface, mode === 'dark') | |
| 168 | for (const role of ROLES) { | |
| 169 | const expression = colors[role] | |
| 170 | if (!expression) { | |
| 171 | if (surface === 'base' && mode === 'light') missing.push(role) | |
| 172 | continue | |
| 173 | } | |
| 174 | const value = resolveValue(expression, vars) | |
| 175 | manifest[surface][mode][role] = value | |
| 176 | if (value.includes('var(') || value.includes('${')) | |
| 177 | unresolved.push(`${surface}/${mode} ${role}: ${value}`) | |
| 178 | } | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | const lost = MUST_DIFFER | |
| 183 | .filter(([a, b]) => manifest.dashboard.light[a] && manifest.dashboard.light[a] === manifest.dashboard.light[b]) | |
| 184 | .map(([a, b]) => `${a} = ${b}`) | |
| 185 | ||
| 186 | const out = resolve(ROOT, 'config/tokens.json') | |
| 187 | writeFileSync(out, `${JSON.stringify({ roles: manifest, lost }, null, 2)}\n`) | |
| 188 | ||
| 189 | console.log(`Resolved ${Object.keys(manifest.base.light).length} of ${ROLES.length} roles, per surface (${Object.keys(SURFACES).join(', ')}), light and dark.`) | |
| 190 | if (missing.length) | |
| 191 | console.log(` no utility generated for: ${missing.join(', ')}`) | |
| 192 | if (unresolved.length) { | |
| 193 | console.log(` not reducible to a literal (${unresolved.length}):`) | |
| 194 | for (const entry of unresolved) console.log(` ${entry}`) | |
| 195 | } | |
| 196 | console.log(lost.length | |
| 197 | ? ` lost distinctions (${lost.length}): ${lost.join(', ')}` | |
| 198 | : ' no lost distinctions') | |
| 199 | console.log(`Wrote ${out.replace(`${ROOT}/`, '')}`) | |
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * The token manifest: the app's roles as values, for a renderer that is not | |
| 3 | * Crosswind. | |
| 4 | * | |
| 5 | * `scripts/tokens.ts` resolves every colour role `@stacksjs/components` actually | |
| 6 | * uses down to a literal, per surface and per colour mode. These tests pin the | |
| 7 | * two things that are easy to get silently wrong, both of which the first two | |
| 8 | * drafts did get wrong: | |
| 9 | * | |
| 10 | * 1. SURFACES ARE NOT ONE THEME. loghq is emerald on marketing and indigo on | |
| 11 | * the dashboard. A manifest that flattens them emits an accent no page | |
| 12 | * renders. | |
| 13 | * 2. NESTING. `@media (prefers-color-scheme: dark)` contains rules, so a regex | |
| 14 | * over `selector { body }` ends at the first inner `}` and loses every | |
| 15 | * nested selector -- which attributed the dashboard's indigo to the base | |
| 16 | * surface. | |
| 17 | */ | |
| 18 | import { describe, expect, test } from 'bun:test' | |
| 19 | import { readFileSync } from 'node:fs' | |
| 20 | import { join } from 'node:path' | |
| 21 | ||
| 22 | const ROOT = join(import.meta.dir, '../..') | |
| 23 | const manifest = JSON.parse(readFileSync(join(ROOT, 'config/tokens.json'), 'utf8')) as { | |
| 24 | roles: Record<string, Record<string, Record<string, string>>> | |
| 25 | lost: string[] | |
| 26 | } | |
| 27 | const config = readFileSync(join(ROOT, 'config/crosswind.ts'), 'utf8') | |
| 28 | ||
| 29 | describe('token manifest', () => { | |
| 30 | test('covers both surfaces in both modes', () => { | |
| 31 | expect(Object.keys(manifest.roles).sort()).toEqual(['base', 'dashboard']) | |
| 32 | for (const surface of Object.keys(manifest.roles)) | |
| 33 | expect(Object.keys(manifest.roles[surface]).sort(), surface).toEqual(['dark', 'light']) | |
| 34 | }) | |
| 35 | ||
| 36 | test('every role resolves to a value, and none is left as a var() or a template hole', () => { | |
| 37 | for (const [surface, modes] of Object.entries(manifest.roles)) { | |
| 38 | for (const [mode, roles] of Object.entries(modes)) { | |
| 39 | expect(Object.keys(roles).length, `${surface}/${mode}`).toBe(19) | |
| 40 | for (const [role, value] of Object.entries(roles)) { | |
| 41 | expect(`${surface}/${mode}/${role}: ${value}`).not.toContain('var(') | |
| 42 | expect(`${surface}/${mode}/${role}: ${value}`).not.toContain('${') | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | }) | |
| 47 | ||
| 48 | /* | |
| 49 | * The accent is the one role that differs per surface AND per mode, so it is | |
| 50 | * the case that catches a flattened or mis-nested parse. Read back out of the | |
| 51 | * config rather than hardcoded, so this keeps testing the resolution and not | |
| 52 | * a copy of its output. | |
| 53 | */ | |
| 54 | test('the accent of each surface matches what the config declares for it', () => { | |
| 55 | const declared = [...config.matchAll(/(^|\n)\s*([^{\n][^{]*)\{([^}]*)\}/g)] | |
| 56 | expect(declared.length).toBeGreaterThan(0) | |
| 57 | ||
| 58 | // base: the two unscoped declarations; dashboard: the two .page-dashboard ones | |
| 59 | const accents = [...config.matchAll(/--accent:\s*(#[0-9a-f]{6})/gi)].map(m => m[1].toLowerCase()) | |
| 60 | expect(accents).toContain(manifest.roles.base.light.accent) | |
| 61 | expect(accents).toContain(manifest.roles.base.dark.accent) | |
| 62 | expect(accents).toContain(manifest.roles.dashboard.light.accent) | |
| 63 | expect(accents).toContain(manifest.roles.dashboard.dark.accent) | |
| 64 | ||
| 65 | // and the surfaces must not have been flattened into each other | |
| 66 | expect(manifest.roles.base.light.accent).not.toBe(manifest.roles.dashboard.light.accent) | |
| 67 | expect(manifest.roles.base.dark.accent).not.toBe(manifest.roles.dashboard.dark.accent) | |
| 68 | // nor light into dark | |
| 69 | expect(manifest.roles.base.light.accent).not.toBe(manifest.roles.base.dark.accent) | |
| 70 | expect(manifest.roles.dashboard.light.accent).not.toBe(manifest.roles.dashboard.dark.accent) | |
| 71 | }) | |
| 72 | ||
| 73 | /* | |
| 74 | * Recorded, not asserted away. This app has one text colour and one panel | |
| 75 | * colour, so a component asking for `fg-strong` or `bg-field` gets the same | |
| 76 | * value as `fg` and `bg-panel`. That is a real limit on what a shared | |
| 77 | * component can express here; the list exists so a NEW collapse is visible. | |
| 78 | */ | |
| 79 | test('the known lost distinctions are exactly these two', () => { | |
| 80 | expect(manifest.lost.sort()).toEqual(['fg = fg-strong', 'panel = field']) | |
| 81 | }) | |
| 82 | }) | |