ReviewOS

also looking at this

loghqorg/loghq

Promote main to production: signed-out chrome and the subscriber tables

#17
Merged glennmichael123 wants to merge main into production
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.

config/tokens.jsondeleted+0-141
Changes to config/tokens.json
@@ -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}
scripts/tokens.tsdeleted+0-291
Changes to scripts/tokens.ts
@@ -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 */
36import { writeFileSync } from 'node:fs'
37import { resolve } from 'node:path'
38import theme from '../config/crosswind'
39
40const 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 */
48const 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. */
57const 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 */
79const 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 */
90const 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 */
106function* 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
144function 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 */
174function 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 */
228const colors = ((theme as { theme?: { extend?: { colors?: Record<string, string> } } })
229 .theme?.extend?.colors) ?? {}
230
231const manifest: Record<string, Record<string, Record<string, string>>> = {}
232const unresolved: string[] = []
233const missing: string[] = []
234
235for (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 */
262const 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 */
271const primary = Object.keys(SURFACES)[0]
272const 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
276const out = resolve(ROOT, 'config/tokens.json')
277writeFileSync(out, `${JSON.stringify({ darkMode: hasDarkMode, roles: manifest, lost }, null, 2)}\n`)
278
279console.log(`Resolved ${Object.keys(manifest[primary].light).length} of ${ROLES.length} roles, per surface (${Object.keys(SURFACES).join(', ')}), light and dark.`)
280if (missing.length)
281 console.log(` no utility generated for: ${missing.join(', ')}`)
282if (unresolved.length) {
283 console.log(` not reducible to a literal (${unresolved.length}):`)
284 for (const entry of unresolved) console.log(` ${entry}`)
285}
286if (!hasDarkMode)
287 console.log(' this app declares no dark mode; both columns are the same palette')
288console.log(lost.length
289 ? ` lost distinctions (${lost.length}): ${lost.join(', ')}`
290 : ' no lost distinctions')
291console.log(`Wrote ${out.replace(`${ROOT}/`, '')}`)
tests/unit/tokens.test.tsdeleted+0-117
Changes to tests/unit/tokens.test.ts
@@ -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 */
18import { describe, expect, test } from 'bun:test'
19import { readFileSync } from 'node:fs'
20import { join } from 'node:path'
21
22const ROOT = join(import.meta.dir, '../..')
23const 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}
28const config = readFileSync(join(ROOT, 'config/crosswind.ts'), 'utf8')
29
30describe('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})