Skip to content

Commit 7326927

Browse files
committed
Keep artifact tokens synced with the app theme
1 parent 06e9eb2 commit 7326927

2 files changed

Lines changed: 124 additions & 62 deletions

File tree

apps/sim/lib/workspace-files/artifact-stylesheet.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
/**
22
* @vitest-environment node
33
*/
4+
5+
import { readFileSync } from 'node:fs'
6+
import { join } from 'node:path'
47
import { describe, expect, it } from 'vitest'
58
import {
9+
ARTIFACT_TOKENS,
610
SIM_ARTIFACT_SHELL,
711
SIM_ARTIFACT_STYLESHEET,
812
simTokenOverrides,
@@ -209,3 +213,75 @@ describe('simTokenOverrides', () => {
209213
expect(simTokenOverrides('light')).toBe('')
210214
})
211215
})
216+
217+
/** Extracts the body of the first block whose opening selector matches. */
218+
function cssBlock(css: string, start: RegExp): string {
219+
const match = start.exec(css)
220+
if (!match) throw new Error(`selector not found: ${start}`)
221+
const open = css.indexOf('{', match.index) + 1
222+
let depth = 1
223+
let end = open
224+
while (depth > 0) {
225+
const ch = css[end]
226+
if (ch === '{') depth += 1
227+
if (ch === '}') depth -= 1
228+
end += 1
229+
}
230+
return css.slice(open, end - 1)
231+
}
232+
233+
/** First declaration per token, comments stripped, whitespace normalized. */
234+
function tokenMap(block: string): Map<string, string> {
235+
const map = new Map<string, string>()
236+
for (const decl of block.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
237+
const value = decl[2]
238+
.replace(/\/\*[\s\S]*?\*\//g, '')
239+
.replace(/\s+/g, ' ')
240+
.trim()
241+
.toLowerCase()
242+
if (!map.has(decl[1])) map.set(decl[1], value)
243+
}
244+
return map
245+
}
246+
247+
// The sheet's token values are MIRRORS of globals.css — the surfaces the live
248+
// overrides cannot reach (downloaded/shared/standalone documents, and the
249+
// theme the app is not currently in) render from them. A silent retune of
250+
// globals.css is invisible until a shared page looks off-brand; this makes it
251+
// a named test failure instead.
252+
describe('token mirrors stay in sync with globals.css', () => {
253+
const globals = readFileSync(join(process.cwd(), 'app/_styles/globals.css'), 'utf8')
254+
const appLight = tokenMap(cssBlock(globals, /:root,\s*\n\s*\.light\s*\{/))
255+
const appDark = tokenMap(cssBlock(globals, /\n {2}\.dark\s*\{/))
256+
const sheetLight = tokenMap(cssBlock(SIM_ARTIFACT_STYLESHEET, /^:root \{/))
257+
const sheetMediaDark = tokenMap(
258+
cssBlock(SIM_ARTIFACT_STYLESHEET, /:root:not\(\[data-theme="light"\]\)\s*\{/)
259+
)
260+
const sheetAttrDark = tokenMap(
261+
cssBlock(SIM_ARTIFACT_STYLESHEET, /:root\[data-theme="dark"\]\s*\{/)
262+
)
263+
264+
it('mirrors every consumed token in both themes', () => {
265+
const drift: string[] = []
266+
for (const token of ARTIFACT_TOKENS) {
267+
const appLightValue = appLight.get(token)
268+
const appDarkValue = appDark.get(token)
269+
if (!appLightValue || !appDarkValue) {
270+
drift.push(
271+
`${token}: not defined in globals.css (light=${appLightValue}, dark=${appDarkValue})`
272+
)
273+
continue
274+
}
275+
if (sheetLight.get(token) !== appLightValue) {
276+
drift.push(`${token} light: sheet=${sheetLight.get(token)} app=${appLightValue}`)
277+
}
278+
if (sheetMediaDark.get(token) !== appDarkValue) {
279+
drift.push(`${token} dark(media): sheet=${sheetMediaDark.get(token)} app=${appDarkValue}`)
280+
}
281+
if (sheetAttrDark.get(token) !== appDarkValue) {
282+
drift.push(`${token} dark(stamp): sheet=${sheetAttrDark.get(token)} app=${appDarkValue}`)
283+
}
284+
}
285+
expect(drift).toEqual([])
286+
})
287+
})

apps/sim/lib/workspace-files/artifact-stylesheet.ts

Lines changed: 48 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function usesSimArtifactStyles(content: string): boolean {
3030
* time keeps `globals.css` the single definition: change it and every page
3131
* follows on next render, with no build step and nothing for CI to check.
3232
*/
33-
const ARTIFACT_TOKENS = [
33+
export const ARTIFACT_TOKENS = [
3434
'--bg',
3535
'--surface-1',
3636
'--surface-2',
@@ -110,9 +110,53 @@ export function simTokenOverrides(theme: 'dark' | 'light'): string {
110110
* system stack (pages live inside the app, and Inter read as foreign next to
111111
* emcn/sim chrome). The docs' geometry survives; only the face differs.
112112
*/
113+
/**
114+
* The dark palette, mirrored from globals.css and emitted under BOTH dark
115+
* selectors (the prefers-color-scheme fallback and the explicit
116+
* data-theme="dark" stamp) from this single constant — two hand-maintained
117+
* copies is how the badge colors already diverged between them. The
118+
* mirror-vs-app sync is enforced by artifact-stylesheet.test.ts, which parses
119+
* globals.css and fails naming any token that drifts.
120+
*/
121+
const DARK_TOKEN_DECLARATIONS = `
122+
--bg: #1b1b1b;
123+
--surface-1: #1e1e1e;
124+
--surface-2: #232323;
125+
--surface-3: #242424;
126+
--surface-4: #292929;
127+
--surface-5: #363636;
128+
--surface-hover: #262626;
129+
--surface-active: #2c2c2c;
130+
--border: #444444;
131+
--text-primary: #e6e6e6;
132+
--text-secondary: #cccccc;
133+
--text-body: #c1c1c1;
134+
--text-muted: #6e6e6e;
135+
--text-icon: #969696;
136+
--text-error: #ef4444;
137+
--brand-accent: #33c482;
138+
--brand-secondary: #33b4ff;
139+
--warning: #ff6600;
140+
--badge-success-bg: rgba(34, 197, 94, 0.2);
141+
--badge-success-text: #86efac;
142+
--badge-orange-bg: rgba(249, 115, 22, 0.2);
143+
--badge-orange-text: #fdba74;
144+
--badge-error-bg: #551a1a;
145+
--badge-error-text: #fca5a5;
146+
--badge-gray-bg: #3a3a3a;
147+
--badge-gray-text: #a8a8a8;
148+
--badge-blue-bg: rgba(59, 130, 246, 0.2);
149+
--badge-blue-text: #93c5fd;
150+
--badge-purple-bg: rgba(168, 85, 247, 0.2);
151+
--badge-purple-text: #d8b4fe;
152+
--text-icon-muted: #949494;
153+
--code-bg: #1f1f1f;
154+
--code-surface: var(--code-bg);
155+
--selection-bg: #264f78;`
156+
113157
export const SIM_ARTIFACT_STYLESHEET = `
114158
:root {
115-
--bg: #ffffff;
159+
--bg: #fefefe;
116160
--surface-1: #fbfbfb;
117161
--surface-2: #ffffff;
118162
--surface-3: #f7f7f7;
@@ -157,68 +201,10 @@ export const SIM_ARTIFACT_STYLESHEET = `
157201
--text-md: 16px;
158202
}
159203
@media (prefers-color-scheme: dark) {
160-
:root:not([data-theme="light"]) {
161-
--bg: #1b1b1b;
162-
--surface-1: #1e1e1e;
163-
--surface-2: #232323;
164-
--surface-3: #242424;
165-
--surface-4: #2c2c2c;
166-
--surface-5: #363636;
167-
--surface-hover: #262626;
168-
--surface-active: #2c2c2c;
169-
--border: #444444;
170-
--text-primary: #e6e6e6;
171-
--text-secondary: #cccccc;
172-
--text-body: #c1c1c1;
173-
--text-muted: #6e6e6e;
174-
--text-icon: #969696;
175-
--warning: #ff6600;
176-
--badge-success-bg: rgba(34, 197, 94, 0.2);
177-
--badge-success-text: #86efac;
178-
--badge-orange-bg: rgba(249, 115, 22, 0.2);
179-
--badge-orange-text: #fdba74;
180-
--badge-error-bg: #551a1a;
181-
--badge-error-text: #fca5a5;
182-
--badge-gray-bg: #3a3a3a;
183-
--badge-gray-text: #a8a8a8;
184-
--text-icon-muted: #949494;
185-
--code-bg: #1f1f1f;
186-
--code-surface: var(--code-bg);
187-
--selection-bg: #264f78;
204+
:root:not([data-theme="light"]) {${DARK_TOKEN_DECLARATIONS}
188205
}
189206
}
190-
:root[data-theme="dark"] {
191-
--badge-success-bg: rgba(34, 197, 94, 0.2);
192-
--badge-success-text: #86efac;
193-
--badge-orange-bg: rgba(249, 115, 22, 0.2);
194-
--badge-orange-text: #fdba74;
195-
--badge-error-bg: #551a1a;
196-
--badge-error-text: #fca5a5;
197-
--badge-gray-bg: #3a3a3a;
198-
--badge-gray-text: #a8a8a8;
199-
--badge-blue-bg: rgba(59, 130, 246, 0.2);
200-
--badge-blue-text: #93c5fd;
201-
--badge-purple-bg: rgba(168, 85, 247, 0.2);
202-
--badge-purple-text: #d8b4fe;
203-
--bg: #1b1b1b;
204-
--surface-1: #1e1e1e;
205-
--surface-2: #232323;
206-
--surface-3: #242424;
207-
--surface-4: #2c2c2c;
208-
--surface-5: #363636;
209-
--surface-hover: #262626;
210-
--surface-active: #2c2c2c;
211-
--border: #444444;
212-
--text-primary: #e6e6e6;
213-
--text-secondary: #cccccc;
214-
--text-body: #c1c1c1;
215-
--text-muted: #6e6e6e;
216-
--text-icon: #969696;
217-
--warning: #ff6600;
218-
--text-icon-muted: #949494;
219-
--code-bg: #1f1f1f;
220-
--code-surface: var(--code-bg);
221-
--selection-bg: #264f78;
207+
:root[data-theme="dark"] {${DARK_TOKEN_DECLARATIONS}
222208
}
223209
224210
* { box-sizing: border-box; scrollbar-width: thin; }

0 commit comments

Comments
 (0)