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.jsonadded+96-0
Changes to config/tokens.json
@@ -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}
scripts/tokens.tsadded+199-0
Changes to scripts/tokens.ts
@@ -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 */
36import { readFileSync, 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
68const 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 */
77const 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 */
92function* 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
122function 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
138function 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 */
157const colors = ((theme as { theme?: { extend?: { colors?: Record<string, string> } } })
158 .theme?.extend?.colors) ?? {}
159
160const manifest: Record<string, Record<string, Record<string, string>>> = {}
161const unresolved: string[] = []
162const missing: string[] = []
163
164for (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
182const 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
186const out = resolve(ROOT, 'config/tokens.json')
187writeFileSync(out, `${JSON.stringify({ roles: manifest, lost }, null, 2)}\n`)
188
189console.log(`Resolved ${Object.keys(manifest.base.light).length} of ${ROLES.length} roles, per surface (${Object.keys(SURFACES).join(', ')}), light and dark.`)
190if (missing.length)
191 console.log(` no utility generated for: ${missing.join(', ')}`)
192if (unresolved.length) {
193 console.log(` not reducible to a literal (${unresolved.length}):`)
194 for (const entry of unresolved) console.log(` ${entry}`)
195}
196console.log(lost.length
197 ? ` lost distinctions (${lost.length}): ${lost.join(', ')}`
198 : ' no lost distinctions')
199console.log(`Wrote ${out.replace(`${ROOT}/`, '')}`)
tests/unit/tokens.test.tsadded+82-0
Changes to tests/unit/tokens.test.ts
@@ -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 */
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 roles: Record<string, Record<string, Record<string, string>>>
25 lost: string[]
26}
27const config = readFileSync(join(ROOT, 'config/crosswind.ts'), 'utf8')
28
29describe('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})