From d5e58f7742bbe8fa0bf641416440f8af79667ae5 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Tue, 21 Apr 2026 09:31:49 +1000 Subject: [PATCH 1/3] fix: unify the token key --- .../token-explorer/token-explorer.scss | 8 +- packages/react/src/calendar/demo/CardMode.tsx | 2 +- packages/react/src/card/style/_mixin.scss | 4 +- packages/react/src/card/style/index.scss | 4 +- packages/react/src/collapse/style/index.scss | 6 +- .../react/src/color-picker/demo/Oklch.tsx | 2 +- .../react/src/descriptions/demo/Basic.tsx | 2 +- .../react/src/descriptions/style/index.scss | 4 +- packages/react/src/image/style/index.scss | 4 +- packages/react/src/input/style/index.scss | 2 +- packages/react/src/marquee/demo/basic.tsx | 2 +- packages/react/src/marquee/demo/cards.tsx | 2 +- packages/react/src/marquee/demo/direction.tsx | 2 +- packages/react/src/marquee/demo/speed.tsx | 2 +- .../react/src/quick-actions/style/index.scss | 6 +- packages/react/src/style/_constants.scss | 10 +- packages/tokens/package.json | 1 + packages/tokens/scripts/build-runtime.js | 665 ++++++++++++++++-- .../tokens/source/schema/theme.schema.json | 2 +- packages/tokens/source/themes/dark.json | 27 +- 20 files changed, 649 insertions(+), 108 deletions(-) diff --git a/apps/docs/src/containers/token-explorer/token-explorer.scss b/apps/docs/src/containers/token-explorer/token-explorer.scss index b2aeda669..7a8629e88 100644 --- a/apps/docs/src/containers/token-explorer/token-explorer.scss +++ b/apps/docs/src/containers/token-explorer/token-explorer.scss @@ -62,7 +62,7 @@ padding: 12px 14px; border: 1px solid var(--ty-color-border-light); border-radius: 12px; - background: linear-gradient(180deg, var(--ty-color-bg-container) 0%, var(--ty-color-fill-quaternary) 100%); + background: linear-gradient(180deg, var(--ty-color-bg-container) 0%, var(--ty-color-fill-tertiary) 100%); } &__summary-item span { @@ -128,7 +128,7 @@ } &__token-key { - font-family: var(--ty-font-mono); + font-family: var(--ty-font-family-monospace); font-size: 14px; font-weight: 600; color: var(--ty-color-text); @@ -137,7 +137,7 @@ &__token-var { margin-top: 4px; - font-family: var(--ty-font-mono); + font-family: var(--ty-font-family-monospace); font-size: 12px; color: var(--ty-color-text-secondary); word-break: break-word; @@ -194,7 +194,7 @@ &__token-meta dd { margin: 0; - font-family: var(--ty-font-mono); + font-family: var(--ty-font-family-monospace); font-size: 12px; color: var(--ty-color-text); word-break: break-word; diff --git a/packages/react/src/calendar/demo/CardMode.tsx b/packages/react/src/calendar/demo/CardMode.tsx index 7d9a7c10d..0aad2a5dc 100644 --- a/packages/react/src/calendar/demo/CardMode.tsx +++ b/packages/react/src/calendar/demo/CardMode.tsx @@ -6,7 +6,7 @@ export default function CardModeDemo() { return (
- +

Use arrow keys to navigate, Enter to select, Escape to reset focus.

diff --git a/packages/react/src/card/style/_mixin.scss b/packages/react/src/card/style/_mixin.scss index 35196325a..153b1f93c 100644 --- a/packages/react/src/card/style/_mixin.scss +++ b/packages/react/src/card/style/_mixin.scss @@ -1,4 +1,4 @@ -@mixin card_elevation() { +@mixin card-elevation() { box-shadow: var(--ty-shadow-card); - border-color: var(--ty-card-shadow-border); + border-color: var(--ty-card-shadow-border, var(--ty-card-border, var(--ty-color-border-secondary))); } diff --git a/packages/react/src/card/style/index.scss b/packages/react/src/card/style/index.scss index 7e7131eec..824a6a5e9 100644 --- a/packages/react/src/card/style/index.scss +++ b/packages/react/src/card/style/index.scss @@ -29,12 +29,12 @@ cursor: pointer; &:hover { - @include card_elevation; + @include card-elevation; } } &_active { - @include card_elevation; + @include card-elevation; } &__header { diff --git a/packages/react/src/collapse/style/index.scss b/packages/react/src/collapse/style/index.scss index 910b21a69..973c60200 100644 --- a/packages/react/src/collapse/style/index.scss +++ b/packages/react/src/collapse/style/index.scss @@ -30,9 +30,9 @@ --ty-collapse-body-padding-block: 16px; --ty-collapse-font-size: 14px; --ty-collapse-line-height: 1.5; - --ty-collapse-focus-ring: var(--ty-control-outline, var(--ty-color-primary)); - --ty-collapse-motion-duration: var(--ty-motion-duration-mid, 240ms); - --ty-collapse-motion-easing: var(--ty-motion-ease-standard, ease); + --ty-collapse-focus-ring: var(--ty-color-primary-border); + --ty-collapse-motion-duration: 240ms; + --ty-collapse-motion-easing: ease; box-sizing: border-box; border: 1px solid var(--ty-collapse-border-color); diff --git a/packages/react/src/color-picker/demo/Oklch.tsx b/packages/react/src/color-picker/demo/Oklch.tsx index 613d8d0b0..be21d55d2 100644 --- a/packages/react/src/color-picker/demo/Oklch.tsx +++ b/packages/react/src/color-picker/demo/Oklch.tsx @@ -21,7 +21,7 @@ export default function OklchDemo() { /> Value: {color}
-
+      
         {JSON.stringify(meta, null, 2)}
       
diff --git a/packages/react/src/descriptions/demo/Basic.tsx b/packages/react/src/descriptions/demo/Basic.tsx index 85f80a8a0..60ef8519d 100644 --- a/packages/react/src/descriptions/demo/Basic.tsx +++ b/packages/react/src/descriptions/demo/Basic.tsx @@ -7,7 +7,7 @@ export default function BasicDemo() { title="Workspace Profile" extra={} columns={2} - footer={Last synced 2 minutes ago}> + footer={Last synced 2 minutes ago}> Tiny Studio Australia Southeast Core}> diff --git a/packages/react/src/descriptions/style/index.scss b/packages/react/src/descriptions/style/index.scss index a472fb882..af4e94c50 100644 --- a/packages/react/src/descriptions/style/index.scss +++ b/packages/react/src/descriptions/style/index.scss @@ -48,7 +48,7 @@ &__footer { padding-top: 4px; - border-top: 1px solid color-mix(in srgb, var(--ty-color-text-4) 18%, transparent); + border-top: 1px solid color-mix(in srgb, var(--ty-color-text-quaternary) 18%, transparent); } &__list-item { @@ -58,7 +58,7 @@ gap: 8px; margin: 0; padding-bottom: 12px; - border-bottom: 1px solid color-mix(in srgb, var(--ty-color-text-4) 16%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--ty-color-text-quaternary) 16%, transparent); } &__label-inner { diff --git a/packages/react/src/image/style/index.scss b/packages/react/src/image/style/index.scss index 14c23da3f..75bfc081f 100644 --- a/packages/react/src/image/style/index.scss +++ b/packages/react/src/image/style/index.scss @@ -37,8 +37,8 @@ &__placeholder, &__fallback { align-items: center; - background: var(--ty-image-placeholder-bg, var(--ty-color-fill-1, #f5f5f5)); - color: var(--ty-image-placeholder-color, var(--ty-color-text-3, #999)); + background: var(--ty-image-placeholder-bg, var(--ty-color-fill-secondary, #f5f5f5)); + color: var(--ty-image-placeholder-color, var(--ty-color-text-tertiary, #999)); display: inline-flex; justify-content: center; } diff --git a/packages/react/src/input/style/index.scss b/packages/react/src/input/style/index.scss index 476838b40..0457516ad 100755 --- a/packages/react/src/input/style/index.scss +++ b/packages/react/src/input/style/index.scss @@ -8,7 +8,7 @@ color: var(--ty-input-color, var(--ty-color-text)); height: var(--ty-input-height-current, var(--ty-input-height-md, var(--ty-height-md))); padding-inline: var(--ty-input-padding-inline-current, var(--ty-input-padding-inline-md, #{$input-md-padding})); - gap: var(--ty-input-affix-gap, var(--ty-spacing-2)); + gap: var(--ty-input-affix-gap, var(--ty-spacing-3)); border: 1px solid var(--ty-input-border); border-radius: var(--ty-input-radius, var(--ty-border-radius)); background-color: var(--ty-input-bg, var(--ty-color-bg-container)); diff --git a/packages/react/src/marquee/demo/basic.tsx b/packages/react/src/marquee/demo/basic.tsx index f7cc16311..79c1ddc7c 100644 --- a/packages/react/src/marquee/demo/basic.tsx +++ b/packages/react/src/marquee/demo/basic.tsx @@ -5,7 +5,7 @@ const itemStyle: React.CSSProperties = { flexShrink: 0, padding: '12px 24px', borderRadius: 8, - background: 'var(--ty-color-bg-component)', + background: 'var(--ty-color-bg-container)', border: '1px solid var(--ty-color-border-secondary)', whiteSpace: 'nowrap', }; diff --git a/packages/react/src/marquee/demo/cards.tsx b/packages/react/src/marquee/demo/cards.tsx index 6445b02dd..0d851edd1 100644 --- a/packages/react/src/marquee/demo/cards.tsx +++ b/packages/react/src/marquee/demo/cards.tsx @@ -6,7 +6,7 @@ const cardStyle: React.CSSProperties = { width: 200, padding: 16, borderRadius: 12, - background: 'var(--ty-color-bg-component)', + background: 'var(--ty-color-bg-container)', border: '1px solid var(--ty-color-border-secondary)', }; diff --git a/packages/react/src/marquee/demo/direction.tsx b/packages/react/src/marquee/demo/direction.tsx index f458892dc..2150c807a 100644 --- a/packages/react/src/marquee/demo/direction.tsx +++ b/packages/react/src/marquee/demo/direction.tsx @@ -5,7 +5,7 @@ const itemStyle: React.CSSProperties = { flexShrink: 0, padding: '12px 24px', borderRadius: 8, - background: 'var(--ty-color-bg-component)', + background: 'var(--ty-color-bg-container)', border: '1px solid var(--ty-color-border-secondary)', whiteSpace: 'nowrap', }; diff --git a/packages/react/src/marquee/demo/speed.tsx b/packages/react/src/marquee/demo/speed.tsx index 2ec53bafc..1b8501ca3 100644 --- a/packages/react/src/marquee/demo/speed.tsx +++ b/packages/react/src/marquee/demo/speed.tsx @@ -5,7 +5,7 @@ const itemStyle: React.CSSProperties = { flexShrink: 0, padding: '12px 24px', borderRadius: 8, - background: 'var(--ty-color-bg-component)', + background: 'var(--ty-color-bg-container)', border: '1px solid var(--ty-color-border-secondary)', whiteSpace: 'nowrap', }; diff --git a/packages/react/src/quick-actions/style/index.scss b/packages/react/src/quick-actions/style/index.scss index a58f02aad..0bb0518e9 100644 --- a/packages/react/src/quick-actions/style/index.scss +++ b/packages/react/src/quick-actions/style/index.scss @@ -23,7 +23,7 @@ var(--ty-quick-actions-bg); color: var(--ty-quick-actions-color); font-size: var(--ty-quick-actions-fab-font-size); - box-shadow: var(--ty-shadow); + box-shadow: var(--ty-shadow-card); cursor: pointer; transition: transform var(--ty-quick-actions-button-transition-duration) ease, @@ -37,7 +37,7 @@ } &:focus-visible { - box-shadow: var(--ty-quick-actions-focus-ring), var(--ty-shadow); + box-shadow: var(--ty-quick-actions-focus-ring), var(--ty-shadow-card); } &_open { @@ -192,7 +192,7 @@ --ty-quick-actions-action-bg-hover, color-mix(in srgb, var(--ty-color-primary) 8%, var(--ty-color-bg-container)) ); - box-shadow: var(--ty-shadow); + box-shadow: var(--ty-shadow-card); transform: translateY(-1px); } diff --git a/packages/react/src/style/_constants.scss b/packages/react/src/style/_constants.scss index 762e349f5..0ee2e7ffd 100644 --- a/packages/react/src/style/_constants.scss +++ b/packages/react/src/style/_constants.scss @@ -38,7 +38,7 @@ $input-md-padding: var(--ty-input-padding-inline-md, var(--ty-control-padding-in $input-lg-padding: var(--ty-input-padding-inline-lg, var(--ty-control-padding-inline-lg)) !default; // Menu -$menu-item-padding-vertical: var(--ty-menu-item-padding-vertical) !default; +$menu-item-padding-vertical: var(--ty-menu-item-padding-vertical, var(--ty-menu-item-padding-block)) !default; // Native Select $native-select-sm-padding: var(--ty-native-select-sm-padding) !default; @@ -53,7 +53,7 @@ $notification-margin: var(--ty-notification-margin) !default; $popover-arrow-size: var(--ty-popover-arrow-size) !default; // Select -$select-selected-font-weight: var(--ty-select-selected-font-weight) !default; +$select-selected-font-weight: var(--ty-select-selected-font-weight, 500) !default; $select-dropdown-max-height: var(--ty-select-dropdown-max-height) !default; $select-dropdown-shadow: var(--ty-select-dropdown-shadow) !default; @@ -68,9 +68,9 @@ $steps-title-font-size: var(--ty-steps-title-font-size) !default; $strength-indicator-border-radius: var(--ty-strength-indicator-border-radius) !default; // Switch -$switch-md-font-size: var(--ty-switch-md-font-size) !default; -$switch-sm-font-size: var(--ty-switch-sm-font-size) !default; -$switch-lg-font-size: var(--ty-switch-lg-font-size) !default; +$switch-md-font-size: var(--ty-switch-md-font-size, var(--ty-switch-font-size-md)) !default; +$switch-sm-font-size: var(--ty-switch-sm-font-size, var(--ty-switch-font-size-sm)) !default; +$switch-lg-font-size: var(--ty-switch-lg-font-size, var(--ty-switch-font-size-lg)) !default; // Textarea $textarea-padding: var(--ty-textarea-padding) !default; diff --git a/packages/tokens/package.json b/packages/tokens/package.json index 77208ba42..7861922f4 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -50,6 +50,7 @@ "build": "node scripts/build.js", "build:base-css": "node -e \"require('./scripts/build').buildBaseCss()\"", "build:runtime": "node scripts/build-runtime.js", + "test": "node scripts/build-runtime.js --validate-only", "clean": "rimraf css dist" }, "devDependencies": { diff --git a/packages/tokens/scripts/build-runtime.js b/packages/tokens/scripts/build-runtime.js index 4788c09d5..0c954aff2 100644 --- a/packages/tokens/scripts/build-runtime.js +++ b/packages/tokens/scripts/build-runtime.js @@ -13,6 +13,25 @@ const THEME_SCHEMA_PATH = path.join(SOURCE_DIR, 'schema', 'theme.schema.json'); const SEMANTIC_DIR = path.join(SOURCE_DIR, 'semantic'); const COMPONENT_DIR = path.join(SOURCE_DIR, 'components'); const THEMES_DIR = path.join(SOURCE_DIR, 'themes'); +const REACT_SRC_DIR = path.join(ROOT, '..', 'react', 'src'); +const DOCS_SRC_DIR = path.join(ROOT, '..', '..', 'apps', 'docs', 'src'); + +const TOKEN_TYPE_VALUES = new Set([ + 'color', + 'dimension', + 'number', + 'font-family', + 'font-weight', + 'line-height', + 'shadow', + 'duration', + 'easing', + 'transition', + 'string', +]); +const TOKEN_STATUS_VALUES = new Set(['active', 'deprecated', 'internal']); +const CSS_VAR_PATTERN = /^--ty-[a-z0-9]+(?:-[a-z0-9]+)*$/; +const CONSUMER_FILE_EXTENSIONS = new Set(['.css', '.scss', '.ts', '.tsx']); function mkdirp(dir) { fs.mkdirSync(dir, { recursive: true }); @@ -35,6 +54,44 @@ function listJsonFiles(dir) { .map((name) => path.join(dir, name)); } +function listSourceFiles(dir) { + if (!fs.existsSync(dir)) return []; + + const files = []; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listSourceFiles(fullPath)); + continue; + } + + if (CONSUMER_FILE_EXTENSIONS.has(path.extname(entry.name))) { + files.push(fullPath); + } + } + + return files.sort(); +} + +function listDirectories(dir) { + if (!fs.existsSync(dir)) return []; + + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +function basenameWithoutExt(filePath) { + return path.basename(filePath, path.extname(filePath)); +} + +function isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function tokenKeyToCssVar(key) { return `--ty-${key.replace(/\./g, '-')}`; } @@ -43,10 +100,20 @@ function getComponentName(key) { return key.includes('.') ? key.split('.')[0] : null; } -function assert(condition, message) { - if (!condition) { - throw new Error(message); +function formatValidationError(title, errors) { + return `${title}\n${errors.map((error) => `- ${error}`).join('\n')}`; +} + +function lineNumberForIndex(sourceText, index) { + let line = 1; + + for (let i = 0; i < index; i += 1) { + if (sourceText.charCodeAt(i) === 10) { + line += 1; + } } + + return line; } function resolveTokenValue(rawValue, tokenMap, stack = []) { @@ -61,12 +128,12 @@ function resolveTokenValue(rawValue, tokenMap, stack = []) { const refKey = match[1]; if (stack.includes(refKey)) { - throw new Error(`Circular token reference detected: ${[...stack, refKey].join(' -> ')}`); + throw new Error(`has a circular reference chain: ${[...stack, refKey].join(' -> ')}`); } const refToken = tokenMap.get(refKey); if (!refToken) { - throw new Error(`Unresolved token reference: ${refKey}`); + throw new Error(`references unknown token "${refKey}"`); } return resolveTokenValue(refToken.$value, tokenMap, [...stack, refKey]); @@ -84,41 +151,62 @@ function toCssValue(rawValue, tokenMap) { const refKey = match[1]; if (!tokenMap.has(refKey)) { - throw new Error(`Unresolved token reference: ${refKey}`); + throw new Error(`references unknown token "${refKey}"`); } return `var(${tokenKeyToCssVar(refKey)})`; } +function loadThemeSchemaConfig() { + const schema = readJson(THEME_SCHEMA_PATH); + + return { + id: schema.$id, + requiredTopLevelKeys: new Set(schema.required || []), + topLevelKeys: new Set(Object.keys(schema.properties || {})), + modeValues: new Set((((schema.properties || {}).mode || {}).enum) || []), + extendsPattern: new RegExp(((schema.properties || {}).extends || {}).pattern), + requiredMetaKeys: new Set((((schema.$defs || {}).meta || {}).required) || []), + metaKeys: new Set(Object.keys((((schema.$defs || {}).meta || {}).properties) || {})), + metaIdPattern: new RegExp((((((schema.$defs || {}).meta || {}).properties || {}).id) || {}).pattern), + metaVersionPattern: new RegExp( + (((((schema.$defs || {}).meta || {}).properties || {}).version) || {}).pattern + ), + tagPattern: new RegExp( + (((((((schema.$defs || {}).meta || {}).properties || {}).tags) || {}).items) || {}).pattern + ), + tokenSectionKeys: new Set( + Object.keys(((((schema.$defs || {}).tokenSections || {}).properties) || {})) + ), + tokenKeyPattern: new RegExp((((schema.$defs || {}).tokenKey || {}).pattern)), + }; +} + function loadTokenFiles(dir, category) { return listJsonFiles(dir).flatMap((filePath) => { const fileData = readJson(filePath); - return Object.entries(fileData).map(([key, value]) => ({ - key, - ...value, - category, - source: path.relative(ROOT, filePath), - component: category === 'component' ? getComponentName(key) : undefined, - })); - }); -} + const source = path.relative(ROOT, filePath); + const sourceFileBase = basenameWithoutExt(filePath); -function validateTokens(tokens) { - const keys = new Set(); - const cssVars = new Set(); - - for (const token of tokens) { - assert(!keys.has(token.key), `Duplicate token key: ${token.key}`); - keys.add(token.key); - - const cssVar = tokenKeyToCssVar(token.key); - assert(!cssVars.has(cssVar), `Duplicate css var: ${cssVar}`); - cssVars.add(cssVar); - - if (token.category === 'component') { - assert(token.component, `Missing component name for token: ${token.key}`); + if (!isObject(fileData)) { + throw new Error(`${source} must export an object of token definitions.`); } - } + + return Object.entries(fileData).map(([key, value]) => { + if (!isObject(value)) { + throw new Error(`${source}: token "${key}" must be an object.`); + } + + return { + key, + ...value, + category, + source, + sourceFileBase, + component: category === 'component' ? getComponentName(key) : undefined, + }; + }); + }); } function loadThemes() { @@ -131,6 +219,88 @@ function loadThemes() { }); } +function validateTokens(tokens, themeSchemaConfig) { + const errors = []; + const keys = new Map(); + const cssVars = new Map(); + const cssVarSet = new Set(tokens.map((token) => tokenKeyToCssVar(token.key))); + const tokenMap = new Map(tokens.map((token) => [token.key, token])); + + for (const token of tokens) { + const location = `${token.source}: token "${token.key}"`; + const cssVar = tokenKeyToCssVar(token.key); + + if (!themeSchemaConfig.tokenKeyPattern.test(token.key)) { + errors.push(`${location} does not match the theme token key pattern.`); + } + + if (!('description' in token) || typeof token.description !== 'string' || token.description.trim() === '') { + errors.push(`${location} must include a non-empty description.`); + } + + if (!('$type' in token) || typeof token.$type !== 'string' || !TOKEN_TYPE_VALUES.has(token.$type)) { + errors.push(`${location} has unsupported $type "${token.$type}".`); + } + + if (!('$value' in token) || (typeof token.$value !== 'string' && typeof token.$value !== 'number')) { + errors.push(`${location} must define $value as a string or number.`); + } + + if ( + token.status !== undefined && + (typeof token.status !== 'string' || !TOKEN_STATUS_VALUES.has(token.status)) + ) { + errors.push(`${location} has unsupported status "${token.status}".`); + } + + if (keys.has(token.key)) { + errors.push(`${location} duplicates key already declared in ${keys.get(token.key)}.`); + } else { + keys.set(token.key, token.source); + } + + if (!CSS_VAR_PATTERN.test(cssVar)) { + errors.push(`${location} resolves to invalid css var "${cssVar}".`); + } + + if (cssVars.has(cssVar)) { + errors.push(`${location} resolves to duplicate css var already declared in ${cssVars.get(cssVar)}.`); + } else { + cssVars.set(cssVar, token.source); + } + + if (token.category === 'component') { + if (!token.component) { + errors.push(`${location} is missing a component prefix.`); + } else if (token.component !== token.sourceFileBase) { + errors.push( + `${location} uses component prefix "${token.component}" but lives in ${token.sourceFileBase}.json.` + ); + } + } + + if (token.fallback !== undefined) { + if (typeof token.fallback !== 'string' || !CSS_VAR_PATTERN.test(token.fallback)) { + errors.push(`${location} has invalid fallback "${token.fallback}".`); + } else if (!cssVarSet.has(token.fallback)) { + errors.push(`${location} points to missing fallback "${token.fallback}".`); + } + } + } + + for (const token of tokens) { + const location = `${token.source}: token "${token.key}"`; + + try { + resolveTokenValue(token.$value, tokenMap, [token.key]); + } catch (error) { + errors.push(`${location} ${error.message}.`); + } + } + + return { errors, tokenMap }; +} + function buildPresetMap(themes) { return themes.reduce((acc, theme) => { if (theme.meta && theme.meta.id) { @@ -162,17 +332,255 @@ function buildRegistry(tokens) { }; } -function buildCss(tokens, cssValues) { - const rootLines = [':root {']; +function validateThemeSchemaDocument(theme, themeSchemaConfig) { + const errors = []; + const source = theme.source || 'unknown theme source'; - for (const token of tokens) { - rootLines.push(` ${tokenKeyToCssVar(token.key)}: ${cssValues.get(token.key)};`); + if (!isObject(theme)) { + return [`${source}: theme document must be an object.`]; + } + + for (const key of Object.keys(theme)) { + if (!themeSchemaConfig.topLevelKeys.has(key) && key !== 'source') { + errors.push(`${source}: field "${key}" is not allowed by the theme schema.`); + } + } + + for (const requiredKey of themeSchemaConfig.requiredTopLevelKeys) { + if (!Object.prototype.hasOwnProperty.call(theme, requiredKey)) { + errors.push(`${source}: missing required field "${requiredKey}".`); + } + } + + if (theme.$schema !== undefined && typeof theme.$schema !== 'string') { + errors.push(`${source}: $schema must be a string.`); + } + + if (!isObject(theme.meta)) { + errors.push(`${source}: meta must be an object.`); + } else { + for (const key of Object.keys(theme.meta)) { + if (!themeSchemaConfig.metaKeys.has(key)) { + errors.push(`${source}: meta.${key} is not allowed by the theme schema.`); + } + } + + for (const requiredKey of themeSchemaConfig.requiredMetaKeys) { + if (!Object.prototype.hasOwnProperty.call(theme.meta, requiredKey)) { + errors.push(`${source}: meta.${requiredKey} is required.`); + } + } + + if ( + theme.meta.id !== undefined && + (typeof theme.meta.id !== 'string' || !themeSchemaConfig.metaIdPattern.test(theme.meta.id)) + ) { + errors.push(`${source}: meta.id "${theme.meta.id}" does not match the schema pattern.`); + } + + if (theme.meta.name !== undefined) { + if (typeof theme.meta.name !== 'string' || theme.meta.name.length < 1 || theme.meta.name.length > 120) { + errors.push(`${source}: meta.name must be a non-empty string up to 120 characters.`); + } + } + + if (theme.meta.author !== undefined) { + if ( + typeof theme.meta.author !== 'string' || + theme.meta.author.length < 1 || + theme.meta.author.length > 120 + ) { + errors.push(`${source}: meta.author must be a non-empty string up to 120 characters.`); + } + } + + if (theme.meta.description !== undefined) { + if (typeof theme.meta.description !== 'string' || theme.meta.description.length > 500) { + errors.push(`${source}: meta.description must be a string up to 500 characters.`); + } + } + + if ( + theme.meta.version !== undefined && + (typeof theme.meta.version !== 'string' || + !themeSchemaConfig.metaVersionPattern.test(theme.meta.version)) + ) { + errors.push(`${source}: meta.version "${theme.meta.version}" is not valid semver.`); + } + + if (theme.meta.schemaVersion !== undefined && theme.meta.schemaVersion !== 1) { + errors.push(`${source}: meta.schemaVersion must equal 1.`); + } + + if (theme.meta.tags !== undefined) { + if (!Array.isArray(theme.meta.tags)) { + errors.push(`${source}: meta.tags must be an array.`); + } else { + const tagSet = new Set(); + + if (theme.meta.tags.length > 20) { + errors.push(`${source}: meta.tags cannot contain more than 20 items.`); + } + + for (const tag of theme.meta.tags) { + if (typeof tag !== 'string' || !themeSchemaConfig.tagPattern.test(tag)) { + errors.push(`${source}: meta.tags contains invalid tag "${tag}".`); + continue; + } + + if (tagSet.has(tag)) { + errors.push(`${source}: meta.tags contains duplicate tag "${tag}".`); + continue; + } + + tagSet.add(tag); + } + } + } + } + + if (typeof theme.mode !== 'string' || !themeSchemaConfig.modeValues.has(theme.mode)) { + errors.push(`${source}: mode must be one of ${Array.from(themeSchemaConfig.modeValues).join(', ')}.`); + } + + if ( + theme.extends !== undefined && + (typeof theme.extends !== 'string' || !themeSchemaConfig.extendsPattern.test(theme.extends)) + ) { + errors.push(`${source}: extends "${theme.extends}" does not match the schema pattern.`); + } + + if (!isObject(theme.tokens)) { + errors.push(`${source}: tokens must be an object.`); + } else { + for (const key of Object.keys(theme.tokens)) { + if (!themeSchemaConfig.tokenSectionKeys.has(key)) { + errors.push(`${source}: tokens.${key} is not allowed by the theme schema.`); + } + } + + for (const sectionName of themeSchemaConfig.tokenSectionKeys) { + const section = theme.tokens[sectionName]; + + if (section === undefined) continue; + + if (!isObject(section)) { + errors.push(`${source}: tokens.${sectionName} must be an object.`); + continue; + } + + for (const [key, value] of Object.entries(section)) { + if (!themeSchemaConfig.tokenKeyPattern.test(key)) { + errors.push(`${source}: tokens.${sectionName}.${key} does not match the schema token key pattern.`); + } + + if (typeof value === 'string') { + if (value.length === 0) { + errors.push(`${source}: tokens.${sectionName}.${key} cannot be an empty string.`); + } + continue; + } + + if (typeof value !== 'number') { + errors.push(`${source}: tokens.${sectionName}.${key} must be a string or number.`); + } + } + } + } + + return errors; +} + +function validateThemeRegistryUsage(themes, tokens, themeSchemaConfig) { + const errors = []; + const warnings = []; + const tokenMap = new Map(tokens.map((token) => [token.key, token])); + const themeIdSources = new Map(); + const presetMap = buildPresetMap(themes); + + for (const theme of themes) { + const source = theme.source || 'unknown theme source'; + errors.push(...validateThemeSchemaDocument(theme, themeSchemaConfig)); + + const themeId = theme.meta && theme.meta.id; + if (themeId) { + if (themeIdSources.has(themeId)) { + errors.push( + `${source}: meta.id "${themeId}" duplicates theme id already declared in ${themeIdSources.get(themeId)}.` + ); + } else { + themeIdSources.set(themeId, source); + } + } + } + + function validatePresetChain(theme, visited = new Set()) { + const source = theme.source || 'unknown theme source'; + + if (!theme.extends) return; + + const preset = presetMap[theme.extends]; + if (!preset) { + errors.push(`${source}: extends unknown preset "${theme.extends}".`); + return; + } + + if (visited.has(theme.extends)) { + errors.push(`${source}: extends chain for "${theme.extends}" is circular.`); + return; + } + + visited.add(theme.extends); + validatePresetChain(preset, visited); + visited.delete(theme.extends); } - rootLines.push('}'); - rootLines.push(''); + function validateThemeSection(theme, sectionName, expectedCategory) { + const source = theme.source || 'unknown theme source'; + const section = theme.tokens && theme.tokens[sectionName]; + + if (!isObject(section)) return; + + for (const [key, value] of Object.entries(section)) { + const registryToken = tokenMap.get(key); + + if (!registryToken) { + errors.push(`${source}: tokens.${sectionName}.${key} is not present in the token registry.`); + continue; + } + + if (registryToken.category !== expectedCategory) { + errors.push( + `${source}: tokens.${sectionName}.${key} belongs to category "${registryToken.category}", not "${expectedCategory}".` + ); + } + + if (registryToken.status === 'internal') { + errors.push(`${source}: tokens.${sectionName}.${key} is internal and cannot be authored in themes.`); + } + + if (registryToken.status === 'deprecated') { + warnings.push(`${source}: tokens.${sectionName}.${key} is deprecated.`); + } + + if (typeof value !== 'string') continue; + + const referenceMatch = value.match(/^\{([^}]+)\}$/); + if (!referenceMatch) continue; - return rootLines.join('\n'); + if (!tokenMap.has(referenceMatch[1])) { + errors.push(`${source}: tokens.${sectionName}.${key} references unknown token "${referenceMatch[1]}".`); + } + } + } + + for (const theme of themes) { + validatePresetChain(theme); + validateThemeSection(theme, 'semantic', 'semantic'); + validateThemeSection(theme, 'components', 'component'); + } + + return { errors, warnings }; } function buildThemeCss(tokens, cssValues, tokenMap, overrides, selector) { @@ -282,28 +690,155 @@ export default presets; `; } -function buildRuntimeTokens() { - console.log('Building runtime tokens...\n'); +function collectLocalCssVars(files) { + const definedVars = new Set(); + + for (const filePath of files) { + const sourceText = fs.readFileSync(filePath, 'utf8'); + const definitionRegex = /--ty-[a-z0-9-]+(?=['"]?\s*:)/g; + let match; + + while ((match = definitionRegex.exec(sourceText)) !== null) { + definedVars.add(match[0]); + } + } + + return definedVars; +} + +function extractTyVarReferences(sourceText) { + const references = []; + const referenceRegex = /var\(\s*(--ty-[a-z0-9-]+)\s*(,|\))/g; + let match; + + while ((match = referenceRegex.exec(sourceText)) !== null) { + references.push({ + name: match[1], + hasFallback: match[2] === ',', + index: match.index, + }); + } + + return references; +} + +function collectOverridePrefixes(tokens) { + const prefixes = new Set( + tokens.filter((token) => token.category === 'component' && token.component).map((token) => token.component) + ); + + for (const directoryName of listDirectories(REACT_SRC_DIR)) { + prefixes.add(directoryName); + } + + return prefixes; +} + +function isComponentOverrideHook(cssVar, hasFallback, overridePrefixes) { + if (!hasFallback) return false; + + const body = cssVar.replace(/^--ty-/, ''); + const parts = body.split('-'); + + for (let i = parts.length - 1; i >= 1; i -= 1) { + const prefix = parts.slice(0, i).join('-'); + if (overridePrefixes.has(prefix)) { + return true; + } + } + + return false; +} + +function validateConsumerContracts(tokens) { + const registryVars = new Set(tokens.map((token) => tokenKeyToCssVar(token.key))); + const consumerFiles = [...listSourceFiles(REACT_SRC_DIR), ...listSourceFiles(DOCS_SRC_DIR)]; + const definedVars = collectLocalCssVars(consumerFiles); + const overridePrefixes = collectOverridePrefixes(tokens); + const usedRegistryVars = new Set(); + const errors = []; + + for (const filePath of consumerFiles) { + const sourceText = fs.readFileSync(filePath, 'utf8'); + const relativePath = path.relative(ROOT, filePath); + + for (const reference of extractTyVarReferences(sourceText)) { + if (registryVars.has(reference.name)) { + usedRegistryVars.add(reference.name); + continue; + } + + if (definedVars.has(reference.name)) { + continue; + } + + if (isComponentOverrideHook(reference.name, reference.hasFallback, overridePrefixes)) { + continue; + } + + errors.push( + `${relativePath}:${lineNumberForIndex(sourceText, reference.index)} references unknown token var "${reference.name}".` + ); + } + } + + if (errors.length > 0) { + throw new Error(formatValidationError('Consumer token contract validation failed.', errors)); + } + + return { + filesChecked: consumerFiles.length, + usedRegistryVars: usedRegistryVars.size, + totalRegistryVars: registryVars.size, + }; +} + +function buildRuntimeTokens(options = {}) { + const shouldWriteArtifacts = options.write !== false; + const shouldValidateConsumers = options.verifyConsumers !== false; + const themeSchemaConfig = loadThemeSchemaConfig(); + const actionLabel = shouldWriteArtifacts ? 'Building runtime tokens...' : 'Validating runtime tokens...'; + + console.log(`${actionLabel}\n`); const semanticTokens = loadTokenFiles(SEMANTIC_DIR, 'semantic'); const componentTokens = loadTokenFiles(COMPONENT_DIR, 'component'); const allTokens = [...semanticTokens, ...componentTokens]; - const themes = loadThemes(); + const tokenValidation = validateTokens(allTokens, themeSchemaConfig); - validateTokens(allTokens); + if (tokenValidation.errors.length > 0) { + throw new Error(formatValidationError('Token validation failed.', tokenValidation.errors)); + } - const tokenMap = new Map(allTokens.map((token) => [token.key, token])); - const resolvedValues = new Map( - allTokens.map((token) => [token.key, resolveTokenValue(token.$value, tokenMap)]) - ); - const cssValues = new Map( - allTokens.map((token) => [token.key, toCssValue(token.$value, tokenMap)]) - ); + const themes = loadThemes(); + const themeValidation = validateThemeRegistryUsage(themes, allTokens, themeSchemaConfig); + if (themeValidation.errors.length > 0) { + throw new Error(formatValidationError('Theme validation failed.', themeValidation.errors)); + } + + if (themeValidation.warnings.length > 0) { + console.warn(formatValidationError('Theme validation warnings.', themeValidation.warnings)); + } + const tokenMap = tokenValidation.tokenMap; + const cssValues = new Map(allTokens.map((token) => [token.key, toCssValue(token.$value, tokenMap)])); const registry = buildRegistry(allTokens); const lightTheme = themes.find((theme) => theme.mode === 'light'); const darkTheme = themes.find((theme) => theme.mode === 'dark'); const presets = buildPresetMap(themes); + + if (shouldValidateConsumers) { + const consumerSummary = validateConsumerContracts(allTokens); + console.log( + ` consumer contract check (${consumerSummary.filesChecked} files, ${consumerSummary.usedRegistryVars}/${consumerSummary.totalRegistryVars} registry vars referenced)` + ); + } + + if (!shouldWriteArtifacts) { + console.log('\nRuntime tokens validated.'); + return { registry, presets }; + } + const lightThemeOverrides = { ...((lightTheme && lightTheme.tokens && lightTheme.tokens.semantic) || {}), ...((lightTheme && lightTheme.tokens && lightTheme.tokens.components) || {}), @@ -312,20 +847,8 @@ function buildRuntimeTokens() { ...((darkTheme && darkTheme.tokens && darkTheme.tokens.semantic) || {}), ...((darkTheme && darkTheme.tokens && darkTheme.tokens.components) || {}), }; - const lightCss = buildThemeCss( - allTokens, - cssValues, - tokenMap, - lightThemeOverrides, - ':root' - ); - const darkCss = buildThemeCss( - allTokens, - cssValues, - tokenMap, - darkThemeOverrides, - "[data-tiny-theme='dark']" - ); + const lightCss = buildThemeCss(allTokens, cssValues, tokenMap, lightThemeOverrides, ':root'); + const darkCss = buildThemeCss(allTokens, cssValues, tokenMap, darkThemeOverrides, "[data-tiny-theme='dark']"); const baseCss = buildBaseThemeCss(allTokens, cssValues, tokenMap, lightTheme, darkTheme); mkdirp(DIST_CSS_DIR); @@ -348,10 +871,26 @@ function buildRuntimeTokens() { console.log(' dist/css/dark.css'); console.log(' dist/css/base.css'); console.log('\nRuntime tokens done.'); + + return { registry, presets }; +} + +function parseCliArgs(argv) { + return { + write: !argv.includes('--validate-only'), + verifyConsumers: !argv.includes('--no-consumer-check'), + }; } module.exports = { buildRuntimeTokens }; if (require.main === module) { - buildRuntimeTokens(); + const cliOptions = parseCliArgs(process.argv.slice(2)); + + try { + buildRuntimeTokens(cliOptions); + } catch (err) { + console.error(err); + process.exit(1); + } } diff --git a/packages/tokens/source/schema/theme.schema.json b/packages/tokens/source/schema/theme.schema.json index 8c87b4eeb..901501389 100644 --- a/packages/tokens/source/schema/theme.schema.json +++ b/packages/tokens/source/schema/theme.schema.json @@ -92,7 +92,7 @@ }, "tokenKey": { "type": "string", - "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*){0,2}$" + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*)*$" }, "tokenValue": { "oneOf": [ diff --git a/packages/tokens/source/themes/dark.json b/packages/tokens/source/themes/dark.json index df42adab8..059a013d3 100644 --- a/packages/tokens/source/themes/dark.json +++ b/packages/tokens/source/themes/dark.json @@ -99,7 +99,7 @@ "cascader.selected-bg": "rgba(144, 101, 208, 0.1)", "checkbox.bg": "#1f1f1f", "checkbox.border": "#424242", - "checkbox.disabled-bg": "#2a2a2a", + "checkbox.bg.disabled": "#2a2a2a", "collapse.bg": "#262626", "collapse.border-color": "#424242", "collapse.borderless-divider-color": "#363636", @@ -157,13 +157,14 @@ "notification.bg": "#1f1f1f", "notification.close-color": "rgba(255, 255, 255, 0.2)", "notification.close-hover": "rgba(255, 255, 255, 0.7)", - "pagination.bg": "#1f1f1f", + "pagination.item-bg": "#1f1f1f", "pagination.disabled-active-bg": "#424242", "pagination.disabled-bg": "#2a2a2a", - "pagination.disabled-color": "#525252", - "picker.cell-disabled-bg": "#2a2a2a", + "pagination.disabled-color.md": "#525252", + "pagination.disabled-color.sm": "#525252", + "date-picker.cell-disabled-bg": "#2a2a2a", "picker.cell-hover-bg": "#2a2a2a", - "picker.cell-selected-hover-bg": "#7a50bf", + "date-picker.cell-selected-hover-bg": "#7a50bf", "picker.clear-bg": "#1f1f1f", "picker.dropdown-bg": "#1f1f1f", "picker.input-bg": "#1f1f1f", @@ -175,16 +176,16 @@ "progress.text-color": "rgba(255, 255, 255, 0.65)", "progress.trail-bg": "#363636", "radio.bg": "#1f1f1f", - "radio.disabled-border": "#424242", - "radio.disabled-dot": "rgba(255, 255, 255, 0.2)", + "radio.border.disabled": "#424242", + "radio.dot-bg.disabled": "rgba(255, 255, 255, 0.2)", "result.content-bg": "#262626", "segmented.bg": "#2a2a2a", "segmented.item-bg-hover": "#303030", "segmented.item-bg-selected": "#1f1f1f", "select.dropdown-bg": "#1f1f1f", - "select.option-active-bg": "#2a2a2a", - "select.option-disabled-bg": "#1f1f1f", - "select.option-selected-bg": "#1a1325", + "select.option.active-bg": "#2a2a2a", + "select.option.disabled-bg": "#1f1f1f", + "select.option.selected-bg": "#1a1325", "skeleton.bg": "#303030", "skeleton.shimmer": "linear-gradient(to right, #303030 25%, #3a3a3a 37%, #303030 63%)", "slider.dot-active-border": "#9065d0", @@ -216,8 +217,8 @@ "switch.thumb-shadow": "0 1px 3px 1px rgba(0, 0, 0, 0.4)", "table.border": "#363636", "table.header-bg": "#262626", - "table.hover": "#2a2a2a", - "table.selected-bg": "rgba(144, 101, 208, 0.1)", + "table.row-hover-bg": "#2a2a2a", + "table.row-selected-bg": "rgba(144, 101, 208, 0.1)", "tabs.border": "#303030", "tabs.card-active-bg": "#1f1f1f", "tabs.card-bg": "#262626", @@ -276,7 +277,7 @@ "upload.dragger-bg": "#262626", "upload.dragger-border": "#424242", "upload.dragger-hover-bg": "#303030", - "upload.item-hover-bg": "#2a2a2a" + "upload.list-item-hover-bg": "#2a2a2a" } } } From 28883b9f5bfada9da9610c516ea90a76f00e1834 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Tue, 21 Apr 2026 21:57:35 +1000 Subject: [PATCH 2/3] feat(tokens): add seed-driven token foundations Introduce an internal primitive seed layer (brand, surface, typography, size, effects) and rewrite semantic tokens to reference seeds via {seed.*}. Enforce the primitive -> semantic -> component flow in both build-time validation and theme-runtime checks. Share a single compileBrandTheme via the new @tiny-design/tokens/compile-brand-theme export, generated from a source file with staleness verification. Remove the duplicated compile logic from the docs theme studio and rework its sidebar around seed groups. Add resolvedValue to the registry so downstream tooling (extract/MCP) can surface concrete default values while defaultValue stays the unresolved source reference. --- .../src/containers/theme-studio/defaults.ts | 72 +-- .../containers/theme-studio/editor-config.ts | 125 ++--- .../src/containers/theme-studio/index.tsx | 40 +- .../theme-studio/sidebar-content.tsx | 60 ++- .../theme-studio/theme-document-adapter.ts | 370 +------------ .../containers/theme-studio/theme-studio.scss | 7 + .../docs/src/containers/theme-studio/types.ts | 75 +-- packages/extract/src/extract-tokens.ts | 10 +- packages/mcp/__tests__/extract-tokens.test.ts | 9 + packages/mcp/src/data/components.json | 58 +- packages/mcp/src/data/tokens.json | 26 +- packages/tokens/REGISTRY_SPEC.md | 5 + packages/tokens/package.json | 5 + .../tokens/runtime/compile-brand-theme.cjs | 499 ++++++++++++++++++ .../tokens/runtime/compile-brand-theme.d.ts | 85 +++ .../tokens/runtime/compile-brand-theme.mjs | 494 +++++++++++++++++ packages/tokens/runtime/registry.d.ts | 3 +- packages/tokens/runtime/theme-runtime.cjs | 29 + packages/tokens/runtime/theme-runtime.mjs | 29 + packages/tokens/scripts/build-runtime.js | 141 ++++- packages/tokens/source/primitives/brand.json | 182 +++++++ .../tokens/source/primitives/effects.json | 42 ++ packages/tokens/source/primitives/size.json | 67 +++ .../tokens/source/primitives/surface.json | 112 ++++ .../tokens/source/primitives/typography.json | 77 +++ .../runtime/compile-brand-theme.source.js | 489 +++++++++++++++++ packages/tokens/source/semantic/charts.json | 10 +- packages/tokens/source/semantic/colors.json | 106 ++-- .../tokens/source/semantic/control-group.json | 4 +- packages/tokens/source/semantic/control.json | 6 +- packages/tokens/source/semantic/effects.json | 16 +- packages/tokens/source/semantic/size.json | 8 +- packages/tokens/source/semantic/spacing.json | 8 +- .../tokens/source/semantic/typography.json | 30 +- 34 files changed, 2562 insertions(+), 737 deletions(-) create mode 100644 packages/tokens/runtime/compile-brand-theme.cjs create mode 100644 packages/tokens/runtime/compile-brand-theme.d.ts create mode 100644 packages/tokens/runtime/compile-brand-theme.mjs create mode 100644 packages/tokens/source/primitives/brand.json create mode 100644 packages/tokens/source/primitives/effects.json create mode 100644 packages/tokens/source/primitives/size.json create mode 100644 packages/tokens/source/primitives/surface.json create mode 100644 packages/tokens/source/primitives/typography.json create mode 100644 packages/tokens/source/runtime/compile-brand-theme.source.js diff --git a/apps/docs/src/containers/theme-studio/defaults.ts b/apps/docs/src/containers/theme-studio/defaults.ts index 3d24bb0fa..29a89217e 100644 --- a/apps/docs/src/containers/theme-studio/defaults.ts +++ b/apps/docs/src/containers/theme-studio/defaults.ts @@ -1,75 +1,7 @@ +import { DEFAULT_BRAND_SEED_FIELDS } from '@tiny-design/tokens/compile-brand-theme'; import type { ThemeEditorDraft, ThemeEditorFields, ThemeMode } from './types'; -const DEFAULT_FONT_SANS = '"Instrument Sans", "Inter", system-ui, sans-serif'; -const DEFAULT_FONT_MONO = '"JetBrains Mono", "SFMono-Regular", monospace'; - -export const DEFAULT_FIELDS: ThemeEditorFields = { - primary: '#6e41bf', - primaryForeground: '#ffffff', - secondary: '#f5f5f7', - secondaryForeground: '#16181d', - accent: '#f3eefa', - accentForeground: '#6e41bf', - success: '#52c41a', - successForeground: '#ffffff', - info: '#1890ff', - infoForeground: '#ffffff', - warning: '#fa8c16', - warningForeground: '#ffffff', - danger: '#dc2626', - dangerForeground: '#ffffff', - base: '#ffffff', - baseForeground: 'rgba(0, 0, 0, 0.85)', - card: '#ffffff', - cardForeground: '#111827', - popover: '#ffffff', - popoverForeground: '#111827', - muted: '#f5f5f5', - mutedForeground: 'rgba(0, 0, 0, 0.55)', - border: '#d9d9d9', - input: '#d9d9d9', - ring: '#6e41bf', - chart1: '#6e41bf', - chart2: '#1890ff', - chart3: '#52c41a', - chart4: '#fa8c16', - chart5: '#eb2f96', - sidebar: '#12131a', - sidebarForeground: '#f8fafc', - sidebarPrimary: '#6e41bf', - sidebarPrimaryForeground: '#ffffff', - sidebarAccent: '#23173f', - sidebarAccentForeground: '#efe8ff', - sidebarBorder: '#2a2d36', - sidebarRing: '#8b62d0', - fontSans: DEFAULT_FONT_SANS, - fontMono: DEFAULT_FONT_MONO, - fontSizeBase: '14px', - lineHeightBase: '1.5', - h1Size: '40px', - h2Size: '32px', - letterSpacing: '-0.02em', - radius: '0.3rem', - shadowControl: 'none', - shadowCard: '0 20px 55px rgba(17, 24, 39, 0.08)', - shadowFocus: '0 0 0 3px rgba(110, 65, 191, 0.22)', - buttonRadius: '0.3rem', - inputRadius: '0.3rem', - cardRadius: '0.3rem', - fieldPaddingSm: '8px', - fieldPaddingMd: '8px', - fieldPaddingLg: '8px', - buttonPaddingSm: '8px', - buttonPaddingMd: '15px', - buttonPaddingLg: '20px', - fieldHeightSm: '24px', - fieldHeightMd: '35px', - fieldHeightLg: '44px', - buttonHeightSm: '24px', - buttonHeightMd: '35px', - buttonHeightLg: '44px', - cardPadding: '18px', -}; +export const DEFAULT_FIELDS: ThemeEditorFields = { ...DEFAULT_BRAND_SEED_FIELDS }; export function createDraft( id: string, diff --git a/apps/docs/src/containers/theme-studio/editor-config.ts b/apps/docs/src/containers/theme-studio/editor-config.ts index e35e579da..2114a7334 100644 --- a/apps/docs/src/containers/theme-studio/editor-config.ts +++ b/apps/docs/src/containers/theme-studio/editor-config.ts @@ -1,70 +1,53 @@ -import type { FieldKey, ThemeEditorColorGroup } from './types'; +import type { ThemeEditorSeedGroup } from './types'; -export const COLOR_GROUPS: ThemeEditorColorGroup[] = [ +export const SEED_COLOR_GROUPS: ThemeEditorSeedGroup[] = [ { - title: 'Primary', + title: 'Brand Core', + description: 'Primary, accent, and supporting brand surfaces.', + tier: 'core', fields: [ - { key: 'primary', label: 'Background' }, - { key: 'primaryForeground', label: 'Foreground' }, + { key: 'primary', label: 'Primary' }, + { key: 'primaryForeground', label: 'On Primary' }, + { key: 'secondary', label: 'Secondary' }, + { key: 'secondaryForeground', label: 'On Secondary' }, + { key: 'accent', label: 'Accent' }, + { key: 'accentForeground', label: 'On Accent' }, ], }, { - title: 'Secondary', - fields: [ - { key: 'secondary', label: 'Background' }, - { key: 'secondaryForeground', label: 'Foreground' }, - ], - }, - { - title: 'Accent', - fields: [ - { key: 'accent', label: 'Background' }, - { key: 'accentForeground', label: 'Foreground' }, - ], - }, - { - title: 'Status', + title: 'Feedback States', + description: 'Semantic status hues that propagate through alerts, tags, and feedback UI.', + tier: 'core', fields: [ { key: 'success', label: 'Success' }, - { key: 'successForeground', label: 'Success FG' }, + { key: 'successForeground', label: 'On Success' }, { key: 'info', label: 'Info' }, - { key: 'infoForeground', label: 'Info FG' }, + { key: 'infoForeground', label: 'On Info' }, { key: 'warning', label: 'Warning' }, - { key: 'warningForeground', label: 'Warning FG' }, + { key: 'warningForeground', label: 'On Warning' }, { key: 'danger', label: 'Danger' }, - { key: 'dangerForeground', label: 'Danger FG' }, + { key: 'dangerForeground', label: 'On Danger' }, ], }, { - title: 'Base', + title: 'Surfaces & Text', + description: 'Page, card, popover, and muted surfaces plus their text companions.', + tier: 'core', fields: [ - { key: 'base', label: 'Background' }, - { key: 'baseForeground', label: 'Foreground' }, + { key: 'base', label: 'Page' }, + { key: 'baseForeground', label: 'On Page' }, + { key: 'card', label: 'Card' }, + { key: 'cardForeground', label: 'On Card' }, + { key: 'popover', label: 'Popover' }, + { key: 'popoverForeground', label: 'On Popover' }, + { key: 'muted', label: 'Muted' }, + { key: 'mutedForeground', label: 'On Muted' }, ], }, { - title: 'Card', - fields: [ - { key: 'card', label: 'Background' }, - { key: 'cardForeground', label: 'Foreground' }, - ], - }, - { - title: 'Popover', - fields: [ - { key: 'popover', label: 'Background' }, - { key: 'popoverForeground', label: 'Foreground' }, - ], - }, - { - title: 'Muted', - fields: [ - { key: 'muted', label: 'Background' }, - { key: 'mutedForeground', label: 'Foreground' }, - ], - }, - { - title: 'Border & Input', + title: 'Lines & Focus', + description: 'Borders, field chrome, and the interaction ring seed.', + tier: 'core', fields: [ { key: 'border', label: 'Border' }, { key: 'input', label: 'Input' }, @@ -72,40 +55,34 @@ export const COLOR_GROUPS: ThemeEditorColorGroup[] = [ ], }, { - title: 'Chart', + title: 'Data Visualization', + description: 'Chart palette seeds for data-heavy views.', + tier: 'advanced', fields: [ - { key: 'chart1', label: 'Chart 1' }, - { key: 'chart2', label: 'Chart 2' }, - { key: 'chart3', label: 'Chart 3' }, - { key: 'chart4', label: 'Chart 4' }, - { key: 'chart5', label: 'Chart 5' }, + { key: 'chart1', label: 'Series 1' }, + { key: 'chart2', label: 'Series 2' }, + { key: 'chart3', label: 'Series 3' }, + { key: 'chart4', label: 'Series 4' }, + { key: 'chart5', label: 'Series 5' }, ], }, { - title: 'Sidebar', + title: 'Sidebar Shell', + description: 'Dedicated app-shell seeds for navigation-heavy layouts.', + tier: 'advanced', fields: [ - { key: 'sidebar', label: 'Background' }, - { key: 'sidebarForeground', label: 'Foreground' }, - { key: 'sidebarPrimary', label: 'Primary' }, - { key: 'sidebarPrimaryForeground', label: 'Primary FG' }, - { key: 'sidebarAccent', label: 'Accent' }, - { key: 'sidebarAccentForeground', label: 'Accent FG' }, - { key: 'sidebarBorder', label: 'Border' }, - { key: 'sidebarRing', label: 'Ring' }, + { key: 'sidebar', label: 'Sidebar' }, + { key: 'sidebarForeground', label: 'On Sidebar' }, + { key: 'sidebarPrimary', label: 'Sidebar Primary' }, + { key: 'sidebarPrimaryForeground', label: 'On Sidebar Primary' }, + { key: 'sidebarAccent', label: 'Sidebar Accent' }, + { key: 'sidebarAccentForeground', label: 'On Sidebar Accent' }, + { key: 'sidebarBorder', label: 'Sidebar Border' }, + { key: 'sidebarRing', label: 'Sidebar Ring' }, ], }, ]; -export const CORE_COLOR_GROUP_TITLES = new Set([ - 'Primary', - 'Secondary', - 'Accent', - 'Status', - 'Base', - 'Muted', - 'Border & Input', -]); - export const FONT_OPTIONS = [ 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif', '"Instrument Sans", "Inter", system-ui, sans-serif', diff --git a/apps/docs/src/containers/theme-studio/index.tsx b/apps/docs/src/containers/theme-studio/index.tsx index 93f72541a..f35b1b349 100644 --- a/apps/docs/src/containers/theme-studio/index.tsx +++ b/apps/docs/src/containers/theme-studio/index.tsx @@ -46,7 +46,7 @@ const ThemeStudioPage = (): React.ReactElement => { const [codeVisible, setCodeVisible] = useState(false); const [importText, setImportText] = useState(''); const [importError, setImportError] = useState(null); - const [status, setStatus] = useState('Editing local draft'); + const [status, setStatus] = useState('Editing local seed draft'); const globalMode: ThemeMode = resolvedTheme === 'dark' ? 'dark' : 'light'; useEffect(() => { @@ -54,6 +54,20 @@ const ThemeStudioPage = (): React.ReactElement => { }, [draft]); const themeDocument = useMemo(() => buildThemeDocumentFromDraft(draft), [draft]); + const seedJson = useMemo( + () => + JSON.stringify( + { + meta: draft.meta, + mode: draft.mode, + presetId: draft.presetId, + fields: draft.fields, + }, + null, + 2 + ), + [draft] + ); const themeJson = useMemo(() => generateThemeDocumentJSON(themeDocument), [themeDocument]); const cssVars = useMemo(() => generateThemeCssVariables(themeDocument), [themeDocument]); const changedTokens = useMemo(() => compareThemeAgainstBase(themeDocument), [themeDocument]); @@ -105,13 +119,13 @@ const ThemeStudioPage = (): React.ReactElement => { const resetToPreset = () => { setDraft((current) => applyPresetToDraft(current.presetId, current)); - setStatus('Reset to preset defaults'); + setStatus('Reset seed draft to preset defaults'); }; const handlePresetChange = (presetId: string) => { setDraft((current) => applyPresetToDraft(presetId, current)); setStatus( - `Applied ${THEME_EDITOR_PRESETS.find((preset) => preset.id === presetId)?.name ?? 'preset'}` + `Applied ${THEME_EDITOR_PRESETS.find((preset) => preset.id === presetId)?.name ?? 'preset'} seed preset` ); }; @@ -129,8 +143,8 @@ const ThemeStudioPage = (): React.ReactElement => { setImportError(null); setStatus( validation.warnings.length > 0 - ? 'Imported theme document with validation warnings' - : 'Imported theme document' + ? 'Imported theme document and remapped it into seed groups with validation warnings' + : 'Imported theme document and remapped it into seed groups' ); } catch { setImportError('Invalid theme document JSON'); @@ -253,7 +267,8 @@ const ThemeStudioPage = (): React.ReactElement => { Paste a Tiny theme document JSON export to replace the current global theme. - Preset selection and all editor controls will sync to the imported values. + The studio will map semantic and component token overrides back into the seed groups + shown in the editor.