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.
| @@ -1,141 +0,0 @@ | ||
| 1 | { | |
| 2 | "darkMode": true, | |
| 3 | "roles": { | |
| 4 | "base": { | |
| 5 | "light": { | |
| 6 | "fg": "#0b0f19", | |
| 7 | "fg-strong": "#0b0f19", | |
| 8 | "fg-muted": "#4b5565", | |
| 9 | "fg-soft": "#6b7280", | |
| 10 | "fg-subtle": "#6b7280", | |
| 11 | "page": "#fbfbfd", | |
| 12 | "panel": "#ffffff", | |
| 13 | "field": "#ffffff", | |
| 14 | "surface": "#ffffff", | |
| 15 | "surface-sunken": "#fbfbfd", | |
| 16 | "line": "rgba(15, 23, 42, 0.09)", | |
| 17 | "line-strong": "color-mix(in srgb, #6b7280 55%, rgba(15, 23, 42, 0.09))", | |
| 18 | "accent": "#047857", | |
| 19 | "accent-solid": "#047857", | |
| 20 | "accent-ink": "#ffffff", | |
| 21 | "danger": "#b91c1c", | |
| 22 | "success": "color-mix(in srgb, #16a34a 75%, #0b0f19)", | |
| 23 | "warning": "#b45309", | |
| 24 | "info": "#4b5565" | |
| 25 | }, | |
| 26 | "dark": { | |
| 27 | "fg": "#f3f5f9", | |
| 28 | "fg-strong": "#f3f5f9", | |
| 29 | "fg-muted": "#b3bccb", | |
| 30 | "fg-soft": "#79839a", | |
| 31 | "fg-subtle": "#79839a", | |
| 32 | "page": "#090b11", | |
| 33 | "panel": "#10131c", | |
| 34 | "field": "#10131c", | |
| 35 | "surface": "#10131c", | |
| 36 | "surface-sunken": "#090b11", | |
| 37 | "line": "rgba(148, 163, 184, 0.12)", | |
| 38 | "line-strong": "color-mix(in srgb, #79839a 55%, rgba(148, 163, 184, 0.12))", | |
| 39 | "accent": "#34d399", | |
| 40 | "accent-solid": "#34d399", | |
| 41 | "accent-ink": "#04140d", | |
| 42 | "danger": "#f87171", | |
| 43 | "success": "color-mix(in srgb, #4ade80 75%, #f3f5f9)", | |
| 44 | "warning": "#f59e0b", | |
| 45 | "info": "#b3bccb" | |
| 46 | } | |
| 47 | }, | |
| 48 | "dashboard": { | |
| 49 | "light": { | |
| 50 | "fg": "#0b0f19", | |
| 51 | "fg-strong": "#0b0f19", | |
| 52 | "fg-muted": "#4b5565", | |
| 53 | "fg-soft": "#6b7280", | |
| 54 | "fg-subtle": "#6b7280", | |
| 55 | "page": "#fbfbfd", | |
| 56 | "panel": "#ffffff", | |
| 57 | "field": "#ffffff", | |
| 58 | "surface": "#ffffff", | |
| 59 | "surface-sunken": "#fbfbfd", | |
| 60 | "line": "rgba(15, 23, 42, 0.09)", | |
| 61 | "line-strong": "color-mix(in srgb, #6b7280 55%, rgba(15, 23, 42, 0.09))", | |
| 62 | "accent": "#4f46e5", | |
| 63 | "accent-solid": "#4f46e5", | |
| 64 | "accent-ink": "#ffffff", | |
| 65 | "danger": "#b91c1c", | |
| 66 | "success": "color-mix(in srgb, #16a34a 75%, #0b0f19)", | |
| 67 | "warning": "#b45309", | |
| 68 | "info": "#4b5565" | |
| 69 | }, | |
| 70 | "dark": { | |
| 71 | "fg": "#f3f5f9", | |
| 72 | "fg-strong": "#f3f5f9", | |
| 73 | "fg-muted": "#b3bccb", | |
| 74 | "fg-soft": "#79839a", | |
| 75 | "fg-subtle": "#79839a", | |
| 76 | "page": "#090b11", | |
| 77 | "panel": "#10131c", | |
| 78 | "field": "#10131c", | |
| 79 | "surface": "#10131c", | |
| 80 | "surface-sunken": "#090b11", | |
| 81 | "line": "rgba(148, 163, 184, 0.12)", | |
| 82 | "line-strong": "color-mix(in srgb, #79839a 55%, rgba(148, 163, 184, 0.12))", | |
| 83 | "accent": "#818cf8", | |
| 84 | "accent-solid": "#818cf8", | |
| 85 | "accent-ink": "#04140d", | |
| 86 | "danger": "#f87171", | |
| 87 | "success": "color-mix(in srgb, #4ade80 75%, #f3f5f9)", | |
| 88 | "warning": "#f59e0b", | |
| 89 | "info": "#b3bccb" | |
| 90 | } | |
| 91 | }, | |
| 92 | "marketing": { | |
| 93 | "light": { | |
| 94 | "fg": "#0b0f19", | |
| 95 | "fg-strong": "#0b0f19", | |
| 96 | "fg-muted": "#4b5565", | |
| 97 | "fg-soft": "#6b7280", | |
| 98 | "fg-subtle": "#6b7280", | |
| 99 | "page": "#fbfbfd", | |
| 100 | "panel": "#ffffff", | |
| 101 | "field": "#ffffff", | |
| 102 | "surface": "#ffffff", | |
| 103 | "surface-sunken": "#fbfbfd", | |
| 104 | "line": "rgba(15, 23, 42, 0.09)", | |
| 105 | "line-strong": "color-mix(in srgb, #6b7280 55%, rgba(15, 23, 42, 0.09))", | |
| 106 | "accent": "#047857", | |
| 107 | "accent-solid": "#047857", | |
| 108 | "accent-ink": "#ffffff", | |
| 109 | "danger": "#b91c1c", | |
| 110 | "success": "color-mix(in srgb, #16a34a 75%, #0b0f19)", | |
| 111 | "warning": "#b45309", | |
| 112 | "info": "#4b5565" | |
| 113 | }, | |
| 114 | "dark": { | |
| 115 | "fg": "#f3f5f9", | |
| 116 | "fg-strong": "#f3f5f9", | |
| 117 | "fg-muted": "#b3bccb", | |
| 118 | "fg-soft": "#79839a", | |
| 119 | "fg-subtle": "#79839a", | |
| 120 | "page": "#090b11", | |
| 121 | "panel": "#10131c", | |
| 122 | "field": "#10131c", | |
| 123 | "surface": "#10131c", | |
| 124 | "surface-sunken": "#090b11", | |
| 125 | "line": "rgba(148, 163, 184, 0.12)", | |
| 126 | "line-strong": "color-mix(in srgb, #79839a 55%, rgba(148, 163, 184, 0.12))", | |
| 127 | "accent": "#34d399", | |
| 128 | "accent-solid": "#34d399", | |
| 129 | "accent-ink": "#04140d", | |
| 130 | "danger": "#f87171", | |
| 131 | "success": "color-mix(in srgb, #4ade80 75%, #f3f5f9)", | |
| 132 | "warning": "#f59e0b", | |
| 133 | "info": "#b3bccb" | |
| 134 | } | |
| 135 | } | |
| 136 | }, | |
| 137 | "lost": [ | |
| 138 | "fg = fg-strong", | |
| 139 | "panel = field" | |
| 140 | ] | |
| 141 | } | |
| @@ -1,291 +0,0 @@ | ||
| 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 { 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 | /* | |
| 69 | * The CSS the engine actually receives, not the TypeScript that produces it. | |
| 70 | * | |
| 71 | * Parsing the config file was the wrong source and failed three different ways: | |
| 72 | * comments were read as selectors, `@media` nesting was lost to a regex, and | |
| 73 | * bughq -- where this was found -- declares its tokens as ARRAY ELEMENTS | |
| 74 | * (`` `--text: ${p.text}`, ``) with no semicolon at all, so a scan keyed on `;` | |
| 75 | * found none of them. `preflights[].getCSS()` is the generated stylesheet, so | |
| 76 | * every app's declarations arrive in one shape no matter how the config spells | |
| 77 | * them, already interpolated. | |
| 78 | */ | |
| 79 | const source = ((theme as { preflights?: { getCSS: () => string }[] }).preflights ?? []) | |
| 80 | .map(preflight => preflight.getCSS()) | |
| 81 | .join('\n') | |
| 82 | ||
| 83 | /** | |
| 84 | * The custom properties in force for one surface and one colour mode. | |
| 85 | * | |
| 86 | * Source order with later winning, which is what the cascade does for equal | |
| 87 | * specificity, rather than "first wins" -- the earlier version mixed the two and | |
| 88 | * produced a light value from one surface and a dark value from another. | |
| 89 | */ | |
| 90 | const SURFACES: Record<string, RegExp | null> = { | |
| 91 | base: null, | |
| 92 | dashboard: /\.page-dashboard/, | |
| 93 | marketing: /stx-layout-group/, | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Walk the CSS with real brace matching, keeping the full selector stack. | |
| 98 | * | |
| 99 | * A regex over `selector { body }` cannot do this: an `@media` block contains | |
| 100 | * rules, so the match ends at the FIRST inner `}` and every nested selector is | |
| 101 | * swallowed into what looks like the media query's own body. This app has two | |
| 102 | * `@media (prefers-color-scheme: dark)` blocks, one of them holding the | |
| 103 | * `.page-dashboard` overrides -- so the naive version attributed the dashboard's | |
| 104 | * indigo to the base surface and reported an accent no base page renders. | |
| 105 | */ | |
| 106 | function* declarationsInContext(raw: string): Generator<[string, string, string]> { | |
| 107 | /* | |
| 108 | * Comments out first. The scan below treats whatever precedes a `{` as a | |
| 109 | * selector, so a comment is not inert here -- prose containing a brace, a | |
| 110 | * semicolon or the words `data-theme="dark"` lands in the selector stack and | |
| 111 | * can reclassify a whole block. Found while pointing this at the other five | |
| 112 | * apps, where sentences from comment blocks turned up as selector names. | |
| 113 | */ | |
| 114 | const css = raw.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/^[ \t]*\/\/.*$/gm, ' ') | |
| 115 | const stack: string[] = [] | |
| 116 | let i = 0 | |
| 117 | let pending = '' | |
| 118 | while (i < css.length) { | |
| 119 | const ch = css[i] | |
| 120 | if (ch === '{') { | |
| 121 | stack.push(pending.trim().replace(/\s+/g, ' ')) | |
| 122 | pending = '' | |
| 123 | i++ | |
| 124 | } | |
| 125 | else if (ch === '}') { | |
| 126 | stack.pop() | |
| 127 | pending = '' | |
| 128 | i++ | |
| 129 | } | |
| 130 | else if (ch === ';') { | |
| 131 | const decl = /(--[a-z0-9][a-z0-9-]*)\s*:\s*(.+)/s.exec(pending.trim()) | |
| 132 | if (decl) | |
| 133 | yield [stack.join(' | '), decl[1], decl[2].trim()] | |
| 134 | pending = '' | |
| 135 | i++ | |
| 136 | } | |
| 137 | else { | |
| 138 | pending += ch | |
| 139 | i++ | |
| 140 | } | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | function declarations(surface: string, dark: boolean): Map<string, string> { | |
| 145 | const qualifier = SURFACES[surface] | |
| 146 | const out = new Map<string, string>() | |
| 147 | for (const [context, name, value] of declarationsInContext(source)) { | |
| 148 | const scoped = /\.page-dashboard|stx-layout-group/.test(context) | |
| 149 | if (qualifier ? (scoped && !qualifier.test(context)) : scoped) | |
| 150 | continue | |
| 151 | const isDark = /data-theme\s*=\s*["']dark["']|prefers-color-scheme:\s*dark/.test(context) | |
| 152 | // `@media (prefers-color-scheme: light)` counts too. Without it, a light | |
| 153 | // media block reads as unqualified and overwrites the dark column -- which | |
| 154 | // is what happened to analyticshq's dark-first marketing and auth surfaces, | |
| 155 | // where the unscoped block holds the DARK values and light is the override. | |
| 156 | const isLight = /data-theme\s*=\s*["']light["']|prefers-color-scheme:\s*light/.test(context) | |
| 157 | if (dark ? isLight : isDark) | |
| 158 | continue | |
| 159 | out.set(name, value) | |
| 160 | } | |
| 161 | return out | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Resolve `var()` by matching parentheses, not by regex. | |
| 166 | * | |
| 167 | * A regex has to bound how deep a fallback may nest, and these nest deeper than | |
| 168 | * is comfortable to guess: analyticshq declares | |
| 169 | * `warning: var(--warn, var(--text-2, var(--muted)))`, three levels, because | |
| 170 | * its three layout groups name that colour three different ways. A | |
| 171 | * one-level-nesting pattern resolved the inner chain and left the outer | |
| 172 | * `var(--warn, ...)` sitting in the output. | |
| 173 | */ | |
| 174 | function resolveValue(value: string, vars: Map<string, string>, depth = 0): string { | |
| 175 | if (depth > 8) | |
| 176 | return value | |
| 177 | ||
| 178 | let out = '' | |
| 179 | let i = 0 | |
| 180 | while (i < value.length) { | |
| 181 | const at = value.indexOf('var(', i) | |
| 182 | if (at < 0) { | |
| 183 | out += value.slice(i) | |
| 184 | break | |
| 185 | } | |
| 186 | out += value.slice(i, at) | |
| 187 | ||
| 188 | let depthHere = 0 | |
| 189 | let close = at + 3 | |
| 190 | for (; close < value.length; close++) { | |
| 191 | if (value[close] === '(') depthHere++ | |
| 192 | else if (value[close] === ')') { depthHere--; if (depthHere === 0) break } | |
| 193 | } | |
| 194 | const inner = value.slice(at + 4, close) | |
| 195 | ||
| 196 | // the first comma that is not inside a nested call separates name from fallback | |
| 197 | let comma = -1 | |
| 198 | let nested = 0 | |
| 199 | for (let x = 0; x < inner.length; x++) { | |
| 200 | if (inner[x] === '(') nested++ | |
| 201 | else if (inner[x] === ')') nested-- | |
| 202 | else if (inner[x] === ',' && nested === 0) { comma = x; break } | |
| 203 | } | |
| 204 | const name = (comma < 0 ? inner : inner.slice(0, comma)).trim() | |
| 205 | const fallback = comma < 0 ? '' : inner.slice(comma + 1).trim() | |
| 206 | ||
| 207 | const declared = vars.get(name) | |
| 208 | if (declared !== undefined) | |
| 209 | out += resolveValue(declared, vars, depth + 1) | |
| 210 | else if (fallback) | |
| 211 | out += resolveValue(fallback, vars, depth + 1) | |
| 212 | else | |
| 213 | out += value.slice(at, close + 1) | |
| 214 | ||
| 215 | i = close + 1 | |
| 216 | } | |
| 217 | return out | |
| 218 | } | |
| 219 | ||
| 220 | /* | |
| 221 | * What each role is declared as, straight from `theme.extend.colors`. | |
| 222 | * | |
| 223 | * This is the same answer the CSS engine gives -- checked by generating the | |
| 224 | * utilities and comparing, e.g. `text-fg` emits `color: var(--text)` and | |
| 225 | * `colors.fg` is `var(--text)` -- but reading the theme needs no engine, no | |
| 226 | * `dist/` import, and no merge step. | |
| 227 | */ | |
| 228 | const colors = ((theme as { theme?: { extend?: { colors?: Record<string, string> } } }) | |
| 229 | .theme?.extend?.colors) ?? {} | |
| 230 | ||
| 231 | const manifest: Record<string, Record<string, Record<string, string>>> = {} | |
| 232 | const unresolved: string[] = [] | |
| 233 | const missing: string[] = [] | |
| 234 | ||
| 235 | for (const surface of Object.keys(SURFACES)) { | |
| 236 | manifest[surface] = { light: {}, dark: {} } | |
| 237 | for (const mode of ['light', 'dark'] as const) { | |
| 238 | const vars = declarations(surface, mode === 'dark') | |
| 239 | for (const role of ROLES) { | |
| 240 | const expression = colors[role] | |
| 241 | if (!expression) { | |
| 242 | if (surface === Object.keys(SURFACES)[0] && mode === 'light') missing.push(role) | |
| 243 | continue | |
| 244 | } | |
| 245 | const value = resolveValue(expression, vars) | |
| 246 | manifest[surface][mode][role] = value | |
| 247 | if (value.includes('var(') || value.includes('${')) | |
| 248 | unresolved.push(`${surface}/${mode} ${role}: ${value}`) | |
| 249 | } | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | /* | |
| 254 | * Whether this app has a dark mode at all. | |
| 255 | * | |
| 256 | * commshq has none -- not one `prefers-color-scheme: dark` or | |
| 257 | * `[data-theme="dark"]` in its stylesheet -- so its two columns are the same | |
| 258 | * palette twice. Recording that is the difference between "this app is | |
| 259 | * light-only" and "the dark context was never found", which look identical in | |
| 260 | * the output and are very different facts. | |
| 261 | */ | |
| 262 | const hasDarkMode = /prefers-color-scheme:\s*dark|data-theme\s*=\s*["']dark["']/.test(source) | |
| 263 | ||
| 264 | /* | |
| 265 | * Read off the first surface. Not `base` by name: analyticshq declares almost | |
| 266 | * nothing unscoped and its surfaces are the three layout groups, so a hardcoded | |
| 267 | * `base` resolves to nothing there. A collapse is a limit of the palette rather | |
| 268 | * than of one surface anyway -- the surfaces differ in accent, not in whether | |
| 269 | * two greys are the same grey. | |
| 270 | */ | |
| 271 | const primary = Object.keys(SURFACES)[0] | |
| 272 | const lost = MUST_DIFFER | |
| 273 | .filter(([a, b]) => manifest[primary].light[a] && manifest[primary].light[a] === manifest[primary].light[b]) | |
| 274 | .map(([a, b]) => `${a} = ${b}`) | |
| 275 | ||
| 276 | const out = resolve(ROOT, 'config/tokens.json') | |
| 277 | writeFileSync(out, `${JSON.stringify({ darkMode: hasDarkMode, roles: manifest, lost }, null, 2)}\n`) | |
| 278 | ||
| 279 | console.log(`Resolved ${Object.keys(manifest[primary].light).length} of ${ROLES.length} roles, per surface (${Object.keys(SURFACES).join(', ')}), light and dark.`) | |
| 280 | if (missing.length) | |
| 281 | console.log(` no utility generated for: ${missing.join(', ')}`) | |
| 282 | if (unresolved.length) { | |
| 283 | console.log(` not reducible to a literal (${unresolved.length}):`) | |
| 284 | for (const entry of unresolved) console.log(` ${entry}`) | |
| 285 | } | |
| 286 | if (!hasDarkMode) | |
| 287 | console.log(' this app declares no dark mode; both columns are the same palette') | |
| 288 | console.log(lost.length | |
| 289 | ? ` lost distinctions (${lost.length}): ${lost.join(', ')}` | |
| 290 | : ' no lost distinctions') | |
| 291 | console.log(`Wrote ${out.replace(`${ROOT}/`, '')}`) | |
| @@ -1,117 +0,0 @@ | ||
| 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 | darkMode: boolean | |
| 25 | roles: Record<string, Record<string, Record<string, string>>> | |
| 26 | lost: string[] | |
| 27 | } | |
| 28 | const config = readFileSync(join(ROOT, 'config/crosswind.ts'), 'utf8') | |
| 29 | ||
| 30 | describe('token manifest', () => { | |
| 31 | /* | |
| 32 | * Recorded because the alternative reading is indistinguishable in the output: | |
| 33 | * commshq declares no dark mode at all, so its two columns are the same | |
| 34 | * palette twice, which looks exactly like a dark context the resolver failed | |
| 35 | * to find. This app has one. | |
| 36 | */ | |
| 37 | test('this app has a dark mode, and light and dark differ', () => { | |
| 38 | expect(manifest.darkMode).toBe(true) | |
| 39 | for (const surface of Object.keys(manifest.roles)) { | |
| 40 | const light = manifest.roles[surface].light | |
| 41 | const dark = manifest.roles[surface].dark | |
| 42 | const identical = Object.keys(light).filter(role => light[role] === dark[role]).length | |
| 43 | expect(`${surface}: all identical = ${identical === Object.keys(light).length}`) | |
| 44 | .toBe(`${surface}: all identical = false`) | |
| 45 | } | |
| 46 | }) | |
| 47 | ||
| 48 | test('covers both surfaces in both modes', () => { | |
| 49 | expect(Object.keys(manifest.roles).sort()).toEqual(['base', 'dashboard', 'marketing']) | |
| 50 | for (const surface of Object.keys(manifest.roles)) | |
| 51 | expect(Object.keys(manifest.roles[surface]).sort(), surface).toEqual(['dark', 'light']) | |
| 52 | }) | |
| 53 | ||
| 54 | test('every role resolves to a value, and none is left as a var() or a template hole', () => { | |
| 55 | for (const [surface, modes] of Object.entries(manifest.roles)) { | |
| 56 | for (const [mode, roles] of Object.entries(modes)) { | |
| 57 | expect(Object.keys(roles).length, `${surface}/${mode}`).toBe(19) | |
| 58 | for (const [role, value] of Object.entries(roles)) { | |
| 59 | expect(`${surface}/${mode}/${role}: ${value}`).not.toContain('var(') | |
| 60 | expect(`${surface}/${mode}/${role}: ${value}`).not.toContain('${') | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | }) | |
| 65 | ||
| 66 | /* | |
| 67 | * The accent is the one role that differs per surface AND per mode, so it is | |
| 68 | * the case that catches a flattened or mis-nested parse. Read back out of the | |
| 69 | * config rather than hardcoded, so this keeps testing the resolution and not | |
| 70 | * a copy of its output. | |
| 71 | */ | |
| 72 | test('the accent of each surface matches what the config declares for it', () => { | |
| 73 | const declared = [...config.matchAll(/(^|\n)\s*([^{\n][^{]*)\{([^}]*)\}/g)] | |
| 74 | expect(declared.length).toBeGreaterThan(0) | |
| 75 | ||
| 76 | // base: the two unscoped declarations; dashboard: the two .page-dashboard ones | |
| 77 | const accents = [...config.matchAll(/--accent:\s*(#[0-9a-f]{6})/gi)].map(m => m[1].toLowerCase()) | |
| 78 | expect(accents).toContain(manifest.roles.base.light.accent) | |
| 79 | expect(accents).toContain(manifest.roles.base.dark.accent) | |
| 80 | expect(accents).toContain(manifest.roles.dashboard.light.accent) | |
| 81 | expect(accents).toContain(manifest.roles.dashboard.dark.accent) | |
| 82 | ||
| 83 | // and the surfaces must not have been flattened into each other | |
| 84 | expect(manifest.roles.base.light.accent).not.toBe(manifest.roles.dashboard.light.accent) | |
| 85 | expect(manifest.roles.base.dark.accent).not.toBe(manifest.roles.dashboard.dark.accent) | |
| 86 | // nor light into dark | |
| 87 | expect(manifest.roles.base.light.accent).not.toBe(manifest.roles.base.dark.accent) | |
| 88 | expect(manifest.roles.dashboard.light.accent).not.toBe(manifest.roles.dashboard.dark.accent) | |
| 89 | }) | |
| 90 | ||
| 91 | /* | |
| 92 | * Recorded, not asserted away. This app has one text colour and one panel | |
| 93 | * colour, so a component asking for `fg-strong` or `bg-field` gets the same | |
| 94 | * value as `fg` and `bg-panel`. That is a real limit on what a shared | |
| 95 | * component can express here; the list exists so a NEW collapse is visible. | |
| 96 | */ | |
| 97 | /* | |
| 98 | * Marketing declares its own block but overrides nothing any component role | |
| 99 | * reads, so it resolves identically to base. Asserted rather than left | |
| 100 | * implicit: if marketing ever moves off the base palette, this is where it | |
| 101 | * shows up, instead of the manifest quietly carrying a surface nobody checks. | |
| 102 | */ | |
| 103 | test('marketing resolves identically to base, and dashboard differs only in the accent', () => { | |
| 104 | for (const mode of ['light', 'dark'] as const) | |
| 105 | expect(manifest.roles.marketing[mode], mode).toEqual(manifest.roles.base[mode]) | |
| 106 | ||
| 107 | for (const mode of ['light', 'dark'] as const) { | |
| 108 | const differing = Object.keys(manifest.roles.base[mode]) | |
| 109 | .filter(role => manifest.roles.base[mode][role] !== manifest.roles.dashboard[mode][role]) | |
| 110 | expect(differing.sort(), mode).toEqual(['accent', 'accent-solid']) | |
| 111 | } | |
| 112 | }) | |
| 113 | ||
| 114 | test('the known lost distinctions are exactly these two', () => { | |
| 115 | expect(manifest.lost.sort()).toEqual(['fg = fg-strong', 'panel = field']) | |
| 116 | }) | |
| 117 | }) | |