Skip to content

Commit 2c8b863

Browse files
HerrTopiclaude
andcommitted
feat(ui-scripts,ui-themes): generate theme tokens as css custom properties
Add a buildCSSVariables step to build-themes that flattens each theme's shared tokens and emits one stylesheet per theme under src/themes/newThemeTokens/themesAsCSSVariables/, plus a cssThemesWithMediaQueries.css that maps the light and dark themes to prefers-color-scheme. Register the malva plugin in dprint.json so the generated stylesheets are formatted by the existing dprint pass, and mark *.css as side-effectful in ui-themes so bundlers do not drop the stylesheet imports. Also emit SharedTokens as a type-only import in the generated component templates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 699c6be commit 2c8b863

4 files changed

Lines changed: 129 additions & 5 deletions

File tree

dprint.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"**/node_modules",
88
"!packages/ui-themes/src/themes/newThemeTokens/"
99
],
10-
"plugins": ["https://plugins.dprint.dev/typescript-0.95.11.wasm"]
10+
"plugins": [
11+
"https://plugins.dprint.dev/typescript-0.95.11.wasm",
12+
"https://plugins.dprint.dev/g-plane/malva-v0.16.0.wasm"
13+
]
1114
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2015 - present Instructure, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import { promises as fs } from 'fs'
26+
import path from 'path'
27+
import { pathToFileURL } from 'url'
28+
29+
const importDefault = async (file: string) =>
30+
(await import(pathToFileURL(file).href)).default
31+
32+
const loadTheme = async (themePath: string) => {
33+
const [primitives, semantics, sharedTokens] = await Promise.all([
34+
importDefault(path.join(themePath, 'primitives.ts')),
35+
importDefault(path.join(themePath, 'semantics.ts')),
36+
importDefault(path.join(themePath, 'sharedTokens.ts'))
37+
])
38+
return sharedTokens(semantics(primitives))
39+
}
40+
41+
const flattenObj = (obj: Record<string, any>) => {
42+
const result: Record<string, any> = {}
43+
44+
for (const i in obj) {
45+
if (typeof obj[i] === 'object' && !Array.isArray(obj[i]) && !obj[i].type) {
46+
const temp = flattenObj(obj[i])
47+
for (const j in temp) {
48+
result[i + '-' + j] = temp[j]
49+
}
50+
} else {
51+
if (obj[i].type) {
52+
result[i] = `${obj[i].type === 'innerShadow' ? 'inset ' : ''}${
53+
obj[i].x
54+
} ${obj[i].y} ${obj[i].blur} ${obj[i].spread} ${obj[i].color}`
55+
} else {
56+
result[i] = obj[i]
57+
}
58+
}
59+
}
60+
return result
61+
}
62+
63+
const buildCSSVariables = async (targetPath: string) => {
64+
const root = path.resolve(targetPath)
65+
const entries = await fs.readdir(root, { withFileTypes: true })
66+
67+
const themeDirs = entries
68+
.filter((e) => e.isDirectory() && e.name !== 'componentTypes')
69+
.map((e) => e.name)
70+
71+
const sharedTokensByThemes: Record<string, any> = {}
72+
73+
for (const theme of themeDirs) {
74+
const resolved = await loadTheme(path.join(root, theme))
75+
76+
const flatResult = flattenObj(resolved)
77+
const cssVariables = Object.keys(flatResult).reduce((res, key) => {
78+
return `${res}--${key}:${flatResult[key]};`
79+
}, '')
80+
sharedTokensByThemes[theme] = cssVariables
81+
}
82+
83+
return sharedTokensByThemes
84+
}
85+
86+
export default buildCSSVariables

packages/ui-scripts/lib/build/buildThemes/setupThemes.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import generateComponent, {
3333
generateComponentType
3434
} from './generateComponents.ts'
3535
import { resolveBin, runCommandAsync } from '@instructure/command-utils'
36+
import buildCSSVariables from './buildCSSVariables.ts'
3637

3738
// transform to an object for easier handling
3839
export const transformThemes = (themes: any, input: any) =>
@@ -193,12 +194,12 @@ const setupThemes = async (targetPath: string, input: any): Promise<void> => {
193194
sharedTokensTypes = componentTypes
194195
const componentFileContent = `
195196
196-
import { SharedTokens } from '../commonTypes'
197+
import type { SharedTokens } from '../commonTypes'
197198
import type { Semantics } from "./semantics"
198199
199200
const ${fullComponentName} = (semantic: Semantics): ${capitalize(
200-
fullComponentName
201-
)} => ({${componentThemeVars}})
201+
fullComponentName
202+
)} => ({${componentThemeVars}})
202203
export default ${fullComponentName}
203204
`
204205

@@ -368,6 +369,38 @@ const setupThemes = async (targetPath: string, input: any): Promise<void> => {
368369
}
369370
`
370371
await createFile(`${targetPath}/index.ts`, exportIndexFileContent)
372+
373+
// generate themes as css variables
374+
const cssVarObject = await buildCSSVariables(targetPath)
375+
376+
const cssThemes = Object.keys(cssVarObject)
377+
for (let i = 0; i < cssThemes.length; i++) {
378+
const cssTheme = cssThemes[i]
379+
await createFile(
380+
`${targetPath}/themesAsCSSVariables/${cssTheme}.css`,
381+
`.${cssTheme}{
382+
${cssVarObject[cssTheme]}
383+
}`
384+
)
385+
}
386+
387+
const cssThemesWithMediaQueries = `
388+
:root {
389+
color-scheme: light;
390+
${cssVarObject['light']}
391+
}
392+
@media (prefers-color-scheme: dark) {
393+
:root {
394+
color-scheme: dark;
395+
${cssVarObject['dark']}
396+
}
397+
}
398+
`
399+
await createFile(
400+
`${targetPath}/themesAsCSSVariables/cssThemesWithMediaQueries.css`,
401+
`${cssThemesWithMediaQueries}`
402+
)
403+
371404
try {
372405
const dprintBin = resolveBin('dprint')
373406
const { stdout, stderr } = await runCommandAsync(dprintBin, [

packages/ui-themes/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
"publishConfig": {
3636
"access": "public"
3737
},
38-
"sideEffects": false,
38+
"sideEffects": [
39+
"*.css"
40+
],
3941
"exports": {
4042
"./lib/*": "./lib/*",
4143
"./es/*": "./es/*",

0 commit comments

Comments
 (0)