From 14e8f2434e4bccf874045173cecec9255e8fc909 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 6 Apr 2026 11:12:24 +1000 Subject: [PATCH 01/10] feat: init migration --- .../theme-editor/components/export-dialog.tsx | 11 +- .../theme-editor/hooks/use-theme-state.ts | 12 +- .../src/containers/theme-editor/index.tsx | 4 +- .../theme-editor/utils/apply-theme.ts | 6 +- .../theme-editor/utils/export-theme.ts | 20 +- apps/docs/src/utils/theme-document.ts | 187 +++++++++ apps/docs/src/utils/theme-persistence.ts | 24 +- apps/docs/tsconfig.json | 1 + packages/react/COMPONENT_TOKEN_MIGRATION.md | 326 ++++++++++++++++ packages/react/SCSS_AUTHORING.md | 84 +++++ packages/react/scripts/build-styles.js | 6 +- packages/react/src/button/style/_index.scss | 108 ++++-- packages/react/src/button/style/_mixin.scss | 14 +- packages/react/src/card/style/_index.scss | 28 +- packages/react/src/config-provider/index.tsx | 2 +- .../react/src/config-provider/token-utils.ts | 82 +++- packages/react/src/input/style/_index.scss | 18 +- packages/react/src/input/style/_mixin.scss | 18 +- packages/tokens/.gitignore | 1 + packages/tokens/ALIAS_MAP_SPEC.md | 113 ++++++ packages/tokens/REGISTRY_SPEC.md | 154 ++++++++ packages/tokens/build/build-v2.js | 355 ++++++++++++++++++ packages/tokens/package.json | 11 +- packages/tokens/scripts/build.js | 26 +- packages/tokens/source/components/button.json | 225 +++++++++++ packages/tokens/source/components/card.json | 71 ++++ packages/tokens/source/components/input.json | 149 ++++++++ .../tokens/source/schema/theme.v1.schema.json | 136 +++++++ packages/tokens/source/semantic/colors.json | 137 +++++++ packages/tokens/source/semantic/effects.json | 12 + packages/tokens/source/semantic/size.json | 22 ++ packages/tokens/source/semantic/spacing.json | 17 + .../tokens/source/semantic/typography.json | 27 ++ packages/tokens/source/themes/dark.json | 40 ++ packages/tokens/source/themes/light.json | 10 + 35 files changed, 2330 insertions(+), 127 deletions(-) create mode 100644 apps/docs/src/utils/theme-document.ts create mode 100644 packages/react/COMPONENT_TOKEN_MIGRATION.md create mode 100644 packages/react/SCSS_AUTHORING.md create mode 100644 packages/tokens/ALIAS_MAP_SPEC.md create mode 100644 packages/tokens/REGISTRY_SPEC.md create mode 100644 packages/tokens/build/build-v2.js create mode 100644 packages/tokens/source/components/button.json create mode 100644 packages/tokens/source/components/card.json create mode 100644 packages/tokens/source/components/input.json create mode 100644 packages/tokens/source/schema/theme.v1.schema.json create mode 100644 packages/tokens/source/semantic/colors.json create mode 100644 packages/tokens/source/semantic/effects.json create mode 100644 packages/tokens/source/semantic/size.json create mode 100644 packages/tokens/source/semantic/spacing.json create mode 100644 packages/tokens/source/semantic/typography.json create mode 100644 packages/tokens/source/themes/dark.json create mode 100644 packages/tokens/source/themes/light.json diff --git a/apps/docs/src/containers/theme-editor/components/export-dialog.tsx b/apps/docs/src/containers/theme-editor/components/export-dialog.tsx index fcde808d..022f55d8 100644 --- a/apps/docs/src/containers/theme-editor/components/export-dialog.tsx +++ b/apps/docs/src/containers/theme-editor/components/export-dialog.tsx @@ -1,12 +1,13 @@ import React, { useState } from 'react'; +import type { ThemeDocument } from '@tiny-design/react'; import { Button, Modal, Tabs } from '@tiny-design/react'; import { generateCSS, generateJSON } from '../utils/export-theme'; interface ExportDialogProps { visible: boolean; onClose: () => void; - seeds: Record; appliedTokens: Record; + themeDocument: ThemeDocument; } function downloadFile(content: string, filename: string, mime: string): void { @@ -22,13 +23,13 @@ function downloadFile(content: string, filename: string, mime: string): void { export const ExportDialog = ({ visible, onClose, - seeds, appliedTokens, + themeDocument, }: ExportDialogProps): React.ReactElement => { const [copied, setCopied] = useState(false); const cssCode = generateCSS(appliedTokens); - const jsonCode = generateJSON(seeds); + const jsonCode = generateJSON(themeDocument); const handleCopy = (code: string) => { navigator.clipboard.writeText(code).then(() => { @@ -70,8 +71,8 @@ export const ExportDialog = ({ {renderBlock(cssCode, 'tiny-theme.css', 'text/css')} - - {renderBlock(jsonCode, 'tiny-theme.json', 'application/json')} + + {renderBlock(jsonCode, 'tiny-theme.document.json', 'application/json')} diff --git a/apps/docs/src/containers/theme-editor/hooks/use-theme-state.ts b/apps/docs/src/containers/theme-editor/hooks/use-theme-state.ts index 4f886f0f..dc99502d 100644 --- a/apps/docs/src/containers/theme-editor/hooks/use-theme-state.ts +++ b/apps/docs/src/containers/theme-editor/hooks/use-theme-state.ts @@ -1,7 +1,9 @@ import { useState, useCallback, useEffect, useRef } from 'react'; +import type { ThemeDocument } from '@tiny-design/react'; import { ALL_TOKENS } from '../constants/default-tokens'; import { applyThemeToDOM, + buildThemeDocumentFromSeeds, clearThemeFromDOM, saveSeeds, clearStoredSeeds, @@ -28,6 +30,8 @@ export interface ThemeState { seeds: Record; /** All derived + seed tokens currently applied */ applied: Record; + /** Current v2 theme document exported from editor state */ + themeDocument: ThemeDocument; /** Whether dark mode is active */ isDark: boolean; /** Update a single seed token */ @@ -46,13 +50,12 @@ export function useThemeState(): ThemeState { const [seeds, setSeeds] = useState>(loadFromStorage); const [isDark, setIsDark] = useState(detectDarkMode); const appliedRef = useRef>({}); + const themeDocumentRef = useRef(buildThemeDocumentFromSeeds({}, detectDarkMode())); const applyAll = useCallback((newSeeds: Record, dark?: boolean) => { const darkMode = dark ?? detectDarkMode(); - - // Delegate DOM application to the global persistence module - const derived = applyThemeToDOM(newSeeds, darkMode); - appliedRef.current = derived; + appliedRef.current = applyThemeToDOM(newSeeds, darkMode); + themeDocumentRef.current = buildThemeDocumentFromSeeds(newSeeds, darkMode); }, []); // Apply on mount @@ -162,6 +165,7 @@ export function useThemeState(): ThemeState { return { seeds, applied: appliedRef.current, + themeDocument: themeDocumentRef.current, isDark, setSeed, applyPreset, diff --git a/apps/docs/src/containers/theme-editor/index.tsx b/apps/docs/src/containers/theme-editor/index.tsx index dde5f406..ca170f1e 100644 --- a/apps/docs/src/containers/theme-editor/index.tsx +++ b/apps/docs/src/containers/theme-editor/index.tsx @@ -24,7 +24,7 @@ function loadPresetId(): string | undefined { } const ThemeEditor = (): React.ReactElement => { - const { seeds, applied, isDark, setSeed, applyPreset, reset, isOverridden, resetToken } = + const { seeds, applied, themeDocument, isDark, setSeed, applyPreset, reset, isOverridden, resetToken } = useThemeState(); const [exportVisible, setExportVisible] = useState(false); const [activePresetId, setActivePresetId] = useState(loadPresetId); @@ -135,8 +135,8 @@ const ThemeEditor = (): React.ReactElement => { setExportVisible(false)} - seeds={seeds} appliedTokens={applied} + themeDocument={themeDocument} /> ); diff --git a/apps/docs/src/containers/theme-editor/utils/apply-theme.ts b/apps/docs/src/containers/theme-editor/utils/apply-theme.ts index 7347635a..0b7c36d3 100644 --- a/apps/docs/src/containers/theme-editor/utils/apply-theme.ts +++ b/apps/docs/src/containers/theme-editor/utils/apply-theme.ts @@ -3,14 +3,16 @@ const PREFIX = '--ty-'; export function applyTokens(overrides: Record): void { const style = document.documentElement.style; for (const [key, value] of Object.entries(overrides)) { - style.setProperty(`${PREFIX}${key}`, value); + const cssVarName = key.startsWith('--') ? key : `${PREFIX}${key}`; + style.setProperty(cssVarName, value); } } export function removeTokens(keys: string[]): void { const style = document.documentElement.style; for (const key of keys) { - style.removeProperty(`${PREFIX}${key}`); + const cssVarName = key.startsWith('--') ? key : `${PREFIX}${key}`; + style.removeProperty(cssVarName); } } diff --git a/apps/docs/src/containers/theme-editor/utils/export-theme.ts b/apps/docs/src/containers/theme-editor/utils/export-theme.ts index 1354e99b..a3678971 100644 --- a/apps/docs/src/containers/theme-editor/utils/export-theme.ts +++ b/apps/docs/src/containers/theme-editor/utils/export-theme.ts @@ -1,21 +1,17 @@ -/** Keys that should be excluded from CSS variable export (they are meta-seeds, not real tokens) */ -const META_KEYS = new Set(['shadow-intensity']); +import type { ThemeDocument } from '@tiny-design/react'; +import { generateThemeDocumentJSON } from '../../../utils/theme-document'; export function generateCSS(overrides: Record): string { const lines = Object.entries(overrides) - .filter(([key]) => !META_KEYS.has(key)) - .map(([key, value]) => ` --ty-${key}: ${value};`) + .map(([key, value]) => { + const cssVarName = key.startsWith('--') ? key : `--ty-${key}`; + return ` ${cssVarName}: ${value};`; + }) .join('\n'); return `:root {\n${lines}\n}`; } -export function generateJSON(seeds: Record): string { - const clean: Record = {}; - for (const [key, value] of Object.entries(seeds)) { - if (!META_KEYS.has(key)) { - clean[key] = value; - } - } - return JSON.stringify(clean, null, 2); +export function generateJSON(themeDocument: ThemeDocument): string { + return generateThemeDocumentJSON(themeDocument); } diff --git a/apps/docs/src/utils/theme-document.ts b/apps/docs/src/utils/theme-document.ts new file mode 100644 index 00000000..d7f1896e --- /dev/null +++ b/apps/docs/src/utils/theme-document.ts @@ -0,0 +1,187 @@ +import type { ThemeDocument } from '@tiny-design/react'; +import { deriveAllTokens } from '../containers/theme-editor/utils/color-utils'; +import semanticColors from '../../../../packages/tokens/source/semantic/colors.json'; +import semanticTypography from '../../../../packages/tokens/source/semantic/typography.json'; +import semanticSize from '../../../../packages/tokens/source/semantic/size.json'; +import semanticSpacing from '../../../../packages/tokens/source/semantic/spacing.json'; +import semanticEffects from '../../../../packages/tokens/source/semantic/effects.json'; +import buttonTokens from '../../../../packages/tokens/source/components/button.json'; +import inputTokens from '../../../../packages/tokens/source/components/input.json'; +import cardTokens from '../../../../packages/tokens/source/components/card.json'; +import lightTheme from '../../../../packages/tokens/source/themes/light.json'; +import darkTheme from '../../../../packages/tokens/source/themes/dark.json'; + +type TokenDefinition = { + $value: string | number; +}; + +const META_KEYS = new Set(['shadow-intensity']); + +const SOURCE_TOKENS: Record = { + ...semanticColors, + ...semanticTypography, + ...semanticSize, + ...semanticSpacing, + ...semanticEffects, + ...buttonTokens, + ...inputTokens, + ...cardTokens, +}; + +const SOURCE_TOKEN_KEYS = new Set(Object.keys(SOURCE_TOKENS)); + +const EDITOR_THEME_DOCUMENT_KEYS = new Set([ + ...Object.keys(semanticColors), + ...Object.keys(semanticTypography), + ...Object.keys(semanticSize), + ...Object.keys(semanticEffects), + 'color-bg', + 'color-bg-elevated', + 'color-bg-layout', + 'color-bg-spotlight', + 'color-text-secondary', + 'color-text-tertiary', + 'color-border-light', + 'font-family', + 'font-family-monospace', + 'font-weight', + 'headings-font-weight', + 'letter-spacing', + 'shadow', + 'shadow-sm', + 'shadow-lg', + 'shadow-popup', + 'shadow-modal', + 'shadow-btn', +]); + +const BASE_THEME_BY_ID = { + 'tiny-light': lightTheme, + 'tiny-dark': darkTheme, +} as const; + +function getBaseTheme(theme: ThemeDocument): typeof lightTheme | typeof darkTheme { + if (theme.extends && theme.extends in BASE_THEME_BY_ID) { + return BASE_THEME_BY_ID[theme.extends as keyof typeof BASE_THEME_BY_ID]; + } + return theme.mode === 'dark' ? darkTheme : lightTheme; +} + +function resolveTokenValue( + key: string, + rawValues: Record, + cache: Map, + stack: Set +): string { + const cached = cache.get(key); + if (cached) return cached; + + const raw = rawValues[key]; + if (raw == null) return ''; + if (stack.has(key)) return raw; + + const match = /^\{(.+)\}$/.exec(raw); + if (!match) { + cache.set(key, raw); + return raw; + } + + stack.add(key); + const resolved = resolveTokenValue(match[1], rawValues, cache, stack) || raw; + stack.delete(key); + cache.set(key, resolved); + return resolved; +} + +function componentTokenKeyToCssVar(key: string): string { + return `--ty-${key.replace(/\./g, '-')}`; +} + +export function buildThemeDocumentFromSeeds( + seeds: Record, + isDark: boolean +): ThemeDocument { + const derived = deriveAllTokens(seeds, isDark); + const semantic: Record = {}; + + for (const [key, value] of Object.entries(derived)) { + if (META_KEYS.has(key)) continue; + if (!EDITOR_THEME_DOCUMENT_KEYS.has(key)) continue; + semantic[key] = value; + } + + return { + meta: { + id: 'docs-theme-editor', + name: 'Docs Theme Editor', + schemaVersion: 1, + }, + mode: isDark ? 'dark' : 'light', + extends: isDark ? 'tiny-dark' : 'tiny-light', + tokens: { + semantic, + components: {}, + }, + }; +} + +export function resolveThemeDocument(theme: ThemeDocument): Record { + const baseTheme = getBaseTheme(theme); + const baseOverrides = baseTheme.tokens?.semantic ?? {}; + const semanticOverrides = theme.tokens?.semantic ?? {}; + const componentOverrides = theme.tokens?.components ?? {}; + + const rawValues: Record = {}; + for (const [key, definition] of Object.entries(SOURCE_TOKENS)) { + const override = key in componentOverrides + ? componentOverrides[key] + : key in semanticOverrides + ? semanticOverrides[key] + : key in baseOverrides + ? baseOverrides[key] + : definition.$value; + rawValues[key] = String(override); + } + + const cache = new Map(); + const resolved: Record = {}; + for (const key of Object.keys(SOURCE_TOKENS)) { + const value = resolveTokenValue(key, rawValues, cache, new Set()); + const cssVar = key.includes('.') ? componentTokenKeyToCssVar(key) : `--ty-${key}`; + resolved[cssVar] = value; + } + + for (const [key, value] of Object.entries(semanticOverrides)) { + if (!SOURCE_TOKEN_KEYS.has(key)) { + resolved[`--ty-${key}`] = String(value); + } + } + + for (const [key, value] of Object.entries(componentOverrides)) { + if (!SOURCE_TOKEN_KEYS.has(key)) { + resolved[componentTokenKeyToCssVar(key)] = String(value); + } + } + + return resolved; +} + +export function buildLegacyPreviewOverrides( + seeds: Record, + isDark: boolean +): Record { + const derived = deriveAllTokens(seeds, isDark); + const extras: Record = {}; + + for (const [key, value] of Object.entries(derived)) { + if (META_KEYS.has(key)) continue; + if (EDITOR_THEME_DOCUMENT_KEYS.has(key)) continue; + extras[key] = value; + } + + return extras; +} + +export function generateThemeDocumentJSON(theme: ThemeDocument): string { + return JSON.stringify(theme, null, 2); +} diff --git a/apps/docs/src/utils/theme-persistence.ts b/apps/docs/src/utils/theme-persistence.ts index fca97f0b..60bcd75c 100644 --- a/apps/docs/src/utils/theme-persistence.ts +++ b/apps/docs/src/utils/theme-persistence.ts @@ -1,6 +1,10 @@ -import { deriveAllTokens } from '../containers/theme-editor/utils/color-utils'; import { applyTokens, clearAllTokenOverrides } from '../containers/theme-editor/utils/apply-theme'; import { loadFontFromValue } from '../containers/theme-editor/utils/font-loader'; +import { + buildLegacyPreviewOverrides, + buildThemeDocumentFromSeeds, + resolveThemeDocument, +} from './theme-document'; const STORAGE_KEY = 'ty-theme-editor-overrides'; const STORAGE_KEY_DARK = 'ty-theme-editor-overrides-dark'; @@ -58,11 +62,17 @@ export function applyThemeToDOM( if (resolvedSeeds['font-family']) loadFontFromValue(resolvedSeeds['font-family']); if (resolvedSeeds['font-family-monospace']) loadFontFromValue(resolvedSeeds['font-family-monospace']); - // Derive and apply - const derived = deriveAllTokens(resolvedSeeds, darkMode); - lastApplied = derived; - applyTokens(derived); - return derived; + const themeDocument = buildThemeDocumentFromSeeds(resolvedSeeds, darkMode); + const resolvedV2Vars = resolveThemeDocument(themeDocument); + const legacyPreviewVars = buildLegacyPreviewOverrides(resolvedSeeds, darkMode); + const applied = { + ...resolvedV2Vars, + ...legacyPreviewVars, + }; + + lastApplied = applied; + applyTokens(applied); + return applied; } /** @@ -103,6 +113,8 @@ export function clearStoredSeeds(): void { localStorage.removeItem(STORAGE_KEY_DARK); } +export { buildThemeDocumentFromSeeds } from './theme-document'; + // Global MutationObserver: re-apply theme tokens when dark mode toggles, // even when the theme editor page is not mounted. const observer = new MutationObserver(() => { diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 1d189091..5bd8aa1a 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "baseUrl": ".", "noUnusedLocals": false, + "resolveJsonModule": true, "paths": { "@tiny-design/react": ["../../packages/react/src"], "@tiny-design/react/*": ["../../packages/react/src/*"] diff --git a/packages/react/COMPONENT_TOKEN_MIGRATION.md b/packages/react/COMPONENT_TOKEN_MIGRATION.md new file mode 100644 index 00000000..1688ff3c --- /dev/null +++ b/packages/react/COMPONENT_TOKEN_MIGRATION.md @@ -0,0 +1,326 @@ +# Component Token Migration Draft + +This draft defines the first v2 token inventories for `Button`, `Input`, and `Card`, plus the SCSS migration patterns that move existing styles to token-first authoring. + +## Scope +These three components are the first migration targets because they define the baseline for: + +- typography and density +- interactive states +- semantic fallback rules +- slot naming for container components + +## Naming Rules +- Source token keys use dot notation with kebab-case segments. +- Runtime CSS vars stay on the `--ty-*` prefix for v2 compatibility. +- New component names use full nouns: `button`, `input`, `card`. +- Existing legacy vars such as `--ty-btn-*` remain supported through an alias layer during migration. + +## Button + +### Proposed token inventory +- `button.radius` +- `button.line-height` +- `button.min-width` +- `button.group-gap` +- `button.group-divider-color` +- `button.round-radius` +- `button.loading-bg` +- `button.loading-opacity` +- `button.font-size-sm` +- `button.font-size-md` +- `button.font-size-lg` +- `button.height.sm` +- `button.height.md` +- `button.height.lg` +- `button.padding-inline-sm` +- `button.padding-inline-md` +- `button.padding-inline-lg` +- `button.bg.default` +- `button.bg.default-hover` +- `button.bg.default-active` +- `button.border.default` +- `button.border.default-hover` +- `button.border.default-active` +- `button.text.default` +- `button.text.default-hover` +- `button.text.default-active` +- `button.bg.primary` +- `button.bg.primary-hover` +- `button.bg.primary-active` +- `button.text.primary` +- `button.bg.outline-hover` +- `button.bg.outline-active` +- `button.bg.ghost-hover` +- `button.bg.ghost-active` +- `button.text.link-disabled` +- `button.bg.disabled` +- `button.border.disabled` +- `button.text.disabled` + +### Final v2 token surface +The list above is the migration inventory found in the current SCSS. The recommended v2 public surface should be smaller: + +- `button.radius` +- `button.line-height` +- `button.min-width` +- `button.group-gap` +- `button.group-divider-color` +- `button.round-radius` +- `button.loading-bg` +- `button.loading-opacity` +- `button.font-size-sm` +- `button.font-size-md` +- `button.font-size-lg` +- `button.height.sm` +- `button.height.md` +- `button.height.lg` +- `button.padding-inline-sm` +- `button.padding-inline-md` +- `button.padding-inline-lg` +- `button.bg.default` +- `button.bg.default-hover` +- `button.bg.default-active` +- `button.border.default` +- `button.border.default-hover` +- `button.border.default-active` +- `button.text.default` +- `button.text.default-hover` +- `button.text.default-active` +- `button.bg.primary` +- `button.bg.primary-hover` +- `button.bg.primary-active` +- `button.text.primary` +- `button.text.link-disabled` +- `button.bg.disabled` +- `button.border.disabled` +- `button.text.disabled` + +### Excluded from v2 component tokens +These stay semantic for now and should not get dedicated component tokens unless a real customization need appears: + +- `outline` hover and active colors +- `ghost` hover and active colors +- `info` / `success` / `warning` / `danger` variant colors +- solid variant text colors that are always `#fff` + +This keeps the public `Button` token surface near 30-33 tokens instead of expanding toward per-variant state explosion. + +### Scope note +The v2 component surface for `Button` should stay intentionally narrow. Semantic tokens should continue to drive `info`, `success`, `warning`, and `danger` variants: + +```scss +.ty-btn_success { + background: var(--ty-color-success); + + &:hover { + background: var(--ty-color-success-hover); + } + + &:active { + background: var(--ty-color-success-active); + } +} +``` + +Only variants with distinct structural or non-semantic behavior should get dedicated component tokens: +- `default` +- `primary` +- `link` + +`outline` and `ghost` should continue to compose semantic tokens unless a later product requirement proves they need independent theming. + +### Recommended fallback examples + +```scss +.ty-btn { + min-width: var(--ty-button-min-width, auto); + border-radius: var(--ty-button-radius, var(--ty-border-radius)); + line-height: var(--ty-button-line-height, var(--ty-line-height-base)); +} + +.ty-btn_primary { + color: var(--ty-button-text-primary, #fff); + background: var(--ty-button-bg-primary, var(--ty-color-primary)); + border-color: var(--ty-button-border-primary, var(--ty-color-primary)); +} + +.ty-btn_outline { + color: var(--ty-color-primary); + background: var(--ty-button-bg-default, var(--ty-color-bg-container)); + border-color: var(--ty-color-primary); + + &:hover { + background: var(--ty-color-primary-bg); + border-color: var(--ty-color-primary-hover); + } + + &:active { + background: var(--ty-color-primary-bg-hover); + } +} +``` + +### Legacy to v2 alias examples +- `--ty-btn-border-radius` -> `--ty-button-radius` +- `--ty-btn-height-md` -> `--ty-button-height-md` +- `--ty-btn-default-bg` -> `--ty-button-bg-default` +- `--ty-btn-default-hover-bg` -> `--ty-button-bg-default-hover` + +## Input + +### Proposed token inventory +- `input.radius` +- `input.color` +- `input.bg` +- `input.bg.disabled` +- `input.border` +- `input.border.hover` +- `input.border.focus` +- `input.shadow.focus` +- `input.placeholder` +- `input.addon-bg` +- `input.addon-padding` +- `input.affix-margin` +- `input.clear-size` +- `input.clear-color` +- `input.font-size-sm` +- `input.font-size-md` +- `input.font-size-lg` +- `input.height.sm` +- `input.height.md` +- `input.height.lg` +- `input.padding-inline-sm` +- `input.padding-inline-md` +- `input.padding-inline-lg` +- `input.text.disabled` + +### Recommended fallback examples + +```scss +.ty-input__input { + color: var(--ty-input-color, var(--ty-color-text)); + background: var(--ty-input-bg, var(--ty-color-bg-container)); + border: 1px solid var(--ty-input-border, var(--ty-color-border)); + border-radius: var(--ty-input-radius, var(--ty-border-radius)); +} + +.ty-input__input:focus { + border-color: var(--ty-input-border-focus, var(--ty-color-primary)); + box-shadow: var(--ty-input-shadow-focus, var(--ty-shadow-focus)); +} +``` + +### Legacy to v2 alias examples +- `--ty-input-border-radius` -> `--ty-input-radius` +- `--ty-input-focus-border` -> `--ty-input-border-focus` +- `--ty-input-focus-shadow` -> `--ty-input-shadow-focus` +- `--ty-input-disabled-color` -> `--ty-input-text-disabled` + +## Card + +### Proposed token inventory +- `card.radius` +- `card.bg` +- `card.bg.filled` +- `card.border` +- `card.shadow` +- `card.shadow.hover` +- `card.header-padding` +- `card.body-padding` +- `card.footer-padding` +- `card.header-color` +- `card.header-font-size` +- `card.header-font-weight` + +### Recommended fallback examples + +```scss +.ty-card { + border-radius: var(--ty-card-radius, var(--ty-border-radius)); + background: var(--ty-card-bg, var(--ty-color-bg-container)); +} + +.ty-card__header { + padding: var(--ty-card-header-padding, var(--ty-spacing-5)); + color: var(--ty-card-header-color, var(--ty-color-text-heading)); + border-bottom: 1px solid var(--ty-card-border, var(--ty-color-border-secondary)); +} +``` + +### Legacy to v2 alias examples +- `--ty-card-border-radius` -> `--ty-card-radius` +- `--ty-shadow-card` -> `--ty-card-shadow` + +## SCSS Migration Patterns + +### 1. Replace legacy component aliases gradually +Do not rename class selectors in v2. Migrate variables first. + +```scss +.ty-btn { + border-radius: var(--ty-button-radius, var(--ty-border-radius)); +} +``` + +This allows: +- new token source to emit `--ty-button-*` +- old themes to keep working through build-emitted alias vars +- SCSS to move once without breaking user overrides + +### 2. Prefer component token + semantic fallback + +```scss +.ty-card { + background: var(--ty-card-bg, var(--ty-color-bg-container)); +} +``` + +Avoid direct semantic usage when the property could become component-specific later. + +### 3. Keep structural literals only when they are not part of the design language + +Allowed: + +```scss +.ty-input { + width: 100%; + position: relative; +} +``` + +Not allowed: + +```scss +.ty-input__input { + padding: 0 12px; + border-radius: 6px; + background: #fff; +} +``` + +### 4. Tokenize state, not selector mechanics +The state shape stays in SCSS; the state values move to tokens. + +```scss +.ty-btn_primary:hover { + background: var(--ty-button-bg-primary-hover, var(--ty-color-primary-hover)); +} +``` + +### 5. Slot names should stay flat +Prefer: +- `card.header-padding` +- `card.body-padding` +- `input.addon-bg` + +Avoid: +- `card.header.surface.padding` +- `input.affix.prefix.margin` + +## Initial Migration Order +1. Add registry entries and alias map for `button`, `input`, `card` +2. Update SCSS to prefer `--ty-button-*`, `--ty-input-*`, `--ty-card-*` +3. Keep legacy `--ty-btn-*` fallbacks for one compatibility cycle +4. Update Theme Studio to edit only v2 names +5. Mark legacy names as deprecated in docs and generated registry metadata diff --git a/packages/react/SCSS_AUTHORING.md b/packages/react/SCSS_AUTHORING.md new file mode 100644 index 00000000..f8be00c4 --- /dev/null +++ b/packages/react/SCSS_AUTHORING.md @@ -0,0 +1,84 @@ +# SCSS Authoring Spec + +## Purpose +This spec defines how component styles consume runtime theme tokens in `@tiny-design/react`. The goal is to keep existing class names and SCSS ergonomics while making every visual decision themeable through CSS custom properties. + +## Rules +1. SCSS defines structure, state, and selector relationships only. +2. Visual values must come from `var(--ty-*)` tokens. +3. New hard-coded colors, radii, shadows, font sizes, spacing, and motion values are not allowed. +4. Component tokens use dot notation with kebab-case segments in source registries, for example `button.bg.primary-hover`. +5. CSS variables stay on the existing `--ty-*` prefix for v2 compatibility. + +## Token Priority +Use this fallback chain consistently: + +1. Component token +2. Semantic token +3. Native CSS fallback only when no semantic token exists + +Preferred pattern: + +```scss +.ty-card { + border-radius: var(--ty-card-radius, var(--ty-border-radius)); + background: var(--ty-card-bg, var(--ty-color-bg-container)); + color: var(--ty-card-text, var(--ty-color-text)); +} +``` + +Direct semantic usage is only correct when the property should never diverge by component: + +```scss +.ty-typography { + font-family: var(--ty-font-family); +} +``` + +## Naming Conventions +- Semantic CSS vars: `--ty-color-primary`, `--ty-border-radius`, `--ty-font-size-base` +- Component CSS vars: `--ty-button-bg-primary`, `--ty-button-bg-primary-hover`, `--ty-card-header-padding` +- Avoid aliases like `btn` or `picker` for new token names. Use full component names. + +## Allowed Hard-coded Values +Only structural values may be hard-coded when tokenizing them would not improve theming: + +- `display`, `position`, `flex`, `overflow`, `white-space`, `pointer-events` +- Layout-only percentages like `width: 100%` +- Browser-specific resets such as `outline: 0` or `appearance: none` +- Rare intrinsic calculations like `inset: -1px` when tied to border mechanics + +If a value affects brand, density, readability, affordance, or perceived motion, it must be tokenized. + +## Examples +Preferred: + +```scss +.ty-button { + height: var(--ty-button-height-md, var(--ty-height-md)); + padding-inline: var(--ty-button-padding-inline-md, var(--ty-spacing-4)); + border-radius: var(--ty-button-radius, var(--ty-border-radius)); + background: var(--ty-button-bg-primary, var(--ty-color-primary)); + box-shadow: var(--ty-button-shadow-focus, var(--ty-shadow-focus)); +} +``` + +Avoid: + +```scss +.ty-button { + height: 40px; + padding-inline: 16px; + border-radius: 6px; + background: #6e41bf; +} +``` + +## Migration Checklist +When editing an existing component style file: + +1. Replace visual literals with `var(--ty-...)`. +2. Prefer component token plus semantic fallback for component-specific properties. +3. Keep selectors and class names stable unless a separate API change is intended. +4. Do not add new theme logic to SCSS maps; add tokens to the JSON source and registry instead. +5. Verify the component still renders correctly with default and dark themes. diff --git a/packages/react/scripts/build-styles.js b/packages/react/scripts/build-styles.js index 1b3add6e..68e607df 100644 --- a/packages/react/scripts/build-styles.js +++ b/packages/react/scripts/build-styles.js @@ -19,15 +19,15 @@ async function processWithPostcss(css) { return result.css; } -// 1. Base CSS: copy pre-built base.css from @tiny-design/tokens +// 1. Base CSS: copy the v2 runtime theme bundle from @tiny-design/tokens function copyBaseCss() { - const src = require.resolve('@tiny-design/tokens/css/base.css'); + const src = require.resolve('@tiny-design/tokens/dist/css/base.css'); for (const dir of [ES_DIR, LIB_DIR]) { const outDir = path.join(dir, 'style'); mkdirp(outDir); fs.copyFileSync(src, path.join(outDir, 'base.css')); } - console.log(' es/style/base.css + lib/style/base.css (copied from @tiny-design/tokens)'); + console.log(' es/style/base.css + lib/style/base.css (copied from @tiny-design/tokens v2 base theme CSS)'); } // 2. Per-component CSS: compile each component's _index.scss partial diff --git a/packages/react/src/button/style/_index.scss b/packages/react/src/button/style/_index.scss index 09dcb6e5..f3eec9d9 100755 --- a/packages/react/src/button/style/_index.scss +++ b/packages/react/src/button/style/_index.scss @@ -6,7 +6,7 @@ $btn-prefix: #{$prefix}-btn; .#{$btn-prefix} { box-sizing: border-box; - border: 1px solid var(--ty-color-border-btn-default); + border: 1px solid var(--ty-button-border-default, var(--ty-color-border-btn-default)); outline: none; letter-spacing: 0; text-align: center; @@ -14,14 +14,14 @@ $btn-prefix: #{$prefix}-btn; display: inline-flex; justify-content: center; align-items: center; - min-width: var(--ty-btn-min-width); + min-width: var(--ty-button-min-width, auto); vertical-align: middle; text-decoration: none; white-space: nowrap; user-select: none; - border-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); + border-radius: var(--ty-button-radius, var(--ty-border-radius)); transition: $btn-transition; - line-height: var(--ty-btn-line-height, var(--ty-line-height-base)); + line-height: var(--ty-button-line-height, var(--ty-line-height-base)); &__loader { @include loader; @@ -31,7 +31,7 @@ $btn-prefix: #{$prefix}-btn; display: inline-block; flex-shrink: 0; pointer-events: none; - line-height: var(--ty-btn-line-height, var(--ty-line-height-base)); + line-height: var(--ty-button-line-height, var(--ty-line-height-base)); vertical-align: middle; & + span { @@ -48,28 +48,56 @@ $btn-prefix: #{$prefix}-btn; // Types &_default { @include button-style( - var(--ty-btn-default-color), var(--ty-btn-default-bg), var(--ty-btn-default-border), - var(--ty-btn-default-hover-bg), var(--ty-btn-default-hover-border), var(--ty-btn-default-hover-color), - var(--ty-btn-default-active-bg), var(--ty-btn-default-active-border), var(--ty-btn-default-active-color) + var(--ty-button-text-default, var(--ty-color-text)), + var(--ty-button-bg-default, var(--ty-color-bg-container)), + var(--ty-button-border-default, var(--ty-color-border-btn-default)), + var(--ty-button-bg-default-hover, var(--ty-color-bg-container)), + var(--ty-button-border-default-hover, var(--ty-color-primary)), + var(--ty-button-text-default-hover, var(--ty-color-primary)), + var(--ty-button-bg-default-active, var(--ty-color-fill)), + var(--ty-button-border-default-active, var(--ty-color-primary)), + var(--ty-button-text-default-active, var(--ty-color-primary)) ); } &_primary { - @include button-style(#fff, var(--ty-color-primary), var(--ty-color-primary), - var(--ty-color-primary-hover), var(--ty-color-primary-hover), #fff, - var(--ty-color-primary-active), var(--ty-color-primary-active), #fff); + @include button-style( + var(--ty-button-text-primary, #fff), + var(--ty-button-bg-primary, var(--ty-color-primary)), + var(--ty-color-primary), + var(--ty-button-bg-primary-hover, var(--ty-color-primary-hover)), + var(--ty-color-primary-hover), + var(--ty-button-text-primary, #fff), + var(--ty-button-bg-primary-active, var(--ty-color-primary-active)), + var(--ty-color-primary-active), + var(--ty-button-text-primary, #fff) + ); } &_outline { - @include button-style(var(--ty-color-primary), var(--ty-btn-default-bg), var(--ty-color-primary), - var(--ty-btn-outline-hover-bg), var(--ty-color-primary-hover), var(--ty-color-primary), - var(--ty-btn-outline-active-bg)); + @include button-style( + var(--ty-color-primary), + var(--ty-button-bg-default, var(--ty-color-bg-container)), + var(--ty-color-primary), + var(--ty-color-primary-bg, var(--ty-button-bg-default, var(--ty-color-bg-container))), + var(--ty-color-primary-hover), + var(--ty-color-primary), + var(--ty-color-primary-bg-hover, var(--ty-color-fill)) + ); } &_ghost { - @include button-style(var(--ty-color-primary), transparent, transparent, - var(--ty-btn-ghost-hover-bg), transparent, var(--ty-color-primary), - var(--ty-btn-ghost-active-bg), transparent, var(--ty-color-primary)); + @include button-style( + var(--ty-color-primary), + transparent, + transparent, + var(--ty-color-primary-bg, transparent), + transparent, + var(--ty-color-primary), + var(--ty-color-primary-bg-hover, var(--ty-color-fill)), + transparent, + var(--ty-color-primary) + ); &:disabled { border: 0; @@ -84,7 +112,7 @@ $btn-prefix: #{$prefix}-btn; } &:disabled { - color: var(--ty-btn-link-disabled-color); + color: var(--ty-button-text-link-disabled, var(--ty-color-text-quaternary)); background-color: transparent; border-color: transparent; text-decoration-line: none; @@ -117,15 +145,27 @@ $btn-prefix: #{$prefix}-btn; // Sizes &_sm { - @include btn-size($btn-padding-sm, var(--ty-btn-font-size-sm, var(--ty-font-size-sm)), var(--ty-btn-height-sm, var(--ty-height-sm))); + @include btn-size( + var(--ty-button-padding-inline-sm, $btn-padding-sm), + var(--ty-button-font-size-sm, var(--ty-font-size-sm)), + var(--ty-button-height-sm, var(--ty-height-sm)) + ); } &_md { - @include btn-size($btn-padding-md, var(--ty-btn-font-size, var(--ty-font-size-base)), var(--ty-btn-height-md, var(--ty-height-md))); + @include btn-size( + var(--ty-button-padding-inline-md, $btn-padding-md), + var(--ty-button-font-size-md, var(--ty-font-size-base)), + var(--ty-button-height-md, var(--ty-height-md)) + ); } &_lg { - @include btn-size($btn-padding-lg, var(--ty-btn-font-size-lg, var(--ty-font-size-lg)), var(--ty-btn-height-lg, var(--ty-height-lg))); + @include btn-size( + var(--ty-button-padding-inline-lg, $btn-padding-lg), + var(--ty-button-font-size-lg, var(--ty-font-size-lg)), + var(--ty-button-height-lg, var(--ty-height-lg)) + ); } &_block { @@ -137,7 +177,7 @@ $btn-prefix: #{$prefix}-btn; } &_round { - border-radius: var(--ty-height-lg); + border-radius: var(--ty-button-round-radius, var(--ty-height-lg)); } &_loading { @@ -150,9 +190,9 @@ $btn-prefix: #{$prefix}-btn; inset: -1px; z-index: 1; display: block; - background: var(--ty-btn-loading-bg); + background: var(--ty-button-loading-bg, var(--ty-color-bg-container)); border-radius: inherit; - opacity: $btn-loading-opacity; + opacity: var(--ty-button-loading-opacity, $btn-loading-opacity); transition: opacity .2s; } } @@ -166,7 +206,7 @@ $btn-prefix: #{$prefix}-btn; display: inline-block; & + .#{$btn-prefix}-group { - margin-left: var(--ty-btn-group-gap); + margin-left: var(--ty-button-group-gap, 0); } .#{$btn-prefix} { @@ -183,26 +223,26 @@ $btn-prefix: #{$prefix}-btn; } &:first-child { - border-top-left-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); - border-bottom-left-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); + border-top-left-radius: var(--ty-button-radius, var(--ty-border-radius)); + border-bottom-left-radius: var(--ty-button-radius, var(--ty-border-radius)); } &:last-child { - border-top-right-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); - border-bottom-right-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); + border-top-right-radius: var(--ty-button-radius, var(--ty-border-radius)); + border-bottom-right-radius: var(--ty-button-radius, var(--ty-border-radius)); } } &_round { .#{$btn-prefix} { &:first-child { - border-top-left-radius: var(--ty-btn-round-radius); - border-bottom-left-radius: var(--ty-btn-round-radius); + border-top-left-radius: var(--ty-button-round-radius, var(--ty-height-lg)); + border-bottom-left-radius: var(--ty-button-round-radius, var(--ty-height-lg)); } &:last-child { - border-top-right-radius: var(--ty-btn-round-radius); - border-bottom-right-radius: var(--ty-btn-round-radius); + border-top-right-radius: var(--ty-button-round-radius, var(--ty-height-lg)); + border-bottom-right-radius: var(--ty-button-round-radius, var(--ty-height-lg)); } } } @@ -214,7 +254,7 @@ $btn-prefix: #{$prefix}-btn; &_danger { .#{$btn-prefix} { &:not(:first-child) { - border-left-color: var(--ty-btn-group-divider-color); + border-left-color: var(--ty-button-group-divider-color, var(--ty-color-border-secondary)); } } } diff --git a/packages/react/src/button/style/_mixin.scss b/packages/react/src/button/style/_mixin.scss index 1e5a6a38..499d5106 100755 --- a/packages/react/src/button/style/_mixin.scss +++ b/packages/react/src/button/style/_mixin.scss @@ -12,29 +12,37 @@ color: $color; background: $background; border-color: $border; + &:hover { color: $hover-color; + @if $hover-background { background: $hover-background; } + @if $hover-border { border-color: $hover-border; } } &:focus { color: $hover-color; + @if $hover-background { background: $hover-background; } + @if $hover-border { border-color: $hover-border; } + z-index: 1; } &:active { color: $active-color; + @if $active-background { background: $active-background; } + @if $active-border { border-color: $active-border; } } &:disabled { - color: var(--ty-btn-disabled-color); - background-color: var(--ty-btn-disabled-bg); - border-color: var(--ty-btn-disabled-border); + color: var(--ty-button-text-disabled, var(--ty-color-text-quaternary)); + background-color: var(--ty-button-bg-disabled, var(--ty-color-bg-disabled)); + border-color: var(--ty-button-border-disabled, var(--ty-color-border)); } } diff --git a/packages/react/src/card/style/_index.scss b/packages/react/src/card/style/_index.scss index 6f99f8ac..7e31a54e 100644 --- a/packages/react/src/card/style/_index.scss +++ b/packages/react/src/card/style/_index.scss @@ -5,24 +5,24 @@ box-sizing: border-box; padding: 0; margin: 0; - border-radius: var(--ty-card-border-radius, var(--ty-border-radius)); + border-radius: var(--ty-card-radius, var(--ty-border-radius)); transition: all 0.3s; - background-color: var(--ty-card-bg); + background-color: var(--ty-card-bg, var(--ty-color-bg-container)); & > img:first-child { - border-radius: var(--ty-card-border-radius, var(--ty-border-radius)) var(--ty-card-border-radius, var(--ty-border-radius)) 0 0; + border-radius: var(--ty-card-radius, var(--ty-border-radius)) var(--ty-card-radius, var(--ty-border-radius)) 0 0; } &_outlined { - border: 1px solid var(--ty-card-border); + border: 1px solid var(--ty-card-border, var(--ty-color-border-secondary)); } &_elevated { - box-shadow: var(--ty-shadow-card); + box-shadow: var(--ty-card-shadow, var(--ty-shadow-card)); } &_filled { - background-color: var(--ty-color-fill); + background-color: var(--ty-card-bg-filled, var(--ty-color-fill)); } &_hoverable { @@ -41,22 +41,22 @@ box-sizing: border-box; display: flex; justify-content: space-between; - padding: $card-header-padding; - color: var(--ty-card-header-color); - font-weight: var(--ty-card-header-font-weight); - font-size: var(--ty-card-header-font-size); + padding: var(--ty-card-header-padding, $card-header-padding); + color: var(--ty-card-header-color, var(--ty-color-text-heading)); + font-weight: var(--ty-card-header-font-weight, var(--ty-font-weight-medium)); + font-size: var(--ty-card-header-font-size, var(--ty-font-size-base)); background: transparent; - border-bottom: 1px solid var(--ty-card-border); - border-radius: var(--ty-card-border-radius, var(--ty-border-radius)) var(--ty-card-border-radius, var(--ty-border-radius)) 0 0; + border-bottom: 1px solid var(--ty-card-border, var(--ty-color-border-secondary)); + border-radius: var(--ty-card-radius, var(--ty-border-radius)) var(--ty-card-radius, var(--ty-border-radius)) 0 0; } &__body { box-sizing: border-box; - padding: $card-body-padding; + padding: var(--ty-card-body-padding, $card-body-padding); } &__footer { box-sizing: border-box; - padding: $card-footer-padding; + padding: var(--ty-card-footer-padding, $card-footer-padding); } } diff --git a/packages/react/src/config-provider/index.tsx b/packages/react/src/config-provider/index.tsx index e1f96c63..ad9a0c65 100644 --- a/packages/react/src/config-provider/index.tsx +++ b/packages/react/src/config-provider/index.tsx @@ -2,7 +2,7 @@ import ConfigProvider from './config-provider'; import { useConfig } from './config-context'; export type * from './types'; -export type { ThemeConfig } from './token-utils'; +export type { ThemeConfig, ThemeDocument, ThemeDocumentTokens, ThemeTokenValue } from './token-utils'; export type { StaticConfig } from './static-config'; export default ConfigProvider; export { useConfig }; diff --git a/packages/react/src/config-provider/token-utils.ts b/packages/react/src/config-provider/token-utils.ts index 313107a1..8423bd31 100644 --- a/packages/react/src/config-provider/token-utils.ts +++ b/packages/react/src/config-provider/token-utils.ts @@ -69,25 +69,43 @@ function camelToKebab(str: string): string { return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); } +function dotToKebab(str: string): string { + return str.replace(/\./g, '-'); +} + +export type ThemeTokenValue = string | number; + +export interface ThemeDocumentMeta { + id?: string; + name?: string; + author?: string; + schemaVersion?: number; +} + +export interface ThemeDocumentTokens { + semantic?: Record; + components?: Record; +} + +export interface ThemeDocument { + meta?: ThemeDocumentMeta; + mode: 'light' | 'dark' | 'system'; + extends?: string; + tokens?: ThemeDocumentTokens; +} + export interface ThemeConfig { mode?: 'light' | 'dark' | 'system'; - token?: Record; - components?: Record>; + token?: Record; + components?: Record>; + tokens?: ThemeDocumentTokens; + extends?: string; + meta?: ThemeDocumentMeta; } -/** - * Builds a CSSProperties object from a ThemeConfig. - * - * - `token` entries: `colorPrimary: '#1890ff'` → `'--ty-color-primary': '#1890ff'` - * - `components.Button` entries: `borderRadius: '20px'` → `'--ty-btn-border-radius': '20px'` - */ -export function buildCssVars( - theme: ThemeConfig -): React.CSSProperties | undefined { - const { token, components } = theme; - if (!token && !components) return undefined; - +function buildLegacyCssVars(theme: ThemeConfig): Record { const vars: Record = {}; + const { token, components } = theme; if (token) { for (const [key, value] of Object.entries(token)) { @@ -105,6 +123,42 @@ export function buildCssVars( } } + return vars; +} + +function buildDocumentCssVars(tokens?: ThemeDocumentTokens): Record { + const vars: Record = {}; + if (!tokens) return vars; + + if (tokens.semantic) { + for (const [key, value] of Object.entries(tokens.semantic)) { + vars[`--ty-${key}`] = String(value); + } + } + + if (tokens.components) { + for (const [key, value] of Object.entries(tokens.components)) { + vars[`--ty-${dotToKebab(key)}`] = String(value); + } + } + + return vars; +} + +/** + * Builds a CSSProperties object from a ThemeConfig. + * + * - `token` entries: `colorPrimary: '#1890ff'` → `'--ty-color-primary': '#1890ff'` + * - `components.Button` entries: `borderRadius: '20px'` → `'--ty-btn-border-radius': '20px'` + */ +export function buildCssVars( + theme: ThemeConfig +): React.CSSProperties | undefined { + const vars = { + ...buildLegacyCssVars(theme), + ...buildDocumentCssVars(theme.tokens), + }; + if (Object.keys(vars).length === 0) return undefined; return vars as React.CSSProperties; } diff --git a/packages/react/src/input/style/_index.scss b/packages/react/src/input/style/_index.scss index 0cba7dae..c56c993a 100755 --- a/packages/react/src/input/style/_index.scss +++ b/packages/react/src/input/style/_index.scss @@ -30,9 +30,9 @@ &__clear-btn { display: inline-block; - color: var(--ty-color-text-quaternary); - width: var(--ty-input-clear-size); - height: var(--ty-input-clear-size); + color: var(--ty-input-clear-color, var(--ty-color-text-quaternary)); + width: var(--ty-input-clear-size, 1em); + height: var(--ty-input-clear-size, 1em); position: relative; top: 2px; cursor: pointer; @@ -55,7 +55,7 @@ &_md { .#{$prefix}-input { &__input { - font-size: var(--ty-input-font-size, var(--ty-font-size-base)); + font-size: var(--ty-input-font-size-md, var(--ty-font-size-base)); height: var(--ty-input-height-md, var(--ty-height-md)); line-height: var(--ty-input-height-md, var(--ty-height-md)); } @@ -134,21 +134,21 @@ } .#{$prefix}-input-group-addon { - background-color: var(--ty-input-addon-bg); + background-color: var(--ty-input-addon-bg, var(--ty-color-fill)); border: 1px solid var(--ty-input-border); box-sizing: border-box; text-align: center; line-height: 1; - border-radius: var(--ty-input-border-radius, var(--ty-border-radius)); + border-radius: var(--ty-input-radius, var(--ty-border-radius)); color: var(--ty-input-color, var(--ty-color-text)); - padding: var(--ty-input-addon-padding); + padding: var(--ty-input-addon-padding, var(--ty-spacing-3)); &_sm { font-size: var(--ty-input-font-size-sm, var(--ty-font-size-sm)); } &_md { - font-size: var(--ty-input-font-size, var(--ty-font-size-base)); + font-size: var(--ty-input-font-size-md, var(--ty-font-size-base)); } &_lg { @@ -171,7 +171,7 @@ border-radius: 0; border-left: 0; border-right: 0; - padding: var(--ty-input-addon-padding); + padding: var(--ty-input-addon-padding, var(--ty-spacing-3)); } &_no-border { diff --git a/packages/react/src/input/style/_mixin.scss b/packages/react/src/input/style/_mixin.scss index 1cba1049..81f521f8 100755 --- a/packages/react/src/input/style/_mixin.scss +++ b/packages/react/src/input/style/_mixin.scss @@ -9,28 +9,28 @@ border: 1px solid var(--ty-input-border); transition: all 0.3s; outline: 0; - border-radius: var(--ty-input-border-radius, var(--ty-border-radius)); - font-size: var(--ty-input-font-size, var(--ty-font-size-base)); - background-color: var(--ty-input-bg); + border-radius: var(--ty-input-radius, var(--ty-border-radius)); + font-size: var(--ty-input-font-size-md, var(--ty-font-size-base)); + background-color: var(--ty-input-bg, var(--ty-color-bg-container)); &:hover { - border-color: var(--ty-color-primary); + border-color: var(--ty-input-border-hover, var(--ty-color-primary)); } &:focus { - border-color: var(--ty-input-focus-border); - box-shadow: var(--ty-input-focus-shadow); + border-color: var(--ty-input-border-focus, var(--ty-color-primary)); + box-shadow: var(--ty-input-shadow-focus, var(--ty-shadow-focus)); } &::placeholder { - color: var(--ty-color-text-placeholder); + color: var(--ty-input-placeholder, var(--ty-color-text-placeholder)); } } @mixin input-default-disabled { cursor: not-allowed; - background-color: var(--ty-input-disabled-bg); - color: var(--ty-input-disabled-color); + background-color: var(--ty-input-bg-disabled, var(--ty-color-bg-disabled)); + color: var(--ty-input-text-disabled, var(--ty-color-text-quaternary)); &:hover { border-color: var(--ty-input-border); diff --git a/packages/tokens/.gitignore b/packages/tokens/.gitignore index 9798131c..d232d154 100644 --- a/packages/tokens/.gitignore +++ b/packages/tokens/.gitignore @@ -1 +1,2 @@ css/ +dist/ diff --git a/packages/tokens/ALIAS_MAP_SPEC.md b/packages/tokens/ALIAS_MAP_SPEC.md new file mode 100644 index 00000000..1277d9ae --- /dev/null +++ b/packages/tokens/ALIAS_MAP_SPEC.md @@ -0,0 +1,113 @@ +# Alias Map Spec + +## Purpose +The alias map defines backward compatibility between legacy CSS variables and v2 primary tokens. It exists to let component SCSS migrate to clearer names without immediately breaking user themes or docs. + +This map is compatibility metadata. It is not the source of truth for token values. + +## Output Location +The build step should generate: + +- `packages/tokens/dist/alias-map.json` + +## Top-level Shape + +```json +{ + "version": 1, + "entries": [] +} +``` + +## Entry Shape + +```json +{ + "aliasCssVar": "--ty-btn-default-bg", + "targetKey": "button.bg.default", + "targetCssVar": "--ty-button-bg-default", + "status": "active", + "removeAfter": 3, + "notes": "Temporary bridge from btn naming to button naming." +} +``` + +## Required Fields +- `aliasCssVar` +- `targetKey` +- `targetCssVar` +- `status` + +## Field Definitions +- `aliasCssVar` + Legacy CSS variable consumed by older SCSS or user themes. +- `targetKey` + v2 primary token key from the registry. +- `targetCssVar` + v2 primary CSS variable from the registry. +- `status` + One of: `active`, `deprecated`, `removed` +- `removeAfter` + Optional compatibility milestone or major version after which the alias may be removed. +- `notes` + Short migration guidance. + +## Rules +1. One alias maps to exactly one primary token. +2. Aliases must never form chains. + Correct: `--ty-btn-default-bg` -> `--ty-button-bg-default` + Incorrect: `--ty-old-btn-bg` -> `--ty-btn-default-bg` -> `--ty-button-bg-default` +3. New v2 tokens must not be introduced as aliases. +4. Alias entries must also be declared in the owning registry token's `aliases` field. +5. Theme Studio must not expose aliases as editable tokens. + +## Runtime Expectations +v2 compatibility should be handled in the build layer. The recommended implementation is to emit both primary vars and alias vars: + +```css +:root { + --ty-button-bg-default: #fff; + --ty-btn-default-bg: var(--ty-button-bg-default); +} +``` + +SCSS should reference only primary v2 names plus semantic fallbacks. SCSS should not reference alias vars directly, which avoids circular fallback chains and keeps authoring rules simple. + +## Recommended Initial Alias Coverage +- `--ty-btn-*` -> `--ty-button-*` +- `--ty-card-border-radius` -> `--ty-card-radius` +- `--ty-shadow-card` -> `--ty-card-shadow` +- `--ty-input-border-radius` -> `--ty-input-radius` +- `--ty-input-focus-border` -> `--ty-input-border-focus` +- `--ty-input-focus-shadow` -> `--ty-input-shadow-focus` + +## Example Entries + +```json +[ + { + "aliasCssVar": "--ty-btn-border-radius", + "targetKey": "button.radius", + "targetCssVar": "--ty-button-radius", + "status": "active", + "removeAfter": 3, + "notes": "Legacy btn prefix." + }, + { + "aliasCssVar": "--ty-input-focus-shadow", + "targetKey": "input.shadow.focus", + "targetCssVar": "--ty-input-shadow-focus", + "status": "active", + "removeAfter": 3, + "notes": "Normalized focus token naming." + } +] +``` + +## Removal Policy +- A deprecated alias must remain resolvable for at least one full compatibility cycle. +- Removal requires: + - registry status change + - docs update + - migration note in changelog + - Theme Studio import migration if community themes may still reference it diff --git a/packages/tokens/REGISTRY_SPEC.md b/packages/tokens/REGISTRY_SPEC.md new file mode 100644 index 00000000..9dd16c18 --- /dev/null +++ b/packages/tokens/REGISTRY_SPEC.md @@ -0,0 +1,154 @@ +# Token Registry Spec + +## Purpose +The token registry is the canonical machine-readable index of all supported v2 tokens. It is generated from JSON token sources and consumed by: + +- Theme Studio +- theme validation +- docs generation +- migration tooling +- compatibility alias resolution + +The registry is metadata, not a theme document. It describes what tokens exist, how they map to CSS variables, and what fallback behavior they expect. + +## Output Location +The build step should generate: + +- `packages/tokens/dist/registry.json` +- `packages/tokens/dist/registry.d.ts` + +## Top-level Shape + +```json +{ + "version": 1, + "generatedAt": "2026-04-06T10:00:00.000Z", + "tokens": [] +} +``` + +## Token Entry Shape + +```json +{ + "key": "button.bg.primary", + "cssVar": "--ty-button-bg-primary", + "category": "component", + "component": "button", + "type": "color", + "group": "Button", + "description": "Primary button background color.", + "source": "source/components/button.json", + "defaultValue": "{color-primary}", + "fallback": "--ty-color-primary", + "status": "active", + "aliases": ["--ty-btn-default-bg"] +} +``` + +## Required Fields +- `key` +- `cssVar` +- `category` +- `type` +- `source` +- `status` + +## Field Definitions +- `key` + Stable token id in dot notation with kebab-case segments. + Examples: `color-primary`, `button.bg.primary`, `card.header-padding` +- `cssVar` + Public runtime CSS variable name. +- `category` + One of: `primitive`, `semantic`, `component` +- `component` + Required when `category` is `component`; omitted otherwise. +- `type` + One of: `color`, `dimension`, `number`, `font-family`, `font-weight`, `line-height`, `shadow`, `duration`, `easing`, `transition`, `string` +- `group` + Human-facing display group for docs and Theme Studio. +- `description` + Short explanation of what the token controls. +- `source` + Relative path to the token source file. +- `defaultValue` + Unresolved default value from the source token document. +- `fallback` + Recommended authored-SCSS fallback target. This field is guidance metadata for component authors and docs tooling; it does not mean the build step will emit an automatic fallback chain in generated CSS. +- `status` + One of: `active`, `deprecated`, `internal` +- `aliases` + Legacy CSS vars or legacy token keys that map to this token. + +## Naming Rules +- `key` must match the theme schema token key pattern. +- `cssVar` must always use kebab-case. +- `component` names must use full nouns such as `button`, `input`, `card`. +- New entries must not introduce aliases like `btn`, `picker`, or `kbd` as primary names. + +## Fallback Rules +- Primitive tokens should not appear in authored component SCSS. +- Semantic tokens usually have no registry fallback. +- Component tokens should include the semantic fallback they are expected to use in authored SCSS. + +Examples: +- `button.bg.primary` -> fallback `--ty-color-primary` +- `button.radius` -> fallback `--ty-border-radius` +- `card.bg` -> fallback `--ty-color-bg-container` + +## Status Rules +- `active` + Visible in Theme Studio and allowed in theme documents. +- `deprecated` + Still resolved, but hidden by default in editing UIs and marked for migration. +- `internal` + Not allowed in user-authored themes. + +## Alias Handling +- Aliases exist for compatibility only. +- The registry entry owns its aliases. +- Theme Studio should expose only the primary `key`. +- Build output may choose to emit both primary vars and alias vars for one compatibility cycle. + +## Example Entries + +```json +[ + { + "key": "color-primary", + "cssVar": "--ty-color-primary", + "category": "semantic", + "type": "color", + "group": "Colors", + "description": "Primary brand color.", + "source": "source/semantic/colors.json", + "defaultValue": "{color.brand.500}", + "status": "active", + "aliases": [] + }, + { + "key": "button.radius", + "cssVar": "--ty-button-radius", + "category": "component", + "component": "button", + "type": "dimension", + "group": "Button", + "description": "Button border radius.", + "source": "source/components/button.json", + "defaultValue": "{border-radius}", + "fallback": "--ty-border-radius", + "status": "active", + "aliases": ["--ty-btn-border-radius"] + } +] +``` + +## Validation Rules +Build should fail when: + +1. Two entries share the same `key` +2. Two entries share the same `cssVar` +3. A `component` token is missing its `component` +4. A `deprecated` alias points to multiple active tokens +5. A `fallback` points to a CSS var not present in the registry or approved semantic baseline diff --git a/packages/tokens/build/build-v2.js b/packages/tokens/build/build-v2.js new file mode 100644 index 00000000..1a04bee9 --- /dev/null +++ b/packages/tokens/build/build-v2.js @@ -0,0 +1,355 @@ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const SOURCE_DIR = path.join(ROOT, 'source'); +const DIST_DIR = path.join(ROOT, 'dist'); +const DIST_CSS_DIR = path.join(DIST_DIR, 'css'); +const REGISTRY_DTS_PATH = path.join(DIST_DIR, 'registry.d.ts'); +const ALIAS_MAP_DTS_PATH = path.join(DIST_DIR, 'alias-map.d.ts'); + +const SEMANTIC_DIR = path.join(SOURCE_DIR, 'semantic'); +const COMPONENT_DIR = path.join(SOURCE_DIR, 'components'); +const THEMES_DIR = path.join(SOURCE_DIR, 'themes'); + +function mkdirp(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n'); +} + +function listJsonFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => path.join(dir, name)); +} + +function tokenKeyToCssVar(key) { + return `--ty-${key.replace(/\./g, '-')}`; +} + +function getComponentName(key) { + return key.includes('.') ? key.split('.')[0] : null; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function resolveTokenValue(rawValue, tokenMap, stack = []) { + if (typeof rawValue !== 'string') { + return String(rawValue); + } + + const match = rawValue.match(/^\{([^}]+)\}$/); + if (!match) { + return rawValue; + } + + const refKey = match[1]; + if (stack.includes(refKey)) { + throw new Error(`Circular token reference detected: ${[...stack, refKey].join(' -> ')}`); + } + + const refToken = tokenMap.get(refKey); + if (!refToken) { + throw new Error(`Unresolved token reference: ${refKey}`); + } + + return resolveTokenValue(refToken.$value, tokenMap, [...stack, refKey]); +} + +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, + })); + }); +} + +function validateTokens(tokens) { + const keys = new Set(); + const cssVars = new Set(); + const aliasVars = 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}`); + } + + const aliases = token.aliases || []; + for (const aliasCssVar of aliases) { + assert( + aliasCssVar !== cssVar, + `Alias css var matches primary css var for token: ${token.key}` + ); + assert( + !cssVars.has(aliasCssVar), + `Alias css var collides with primary css var: ${aliasCssVar}` + ); + assert(!aliasVars.has(aliasCssVar), `Duplicate alias css var: ${aliasCssVar}`); + aliasVars.add(aliasCssVar); + } + } +} + +function loadThemes() { + return listJsonFiles(THEMES_DIR).map((filePath) => { + const fileData = readJson(filePath); + return { + source: path.relative(ROOT, filePath), + ...fileData, + }; + }); +} + +function buildRegistry(tokens) { + return { + version: 1, + generatedAt: new Date().toISOString(), + tokens: tokens.map((token) => ({ + key: token.key, + cssVar: tokenKeyToCssVar(token.key), + category: token.category, + ...(token.component ? { component: token.component } : {}), + type: token.$type, + group: token.component + ? token.component.charAt(0).toUpperCase() + token.component.slice(1) + : 'Semantic', + description: token.description || '', + source: token.source, + defaultValue: token.$value, + ...(token.fallback ? { fallback: token.fallback } : {}), + status: token.status || 'active', + aliases: token.aliases || [], + })), + }; +} + +function buildAliasMap(tokens) { + const entries = []; + + for (const token of tokens) { + const aliases = token.aliases || []; + for (const aliasCssVar of aliases) { + entries.push({ + aliasCssVar, + targetKey: token.key, + targetCssVar: tokenKeyToCssVar(token.key), + status: 'active', + removeAfter: 3, + notes: `Compatibility alias for ${token.key}.`, + }); + } + } + + return { + version: 1, + entries, + }; +} + +function buildCss(tokens, resolvedValues) { + const rootLines = [':root {']; + + for (const token of tokens) { + rootLines.push(` ${tokenKeyToCssVar(token.key)}: ${resolvedValues.get(token.key)};`); + } + + const aliasLines = []; + for (const token of tokens) { + const aliases = token.aliases || []; + for (const aliasCssVar of aliases) { + aliasLines.push(` ${aliasCssVar}: var(${tokenKeyToCssVar(token.key)});`); + } + } + + if (aliasLines.length > 0) { + rootLines.push(''); + rootLines.push(...aliasLines); + } + + rootLines.push('}'); + rootLines.push(''); + + return rootLines.join('\n'); +} + +function buildThemeCss(tokens, resolvedValues, overrides, selector) { + const lines = [`${selector} {`]; + + for (const token of tokens) { + const overrideValue = overrides[token.key]; + const value = overrideValue !== undefined ? String(overrideValue) : resolvedValues.get(token.key); + lines.push(` ${tokenKeyToCssVar(token.key)}: ${value};`); + } + + const aliasLines = []; + for (const token of tokens) { + const aliases = token.aliases || []; + for (const aliasCssVar of aliases) { + aliasLines.push(` ${aliasCssVar}: var(${tokenKeyToCssVar(token.key)});`); + } + } + + if (aliasLines.length > 0) { + lines.push(''); + lines.push(...aliasLines); + } + + lines.push('}'); + lines.push(''); + + return lines.join('\n'); +} + +function buildBaseThemeCss(tokens, resolvedValues, lightTheme, darkTheme) { + const lightOverrides = (lightTheme && lightTheme.tokens && lightTheme.tokens.semantic) || {}; + const darkOverrides = (darkTheme && darkTheme.tokens && darkTheme.tokens.semantic) || {}; + + const parts = []; + parts.push(buildThemeCss(tokens, resolvedValues, lightOverrides, ':root')); + parts.push(buildThemeCss(tokens, resolvedValues, darkOverrides, "[data-tiny-theme='dark']")); + parts.push('@media (prefers-color-scheme: dark) {'); + parts.push(buildThemeCss(tokens, resolvedValues, darkOverrides, " [data-tiny-theme='system']").trimEnd()); + parts.push('}'); + parts.push(''); + + return parts.join('\n'); +} + +function buildRegistryDts() { + return `export type TokenCategory = 'primitive' | 'semantic' | 'component'; +export type TokenType = + | 'color' + | 'dimension' + | 'number' + | 'font-family' + | 'font-weight' + | 'line-height' + | 'shadow' + | 'duration' + | 'easing' + | 'transition' + | 'string'; + +export interface TokenRegistryEntry { + key: string; + cssVar: string; + category: TokenCategory; + component?: string; + type: TokenType; + group: string; + description: string; + source: string; + defaultValue: string | number; + fallback?: string; + status: 'active' | 'deprecated' | 'internal'; + aliases: string[]; +} + +export interface TokenRegistryDocument { + version: number; + generatedAt: string; + tokens: TokenRegistryEntry[]; +} +`; +} + +function buildAliasMapDts() { + return `export interface AliasMapEntry { + aliasCssVar: string; + targetKey: string; + targetCssVar: string; + status: 'active' | 'deprecated' | 'removed'; + removeAfter?: number; + notes?: string; +} + +export interface AliasMapDocument { + version: number; + entries: AliasMapEntry[]; +} +`; +} + +function buildV2() { + console.log('Building v2 token prototype...\n'); + + const semanticTokens = loadTokenFiles(SEMANTIC_DIR, 'semantic'); + const componentTokens = loadTokenFiles(COMPONENT_DIR, 'component'); + const allTokens = [...semanticTokens, ...componentTokens]; + const themes = loadThemes(); + + validateTokens(allTokens); + + const tokenMap = new Map(allTokens.map((token) => [token.key, token])); + const resolvedValues = new Map( + allTokens.map((token) => [token.key, resolveTokenValue(token.$value, tokenMap)]) + ); + + const registry = buildRegistry(allTokens); + const aliasMap = buildAliasMap(allTokens); + const lightTheme = themes.find((theme) => theme.meta && theme.meta.mode === 'light'); + const darkTheme = themes.find((theme) => theme.meta && theme.meta.mode === 'dark'); + const lightCss = buildThemeCss( + allTokens, + resolvedValues, + (lightTheme && lightTheme.tokens && lightTheme.tokens.semantic) || {}, + ':root' + ); + const darkCss = buildThemeCss( + allTokens, + resolvedValues, + (darkTheme && darkTheme.tokens && darkTheme.tokens.semantic) || {}, + "[data-tiny-theme='dark']" + ); + const baseCss = buildBaseThemeCss(allTokens, resolvedValues, lightTheme, darkTheme); + + mkdirp(DIST_CSS_DIR); + writeJson(path.join(DIST_DIR, 'registry.json'), registry); + writeJson(path.join(DIST_DIR, 'alias-map.json'), aliasMap); + fs.writeFileSync(path.join(DIST_CSS_DIR, 'v2-light.css'), lightCss); + fs.writeFileSync(path.join(DIST_CSS_DIR, 'v2-dark.css'), darkCss); + fs.writeFileSync(path.join(DIST_CSS_DIR, 'base.css'), baseCss); + fs.writeFileSync(REGISTRY_DTS_PATH, buildRegistryDts()); + fs.writeFileSync(ALIAS_MAP_DTS_PATH, buildAliasMapDts()); + + console.log(' dist/registry.json'); + console.log(' dist/alias-map.json'); + console.log(' dist/registry.d.ts'); + console.log(' dist/alias-map.d.ts'); + console.log(' dist/css/v2-light.css'); + console.log(' dist/css/v2-dark.css'); + console.log(' dist/css/base.css'); + console.log('\nV2 token prototype done.'); +} + +module.exports = { buildV2 }; + +if (require.main === module) { + buildV2(); +} diff --git a/packages/tokens/package.json b/packages/tokens/package.json index d7bb60b9..6fc7043b 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -15,18 +15,25 @@ "exports": { ".": "./css/base.css", "./css/*": "./css/*", - "./scss/*": "./scss/*" + "./scss/*": "./scss/*", + "./registry": "./dist/registry.json", + "./alias-map": "./dist/alias-map.json", + "./dist/*": "./dist/*" }, "sideEffects": [ "**/*.css" ], "files": [ "css", + "dist", "scss" ], "scripts": { "build": "node scripts/build.js", - "clean": "rimraf css" + "build:legacy": "node -e \"require('./scripts/build').buildLegacy()\"", + "build:v2": "node build/build-v2.js", + "build:v2-prototype": "node build/build-v2.js", + "clean": "rimraf css dist" }, "devDependencies": { "autoprefixer": "^10.4.4", diff --git a/packages/tokens/scripts/build.js b/packages/tokens/scripts/build.js index 0207c468..0fa42a79 100644 --- a/packages/tokens/scripts/build.js +++ b/packages/tokens/scripts/build.js @@ -3,13 +3,14 @@ const path = require('path'); const sass = require('sass'); const postcss = require('postcss'); const autoprefixer = require('autoprefixer'); +const { buildV2 } = require('../build/build-v2'); const ROOT = path.resolve(__dirname, '..'); const SCSS_DIR = path.join(ROOT, 'scss'); const CSS_DIR = path.join(ROOT, 'css'); -async function build() { - console.log('Building tokens...\n'); +async function buildLegacy() { + console.log('Building legacy tokens...\n'); // Compile scss/base.scss → css/base.css const result = sass.compile(path.join(SCSS_DIR, 'base.scss'), { @@ -22,10 +23,21 @@ async function build() { fs.writeFileSync(path.join(CSS_DIR, 'base.css'), processed.css); console.log(' css/base.css'); - console.log('\nTokens done.'); + console.log('\nLegacy tokens done.'); +} + +async function build() { + console.log('Building tokens package...\n'); + await buildLegacy(); + buildV2(); + console.log('\nTokens package done.'); } -build().catch((err) => { - console.error(err); - process.exit(1); -}); +module.exports = { buildLegacy, build }; + +if (require.main === module) { + build().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/packages/tokens/source/components/button.json b/packages/tokens/source/components/button.json new file mode 100644 index 00000000..c7f39f3b --- /dev/null +++ b/packages/tokens/source/components/button.json @@ -0,0 +1,225 @@ +{ + "button.radius": { + "$value": "{border-radius}", + "$type": "dimension", + "description": "Button border radius.", + "fallback": "--ty-border-radius", + "aliases": ["--ty-btn-border-radius"] + }, + "button.line-height": { + "$value": "{line-height-base}", + "$type": "line-height", + "description": "Button content line height.", + "fallback": "--ty-line-height-base" + }, + "button.min-width": { + "$value": "auto", + "$type": "string", + "description": "Minimum button width." + }, + "button.group-gap": { + "$value": "0", + "$type": "dimension", + "description": "Gap between adjacent button groups." + }, + "button.group-divider-color": { + "$value": "{color-border-secondary}", + "$type": "color", + "description": "Divider color between adjacent grouped solid buttons.", + "fallback": "--ty-color-border-secondary" + }, + "button.round-radius": { + "$value": "{height-lg}", + "$type": "dimension", + "description": "Pill-shaped button border radius.", + "fallback": "--ty-height-lg", + "aliases": ["--ty-btn-round-radius"] + }, + "button.loading-bg": { + "$value": "{color-bg-container}", + "$type": "color", + "description": "Overlay background during button loading state.", + "fallback": "--ty-color-bg-container", + "aliases": ["--ty-btn-loading-bg"] + }, + "button.loading-opacity": { + "$value": "0.35", + "$type": "number", + "description": "Overlay opacity during button loading state.", + "aliases": ["--ty-btn-loading-opacity"] + }, + "button.font-size-sm": { + "$value": "{font-size-sm}", + "$type": "dimension", + "description": "Small button font size.", + "fallback": "--ty-font-size-sm", + "aliases": ["--ty-btn-font-size-sm"] + }, + "button.font-size-md": { + "$value": "{font-size-base}", + "$type": "dimension", + "description": "Medium button font size.", + "fallback": "--ty-font-size-base", + "aliases": ["--ty-btn-font-size"] + }, + "button.font-size-lg": { + "$value": "{font-size-lg}", + "$type": "dimension", + "description": "Large button font size.", + "fallback": "--ty-font-size-lg", + "aliases": ["--ty-btn-font-size-lg"] + }, + "button.height.sm": { + "$value": "{height-sm}", + "$type": "dimension", + "description": "Small button height.", + "fallback": "--ty-height-sm", + "aliases": ["--ty-btn-height-sm"] + }, + "button.height.md": { + "$value": "{height-md}", + "$type": "dimension", + "description": "Medium button height.", + "fallback": "--ty-height-md", + "aliases": ["--ty-btn-height-md"] + }, + "button.height.lg": { + "$value": "{height-lg}", + "$type": "dimension", + "description": "Large button height.", + "fallback": "--ty-height-lg", + "aliases": ["--ty-btn-height-lg"] + }, + "button.padding-inline-sm": { + "$value": "{spacing-3}", + "$type": "dimension", + "description": "Small button inline padding.", + "aliases": ["--ty-btn-padding-sm"] + }, + "button.padding-inline-md": { + "$value": "{spacing-4}", + "$type": "dimension", + "description": "Medium button inline padding.", + "aliases": ["--ty-btn-padding-md"] + }, + "button.padding-inline-lg": { + "$value": "{spacing-5}", + "$type": "dimension", + "description": "Large button inline padding.", + "aliases": ["--ty-btn-padding-lg"] + }, + "button.bg.default": { + "$value": "{color-bg-container}", + "$type": "color", + "description": "Default button background.", + "fallback": "--ty-color-bg-container", + "aliases": ["--ty-btn-default-bg"] + }, + "button.bg.default-hover": { + "$value": "{color-bg-container}", + "$type": "color", + "description": "Default button hover background.", + "fallback": "--ty-color-bg-container", + "aliases": ["--ty-btn-default-hover-bg"] + }, + "button.bg.default-active": { + "$value": "{color-fill}", + "$type": "color", + "description": "Default button active background.", + "fallback": "--ty-color-fill", + "aliases": ["--ty-btn-default-active-bg"] + }, + "button.border.default": { + "$value": "{color-border-btn-default}", + "$type": "color", + "description": "Default button border color.", + "fallback": "--ty-color-border-btn-default", + "aliases": ["--ty-btn-default-border"] + }, + "button.border.default-hover": { + "$value": "{color-primary}", + "$type": "color", + "description": "Default button hover border color.", + "fallback": "--ty-color-primary", + "aliases": ["--ty-btn-default-hover-border"] + }, + "button.border.default-active": { + "$value": "{color-primary}", + "$type": "color", + "description": "Default button active border color.", + "fallback": "--ty-color-primary", + "aliases": ["--ty-btn-default-active-border"] + }, + "button.text.default": { + "$value": "{color-text}", + "$type": "color", + "description": "Default button text color.", + "fallback": "--ty-color-text", + "aliases": ["--ty-btn-default-color"] + }, + "button.text.default-hover": { + "$value": "{color-primary}", + "$type": "color", + "description": "Default button hover text color.", + "fallback": "--ty-color-primary", + "aliases": ["--ty-btn-default-hover-color"] + }, + "button.text.default-active": { + "$value": "{color-primary}", + "$type": "color", + "description": "Default button active text color.", + "fallback": "--ty-color-primary", + "aliases": ["--ty-btn-default-active-color"] + }, + "button.bg.primary": { + "$value": "{color-primary}", + "$type": "color", + "description": "Primary button background.", + "fallback": "--ty-color-primary" + }, + "button.bg.primary-hover": { + "$value": "{color-primary-hover}", + "$type": "color", + "description": "Primary button hover background.", + "fallback": "--ty-color-primary-hover" + }, + "button.bg.primary-active": { + "$value": "{color-primary-active}", + "$type": "color", + "description": "Primary button active background.", + "fallback": "--ty-color-primary-active" + }, + "button.text.primary": { + "$value": "#fff", + "$type": "color", + "description": "Primary button text color." + }, + "button.text.link-disabled": { + "$value": "{color-text-quaternary}", + "$type": "color", + "description": "Disabled link button text color.", + "fallback": "--ty-color-text-quaternary", + "aliases": ["--ty-btn-link-disabled-color"] + }, + "button.bg.disabled": { + "$value": "{color-bg-disabled}", + "$type": "color", + "description": "Disabled button background.", + "fallback": "--ty-color-bg-disabled", + "aliases": ["--ty-btn-disabled-bg"] + }, + "button.border.disabled": { + "$value": "{color-border}", + "$type": "color", + "description": "Disabled button border color.", + "fallback": "--ty-color-border", + "aliases": ["--ty-btn-disabled-border"] + }, + "button.text.disabled": { + "$value": "{color-text-quaternary}", + "$type": "color", + "description": "Disabled button text color.", + "fallback": "--ty-color-text-quaternary", + "aliases": ["--ty-btn-disabled-color"] + } +} diff --git a/packages/tokens/source/components/card.json b/packages/tokens/source/components/card.json new file mode 100644 index 00000000..b9a52eaf --- /dev/null +++ b/packages/tokens/source/components/card.json @@ -0,0 +1,71 @@ +{ + "card.radius": { + "$value": "{border-radius}", + "$type": "dimension", + "description": "Card border radius.", + "fallback": "--ty-border-radius", + "aliases": ["--ty-card-border-radius"] + }, + "card.bg": { + "$value": "{color-bg-container}", + "$type": "color", + "description": "Card background color.", + "fallback": "--ty-color-bg-container" + }, + "card.bg.filled": { + "$value": "{color-fill}", + "$type": "color", + "description": "Filled card background color.", + "fallback": "--ty-color-fill" + }, + "card.border": { + "$value": "{color-border-secondary}", + "$type": "color", + "description": "Card border color.", + "fallback": "--ty-color-border-secondary" + }, + "card.shadow": { + "$value": "{shadow-card}", + "$type": "shadow", + "description": "Elevated card shadow.", + "fallback": "--ty-shadow-card" + }, + "card.shadow.hover": { + "$value": "{shadow-card}", + "$type": "shadow", + "description": "Hoverable card shadow.", + "fallback": "--ty-shadow-card" + }, + "card.header-padding": { + "$value": "{spacing-5}", + "$type": "dimension", + "description": "Card header padding." + }, + "card.body-padding": { + "$value": "{spacing-5}", + "$type": "dimension", + "description": "Card body padding." + }, + "card.footer-padding": { + "$value": "{spacing-5}", + "$type": "dimension", + "description": "Card footer padding." + }, + "card.header-color": { + "$value": "{color-text-heading}", + "$type": "color", + "description": "Card header text color.", + "fallback": "--ty-color-text-heading" + }, + "card.header-font-size": { + "$value": "{font-size-base}", + "$type": "dimension", + "description": "Card header font size.", + "fallback": "--ty-font-size-base" + }, + "card.header-font-weight": { + "$value": "{font-weight-medium}", + "$type": "font-weight", + "description": "Card header font weight." + } +} diff --git a/packages/tokens/source/components/input.json b/packages/tokens/source/components/input.json new file mode 100644 index 00000000..f525e245 --- /dev/null +++ b/packages/tokens/source/components/input.json @@ -0,0 +1,149 @@ +{ + "input.radius": { + "$value": "{border-radius}", + "$type": "dimension", + "description": "Input border radius.", + "fallback": "--ty-border-radius", + "aliases": ["--ty-input-border-radius"] + }, + "input.color": { + "$value": "{color-text}", + "$type": "color", + "description": "Input text color.", + "fallback": "--ty-color-text" + }, + "input.bg": { + "$value": "{color-bg-container}", + "$type": "color", + "description": "Input background color.", + "fallback": "--ty-color-bg-container" + }, + "input.bg.disabled": { + "$value": "{color-bg-disabled}", + "$type": "color", + "description": "Disabled input background color.", + "fallback": "--ty-color-bg-disabled", + "aliases": ["--ty-input-disabled-bg"] + }, + "input.border": { + "$value": "{color-border}", + "$type": "color", + "description": "Input border color.", + "fallback": "--ty-color-border" + }, + "input.border.hover": { + "$value": "{color-primary}", + "$type": "color", + "description": "Input hover border color.", + "fallback": "--ty-color-primary" + }, + "input.border.focus": { + "$value": "{color-primary}", + "$type": "color", + "description": "Input focus border color.", + "fallback": "--ty-color-primary", + "aliases": ["--ty-input-focus-border"] + }, + "input.shadow.focus": { + "$value": "{shadow-focus}", + "$type": "shadow", + "description": "Input focus ring shadow.", + "fallback": "--ty-shadow-focus", + "aliases": ["--ty-input-focus-shadow"] + }, + "input.placeholder": { + "$value": "{color-text-placeholder}", + "$type": "color", + "description": "Input placeholder color.", + "fallback": "--ty-color-text-placeholder" + }, + "input.addon-bg": { + "$value": "{color-fill}", + "$type": "color", + "description": "Addon background color.", + "fallback": "--ty-color-fill" + }, + "input.addon-padding": { + "$value": "{spacing-3}", + "$type": "dimension", + "description": "Addon padding." + }, + "input.affix-margin": { + "$value": "{spacing-3}", + "$type": "dimension", + "description": "Prefix and suffix inset spacing." + }, + "input.clear-size": { + "$value": "1em", + "$type": "dimension", + "description": "Clear button square size." + }, + "input.clear-color": { + "$value": "{color-text-quaternary}", + "$type": "color", + "description": "Clear button icon color.", + "fallback": "--ty-color-text-quaternary" + }, + "input.font-size-sm": { + "$value": "{font-size-sm}", + "$type": "dimension", + "description": "Small input font size.", + "fallback": "--ty-font-size-sm" + }, + "input.font-size-md": { + "$value": "{font-size-base}", + "$type": "dimension", + "description": "Medium input font size.", + "fallback": "--ty-font-size-base", + "aliases": ["--ty-input-font-size"] + }, + "input.font-size-lg": { + "$value": "{font-size-lg}", + "$type": "dimension", + "description": "Large input font size.", + "fallback": "--ty-font-size-lg" + }, + "input.height.sm": { + "$value": "{height-sm}", + "$type": "dimension", + "description": "Small input height.", + "fallback": "--ty-height-sm" + }, + "input.height.md": { + "$value": "{height-md}", + "$type": "dimension", + "description": "Medium input height.", + "fallback": "--ty-height-md" + }, + "input.height.lg": { + "$value": "{height-lg}", + "$type": "dimension", + "description": "Large input height.", + "fallback": "--ty-height-lg" + }, + "input.padding-inline-sm": { + "$value": "{spacing-3}", + "$type": "dimension", + "description": "Small input inline padding.", + "aliases": ["--ty-input-sm-padding"] + }, + "input.padding-inline-md": { + "$value": "{spacing-4}", + "$type": "dimension", + "description": "Medium input inline padding.", + "aliases": ["--ty-input-md-padding"] + }, + "input.padding-inline-lg": { + "$value": "{spacing-5}", + "$type": "dimension", + "description": "Large input inline padding.", + "aliases": ["--ty-input-lg-padding"] + }, + "input.text.disabled": { + "$value": "{color-text-quaternary}", + "$type": "color", + "description": "Disabled input text color.", + "fallback": "--ty-color-text-quaternary", + "aliases": ["--ty-input-disabled-color"] + } +} diff --git a/packages/tokens/source/schema/theme.v1.schema.json b/packages/tokens/source/schema/theme.v1.schema.json new file mode 100644 index 00000000..b3c8c158 --- /dev/null +++ b/packages/tokens/source/schema/theme.v1.schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tiny.design/schema/theme.v1.schema.json", + "title": "Tiny Design Theme Document v1", + "description": "Versioned theme document for Tiny Design token overrides.", + "type": "object", + "additionalProperties": false, + "required": ["meta", "mode", "tokens"], + "properties": { + "$schema": { + "type": "string", + "format": "uri-reference" + }, + "meta": { + "$ref": "#/$defs/meta" + }, + "mode": { + "type": "string", + "enum": ["light", "dark", "system"] + }, + "extends": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$" + }, + "tokens": { + "$ref": "#/$defs/tokenSections" + } + }, + "$defs": { + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "schemaVersion"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "author": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z-.]+)?(?:\\+[0-9A-Za-z-.]+)?$" + }, + "schemaVersion": { + "type": "integer", + "const": 1 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$" + }, + "uniqueItems": true, + "maxItems": 20 + } + } + }, + "tokenSections": { + "type": "object", + "additionalProperties": false, + "properties": { + "semantic": { + "$ref": "#/$defs/tokenMap" + }, + "components": { + "$ref": "#/$defs/tokenMap" + } + } + }, + "tokenMap": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/tokenKey" + }, + "additionalProperties": { + "$ref": "#/$defs/tokenValue" + } + }, + "tokenKey": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*){0,2}$" + }, + "tokenValue": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "number" + } + ] + } + }, + "examples": [ + { + "$schema": "https://tiny.design/schema/theme.v1.schema.json", + "meta": { + "id": "ocean-glass", + "name": "Ocean Glass", + "author": "community-user", + "schemaVersion": 1, + "tags": ["cool", "soft"] + }, + "mode": "light", + "extends": "tiny-light", + "tokens": { + "semantic": { + "color-primary": "#3b82f6", + "font-family": "Inter, sans-serif", + "border-radius": "12px" + }, + "components": { + "button.bg.primary": "#3b82f6", + "button.bg.primary-hover": "#2563eb", + "button.radius": "999px", + "card.header-padding": "16px 20px" + } + } + } + ] +} diff --git a/packages/tokens/source/semantic/colors.json b/packages/tokens/source/semantic/colors.json new file mode 100644 index 00000000..81de8845 --- /dev/null +++ b/packages/tokens/source/semantic/colors.json @@ -0,0 +1,137 @@ +{ + "color-bg-container": { + "$value": "#ffffff", + "$type": "color", + "description": "Default container background color." + }, + "color-bg-disabled": { + "$value": "#f5f5f5", + "$type": "color", + "description": "Disabled surface background color." + }, + "color-fill": { + "$value": "#fafafa", + "$type": "color", + "description": "Secondary filled surface color." + }, + "color-text": { + "$value": "rgba(0, 0, 0, 0.85)", + "$type": "color", + "description": "Default text color." + }, + "color-text-heading": { + "$value": "rgba(0, 0, 0, 0.85)", + "$type": "color", + "description": "Heading text color." + }, + "color-text-placeholder": { + "$value": "#bfbfbf", + "$type": "color", + "description": "Placeholder text color." + }, + "color-text-quaternary": { + "$value": "rgba(0, 0, 0, 0.25)", + "$type": "color", + "description": "Low-emphasis text color." + }, + "color-border": { + "$value": "#d9d9d9", + "$type": "color", + "description": "Default border color." + }, + "color-border-secondary": { + "$value": "#e8e8e8", + "$type": "color", + "description": "Secondary border color." + }, + "color-border-btn-default": { + "$value": "#d0d0d5", + "$type": "color", + "description": "Default button border color." + }, + "color-primary": { + "$value": "#6e41bf", + "$type": "color", + "description": "Primary brand color." + }, + "color-primary-hover": { + "$value": "#8b62d0", + "$type": "color", + "description": "Primary hover color." + }, + "color-primary-active": { + "$value": "#5a30a8", + "$type": "color", + "description": "Primary active color." + }, + "color-primary-bg": { + "$value": "#f3eefa", + "$type": "color", + "description": "Primary tinted background color." + }, + "color-primary-bg-hover": { + "$value": "#ece3f7", + "$type": "color", + "description": "Primary tinted background hover color." + }, + "color-info": { + "$value": "#1890ff", + "$type": "color", + "description": "Info semantic color." + }, + "color-info-hover": { + "$value": "#40a9ff", + "$type": "color", + "description": "Info hover color." + }, + "color-info-active": { + "$value": "#096dd9", + "$type": "color", + "description": "Info active color." + }, + "color-success": { + "$value": "#52c41a", + "$type": "color", + "description": "Success semantic color." + }, + "color-success-hover": { + "$value": "#73d13d", + "$type": "color", + "description": "Success hover color." + }, + "color-success-active": { + "$value": "#389e0d", + "$type": "color", + "description": "Success active color." + }, + "color-warning": { + "$value": "#ff9800", + "$type": "color", + "description": "Warning semantic color." + }, + "color-warning-hover": { + "$value": "#ffad33", + "$type": "color", + "description": "Warning hover color." + }, + "color-warning-active": { + "$value": "#e68a00", + "$type": "color", + "description": "Warning active color." + }, + "color-danger": { + "$value": "#f44336", + "$type": "color", + "description": "Danger semantic color." + }, + "color-danger-hover": { + "$value": "#ff7875", + "$type": "color", + "description": "Danger hover color." + }, + "color-danger-active": { + "$value": "#cf1322", + "$type": "color", + "description": "Danger active color." + } +} diff --git a/packages/tokens/source/semantic/effects.json b/packages/tokens/source/semantic/effects.json new file mode 100644 index 00000000..4767adfc --- /dev/null +++ b/packages/tokens/source/semantic/effects.json @@ -0,0 +1,12 @@ +{ + "shadow-card": { + "$value": "0 1px 6px rgba(0, 0, 0, 0.12)", + "$type": "shadow", + "description": "Card elevation shadow." + }, + "shadow-focus": { + "$value": "0 0 0 3px rgba(110, 65, 191, 0.2)", + "$type": "shadow", + "description": "Default focus ring shadow." + } +} diff --git a/packages/tokens/source/semantic/size.json b/packages/tokens/source/semantic/size.json new file mode 100644 index 00000000..b3bef0cc --- /dev/null +++ b/packages/tokens/source/semantic/size.json @@ -0,0 +1,22 @@ +{ + "height-sm": { + "$value": "24px", + "$type": "dimension", + "description": "Small control height." + }, + "height-md": { + "$value": "32px", + "$type": "dimension", + "description": "Medium control height." + }, + "height-lg": { + "$value": "40px", + "$type": "dimension", + "description": "Large control height." + }, + "border-radius": { + "$value": "6px", + "$type": "dimension", + "description": "Default border radius." + } +} diff --git a/packages/tokens/source/semantic/spacing.json b/packages/tokens/source/semantic/spacing.json new file mode 100644 index 00000000..66d2ae57 --- /dev/null +++ b/packages/tokens/source/semantic/spacing.json @@ -0,0 +1,17 @@ +{ + "spacing-3": { + "$value": "8px", + "$type": "dimension", + "description": "Spacing scale step 3." + }, + "spacing-4": { + "$value": "12px", + "$type": "dimension", + "description": "Spacing scale step 4." + }, + "spacing-5": { + "$value": "16px", + "$type": "dimension", + "description": "Spacing scale step 5." + } +} diff --git a/packages/tokens/source/semantic/typography.json b/packages/tokens/source/semantic/typography.json new file mode 100644 index 00000000..e37a6de5 --- /dev/null +++ b/packages/tokens/source/semantic/typography.json @@ -0,0 +1,27 @@ +{ + "font-size-sm": { + "$value": "12px", + "$type": "dimension", + "description": "Small font size." + }, + "font-size-base": { + "$value": "14px", + "$type": "dimension", + "description": "Base font size." + }, + "font-size-lg": { + "$value": "16px", + "$type": "dimension", + "description": "Large font size." + }, + "font-weight-medium": { + "$value": "500", + "$type": "font-weight", + "description": "Medium font weight." + }, + "line-height-base": { + "$value": "1.5715", + "$type": "line-height", + "description": "Base line height." + } +} diff --git a/packages/tokens/source/themes/dark.json b/packages/tokens/source/themes/dark.json new file mode 100644 index 00000000..da96f820 --- /dev/null +++ b/packages/tokens/source/themes/dark.json @@ -0,0 +1,40 @@ +{ + "meta": { + "id": "tiny-dark", + "name": "Tiny Dark", + "mode": "dark" + }, + "tokens": { + "semantic": { + "color-bg-container": "#1f1f1f", + "color-bg-disabled": "#2a2a2a", + "color-fill": "#262626", + "color-text": "rgba(255, 255, 255, 0.85)", + "color-text-heading": "rgba(255, 255, 255, 0.85)", + "color-text-placeholder": "#5c5c5c", + "color-text-quaternary": "rgba(255, 255, 255, 0.25)", + "color-border": "#424242", + "color-border-secondary": "#363636", + "color-border-btn-default": "#424242", + "color-primary": "#9065d0", + "color-primary-hover": "#a882dc", + "color-primary-active": "#7a50bf", + "color-primary-bg": "#1a1325", + "color-primary-bg-hover": "#231a33", + "color-info": "#177ddc", + "color-info-hover": "#3c9ae8", + "color-info-active": "#1268b3", + "color-success": "#49aa19", + "color-success-hover": "#6abe39", + "color-success-active": "#3c8c14", + "color-warning": "#d89614", + "color-warning-hover": "#e8b339", + "color-warning-active": "#b37a10", + "color-danger": "#d32029", + "color-danger-hover": "#e84749", + "color-danger-active": "#ab1a20", + "shadow-card": "0 1px 6px rgba(0, 0, 0, 0.35)", + "shadow-focus": "0 0 0 3px rgba(144, 101, 208, 0.2)" + } + } +} diff --git a/packages/tokens/source/themes/light.json b/packages/tokens/source/themes/light.json new file mode 100644 index 00000000..ec3b6f02 --- /dev/null +++ b/packages/tokens/source/themes/light.json @@ -0,0 +1,10 @@ +{ + "meta": { + "id": "tiny-light", + "name": "Tiny Light", + "mode": "light" + }, + "tokens": { + "semantic": {} + } +} From 1b0ae07b35c3c9a421ef0db09321e3cb98785c41 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 6 Apr 2026 12:18:19 +1000 Subject: [PATCH 02/10] feat: refactor theme editor --- .../src/containers/home/theme-showcase.tsx | 4 +- .../src/containers/theme-community/index.tsx | 261 ++++++++ .../theme-community/theme-community.scss | 199 ++++++ .../components/color-controls.tsx | 32 - .../components/detail-controls.tsx | 59 -- .../theme-editor/components/export-dialog.tsx | 80 --- .../theme-editor/components/font-controls.tsx | 145 ----- .../components/preset-selector.tsx | 56 -- .../theme-editor/components/preview-panel.tsx | 280 --------- .../theme-editor/components/token-field.tsx | 104 ---- .../components/typography-controls.tsx | 32 - .../theme-editor/constants/default-tokens.ts | 287 --------- .../theme-editor/constants/presets.ts | 467 -------------- .../theme-editor/hooks/use-theme-state.ts | 176 ------ .../src/containers/theme-editor/index.tsx | 145 ----- .../containers/theme-editor/theme-editor.scss | 295 --------- .../theme-editor/utils/export-theme.ts | 17 - .../theme-editor/utils/font-loader.ts | 274 --------- .../components/studio-inspector.tsx | 184 ++++++ .../components/studio-preview.tsx | 472 ++++++++++++++ .../components/token-editor-panel.tsx | 91 +++ .../theme-studio/constants/presets.ts | 130 ++++ .../src/containers/theme-studio/index.tsx | 180 ++++++ .../theme-studio/state/use-studio-state.ts | 209 +++++++ .../containers/theme-studio/theme-studio.scss | 577 ++++++++++++++++++ .../utils/apply-theme.ts | 0 .../utils/color-utils.ts | 352 ++++------- .../theme-studio/utils/font-loader.ts | 68 +++ .../theme-studio/utils/studio-registry.ts | 109 ++++ apps/docs/src/containers/theme/index.tsx | 25 +- apps/docs/src/data/community-themes.ts | 160 +++++ apps/docs/src/index.scss | 24 +- apps/docs/src/locale/en_US.ts | 3 +- apps/docs/src/locale/types.ts | 3 +- apps/docs/src/locale/zh_CN.ts | 3 +- apps/docs/src/routers.tsx | 13 +- apps/docs/src/utils/theme-document.ts | 65 +- apps/docs/src/utils/theme-persistence.ts | 40 +- 38 files changed, 2925 insertions(+), 2696 deletions(-) create mode 100644 apps/docs/src/containers/theme-community/index.tsx create mode 100644 apps/docs/src/containers/theme-community/theme-community.scss delete mode 100644 apps/docs/src/containers/theme-editor/components/color-controls.tsx delete mode 100644 apps/docs/src/containers/theme-editor/components/detail-controls.tsx delete mode 100644 apps/docs/src/containers/theme-editor/components/export-dialog.tsx delete mode 100644 apps/docs/src/containers/theme-editor/components/font-controls.tsx delete mode 100644 apps/docs/src/containers/theme-editor/components/preset-selector.tsx delete mode 100644 apps/docs/src/containers/theme-editor/components/preview-panel.tsx delete mode 100644 apps/docs/src/containers/theme-editor/components/token-field.tsx delete mode 100644 apps/docs/src/containers/theme-editor/components/typography-controls.tsx delete mode 100644 apps/docs/src/containers/theme-editor/constants/default-tokens.ts delete mode 100644 apps/docs/src/containers/theme-editor/constants/presets.ts delete mode 100644 apps/docs/src/containers/theme-editor/hooks/use-theme-state.ts delete mode 100644 apps/docs/src/containers/theme-editor/index.tsx delete mode 100644 apps/docs/src/containers/theme-editor/theme-editor.scss delete mode 100644 apps/docs/src/containers/theme-editor/utils/export-theme.ts delete mode 100644 apps/docs/src/containers/theme-editor/utils/font-loader.ts create mode 100644 apps/docs/src/containers/theme-studio/components/studio-inspector.tsx create mode 100644 apps/docs/src/containers/theme-studio/components/studio-preview.tsx create mode 100644 apps/docs/src/containers/theme-studio/components/token-editor-panel.tsx create mode 100644 apps/docs/src/containers/theme-studio/constants/presets.ts create mode 100644 apps/docs/src/containers/theme-studio/index.tsx create mode 100644 apps/docs/src/containers/theme-studio/state/use-studio-state.ts create mode 100644 apps/docs/src/containers/theme-studio/theme-studio.scss rename apps/docs/src/containers/{theme-editor => theme-studio}/utils/apply-theme.ts (100%) rename apps/docs/src/containers/{theme-editor => theme-studio}/utils/color-utils.ts (50%) create mode 100644 apps/docs/src/containers/theme-studio/utils/font-loader.ts create mode 100644 apps/docs/src/containers/theme-studio/utils/studio-registry.ts create mode 100644 apps/docs/src/data/community-themes.ts diff --git a/apps/docs/src/containers/home/theme-showcase.tsx b/apps/docs/src/containers/home/theme-showcase.tsx index e01df71d..bdbc172c 100644 --- a/apps/docs/src/containers/home/theme-showcase.tsx +++ b/apps/docs/src/containers/home/theme-showcase.tsx @@ -2,7 +2,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Typography, Button, Marquee } from '@tiny-design/react'; import { useTheme } from '@tiny-design/react'; -import { PRESETS, getPresetSeeds, ThemePreset } from '../theme-editor/constants/presets'; +import { PRESETS, getPresetSeeds, ThemePreset } from '../theme-studio/constants/presets'; import { applyThemeToDOM, saveSeeds } from '../../utils/theme-persistence'; import { useLocaleContext } from '../../context/locale-context'; @@ -114,7 +114,7 @@ export const ThemeShowcase = (): React.ReactElement => {
-
diff --git a/apps/docs/src/containers/theme-community/index.tsx b/apps/docs/src/containers/theme-community/index.tsx new file mode 100644 index 00000000..70bb7809 --- /dev/null +++ b/apps/docs/src/containers/theme-community/index.tsx @@ -0,0 +1,261 @@ +import React, { useMemo, useState } from 'react'; +import { Link, Navigate, Route, Routes, useNavigate, useParams } from 'react-router-dom'; +import { + Button, + Card, + ConfigProvider, + Flex, + Input, + Table, + Tag, + Typography, +} from '@tiny-design/react'; +import { COMMUNITY_THEMES, CommunityThemeRecord } from '../../data/community-themes'; +import { savePendingThemeDocument } from '../../utils/theme-document'; +import './theme-community.scss'; + +const columns = [ + { title: 'Campaign', dataIndex: 'name', key: 'name' }, + { title: 'Status', dataIndex: 'status', key: 'status' }, + { title: 'Conversion', dataIndex: 'conversion', key: 'conversion' }, +]; + +const data = [ + { key: '1', name: 'Spring release', status: 'Live', conversion: '18.2%' }, + { key: '2', name: 'Waitlist', status: 'Draft', conversion: '9.4%' }, +]; + +function ThemeMiniPreview({ theme }: { theme: CommunityThemeRecord }): React.ReactElement { + return ( + +
+ + Overview + {theme.themeDocument.mode} + + + + + + + + + + + + + ); +} + +function createForkDocument(theme: CommunityThemeRecord) { + return { + ...theme.themeDocument, + meta: { + ...theme.themeDocument.meta, + id: `${theme.id}-fork`, + name: `${theme.name} Remix`, + }, + }; +} + +function ThemeCard({ + theme, + active, +}: { + theme: CommunityThemeRecord; + active?: boolean; +}): React.ReactElement { + return ( + +
+
+ {theme.swatches.map((color) => ( + + ))} +
+ {theme.themeDocument.mode} +
+
+ {theme.name} + {theme.description} +
+ by {theme.author} + {theme.likes} likes +
+
+ {theme.tags.map((tag) => ( + {tag} + ))} +
+
+ + ); +} + +function ThemeGalleryPage(): React.ReactElement { + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const filteredThemes = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return COMMUNITY_THEMES; + return COMMUNITY_THEMES.filter((theme) => + [theme.name, theme.description, theme.author, ...theme.tags].join(' ').toLowerCase().includes(normalized) + ); + }, [query]); + + const featured = filteredThemes[0] ?? COMMUNITY_THEMES[0]; + + return ( +
+
+
+ Community Theme Hub + + Explore shareable theme documents, inspect them in a live shell, then remix any theme in Theme Studio. + +
+
+ setQuery(event.target.value)} + /> +
+
+ +
+
+ Featured Theme + {featured.name} + {featured.description} +
+ {featured.tags.map((tag) => ( + {tag} + ))} +
+
+ + +
+
+ +
+ +
+ {filteredThemes.map((theme) => ( + + ))} +
+
+ ); +} + +function ThemeDetailPage(): React.ReactElement { + const navigate = useNavigate(); + const { slug } = useParams(); + const theme = COMMUNITY_THEMES.find((item) => item.slug === slug) ?? COMMUNITY_THEMES[0]; + const relatedThemes = COMMUNITY_THEMES.filter((item) => item.slug !== theme.slug).slice(0, 3); + + const handleOpenInStudio = (record: CommunityThemeRecord) => { + savePendingThemeDocument(record.themeDocument); + navigate('/theme/theme-studio'); + }; + + const handleForkInStudio = (record: CommunityThemeRecord) => { + savePendingThemeDocument(createForkDocument(record)); + navigate('/theme/theme-studio'); + }; + + return ( +
+
+
+ Theme Detail + {theme.name} + {theme.description} +
+ {theme.themeDocument.extends} + schema v{theme.themeDocument.meta?.schemaVersion ?? 1} + {theme.likes} likes +
+
+
+ + + +
+
+ +
+ + + + + + + + +
+
Author{theme.author}
+
Slug{theme.slug}
+
Mode{theme.themeDocument.mode}
+
Base Theme{theme.themeDocument.extends}
+
+
+ {theme.tags.map((tag) => ( + {tag} + ))} +
+
+
+
+ +
+ + Related themes + + +
+ {relatedThemes.map((item) => ( + + ))} +
+
+
+ ); +} + +const ThemeCommunityPage = (): React.ReactElement => { + return ( + + } /> + } /> + } /> + + ); +}; + +export default ThemeCommunityPage; diff --git a/apps/docs/src/containers/theme-community/theme-community.scss b/apps/docs/src/containers/theme-community/theme-community.scss new file mode 100644 index 00000000..77eff2e0 --- /dev/null +++ b/apps/docs/src/containers/theme-community/theme-community.scss @@ -0,0 +1,199 @@ +.theme-community { + display: flex; + flex-direction: column; + gap: 20px; +} + +.theme-community__eyebrow { + display: inline-block; + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 12px; + color: var(--ty-color-text-secondary, var(--ty-color-text)); +} + +.theme-community__hero { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 20px 24px; + border-radius: 20px; + background: + radial-gradient(circle at top right, rgba(24, 144, 255, 0.12), transparent 38%), + linear-gradient(180deg, var(--ty-color-bg-container) 0%, var(--ty-color-fill) 100%); + border: 1px solid var(--ty-color-border-secondary, var(--ty-color-border)); +} + +.theme-community__hero-actions { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.theme-community__hero-actions .ty-input { + min-width: 280px; +} + +.theme-community__hero-actions_column { + flex-direction: column; + align-items: stretch; +} + +.theme-community__featured { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 20px; + padding: 24px; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(24, 144, 255, 0.12), transparent 40%), + linear-gradient(180deg, var(--ty-color-bg-container) 0%, var(--ty-color-fill) 100%); + border: 1px solid var(--ty-color-border-secondary, var(--ty-color-border)); +} + +.theme-community__featured-copy { + display: flex; + flex-direction: column; + gap: 12px; +} + +.theme-community__layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 400px); + gap: 20px; +} + +.theme-community__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.theme-community__card { + display: flex; + flex-direction: column; + padding: 0; + border: 1px solid var(--ty-color-border-secondary, var(--ty-color-border)); + border-radius: 20px; + overflow: hidden; + background: var(--ty-color-bg-container); + text-align: left; + cursor: pointer; + color: inherit; + text-decoration: none; + transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +.theme-community__card:hover, +.theme-community__card_active { + transform: translateY(-2px); + border-color: var(--ty-color-primary); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08); +} + +.theme-community__card-preview { + position: relative; + min-height: 148px; + padding: 16px; +} + +.theme-community__card-swatches { + display: flex; + gap: 8px; +} + +.theme-community__card-swatch { + width: 18px; + height: 18px; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.7); +} + +.theme-community__card-badge { + position: absolute; + right: 16px; + bottom: 16px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(10px); + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} + +.theme-community__card-body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; +} + +.theme-community__card-body .ty-paragraph { + margin-bottom: 0; + color: var(--ty-color-text-secondary, var(--ty-color-text)); +} + +.theme-community__card-meta { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: var(--ty-color-text-secondary, var(--ty-color-text)); +} + +.theme-community__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.theme-community__detail { + border-radius: 20px; +} + +.theme-community__detail-meta, +.theme-community__detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.theme-community__detail-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.theme-community__detail-list > div { + display: flex; + flex-direction: column; + gap: 6px; +} + +.theme-community__subsection { + display: flex; + flex-direction: column; + gap: 16px; +} + +.theme-community__mini-preview { + padding: 16px; + border-radius: 16px; + background: var(--ty-color-bg-container); + border: 1px solid var(--ty-color-border-secondary, var(--ty-color-border)); +} + +@media (max-width: 1024px) { + .theme-community__featured, + .theme-community__layout { + grid-template-columns: 1fr; + } + + .theme-community__hero { + flex-direction: column; + } +} diff --git a/apps/docs/src/containers/theme-editor/components/color-controls.tsx b/apps/docs/src/containers/theme-editor/components/color-controls.tsx deleted file mode 100644 index 3e692357..00000000 --- a/apps/docs/src/containers/theme-editor/components/color-controls.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { COLOR_TOKENS } from '../constants/default-tokens'; -import { TokenField } from './token-field'; - -interface ColorControlsProps { - seeds: Record; - onSeedChange: (key: string, value: string) => void; - isOverridden: (key: string) => boolean; - onResetToken: (key: string) => void; -} - -export const ColorControls = ({ - seeds, - onSeedChange, - isOverridden, - onResetToken, -}: ColorControlsProps): React.ReactElement => { - return ( -
- {COLOR_TOKENS.map((token) => ( - onSeedChange(token.key, value)} - onReset={() => onResetToken(token.key)} - /> - ))} -
- ); -}; diff --git a/apps/docs/src/containers/theme-editor/components/detail-controls.tsx b/apps/docs/src/containers/theme-editor/components/detail-controls.tsx deleted file mode 100644 index 7f8e164a..00000000 --- a/apps/docs/src/containers/theme-editor/components/detail-controls.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { Divider } from '@tiny-design/react'; -import { DETAIL_TOKENS, SHADOW_TOKENS, SPACING_TOKENS } from '../constants/default-tokens'; -import { TokenField } from './token-field'; - -interface DetailControlsProps { - seeds: Record; - onSeedChange: (key: string, value: string) => void; - isOverridden: (key: string) => boolean; - onResetToken: (key: string) => void; -} - -export const DetailControls = ({ - seeds, - onSeedChange, - isOverridden, - onResetToken, -}: DetailControlsProps): React.ReactElement => { - return ( -
- {DETAIL_TOKENS.map((token) => ( - onSeedChange(token.key, value)} - onReset={() => onResetToken(token.key)} - /> - ))} - - -
Spacing
- {SPACING_TOKENS.map((token) => ( - onSeedChange(token.key, value)} - onReset={() => onResetToken(token.key)} - /> - ))} - - -
Shadows
- {SHADOW_TOKENS.map((token) => ( - onSeedChange(token.key, value)} - onReset={() => onResetToken(token.key)} - /> - ))} -
- ); -}; diff --git a/apps/docs/src/containers/theme-editor/components/export-dialog.tsx b/apps/docs/src/containers/theme-editor/components/export-dialog.tsx deleted file mode 100644 index 022f55d8..00000000 --- a/apps/docs/src/containers/theme-editor/components/export-dialog.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useState } from 'react'; -import type { ThemeDocument } from '@tiny-design/react'; -import { Button, Modal, Tabs } from '@tiny-design/react'; -import { generateCSS, generateJSON } from '../utils/export-theme'; - -interface ExportDialogProps { - visible: boolean; - onClose: () => void; - appliedTokens: Record; - themeDocument: ThemeDocument; -} - -function downloadFile(content: string, filename: string, mime: string): void { - const blob = new Blob([content], { type: mime }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); -} - -export const ExportDialog = ({ - visible, - onClose, - appliedTokens, - themeDocument, -}: ExportDialogProps): React.ReactElement => { - const [copied, setCopied] = useState(false); - - const cssCode = generateCSS(appliedTokens); - const jsonCode = generateJSON(themeDocument); - - const handleCopy = (code: string) => { - navigator.clipboard.writeText(code).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }; - - const renderBlock = (code: string, filename: string, mime: string) => ( -
-
{code}
-
- - -
-
- ); - - return ( - - - - {renderBlock(cssCode, 'tiny-theme.css', 'text/css')} - - - {renderBlock(jsonCode, 'tiny-theme.document.json', 'application/json')} - - - - ); -}; diff --git a/apps/docs/src/containers/theme-editor/components/font-controls.tsx b/apps/docs/src/containers/theme-editor/components/font-controls.tsx deleted file mode 100644 index 8bbc2f02..00000000 --- a/apps/docs/src/containers/theme-editor/components/font-controls.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { useEffect } from 'react'; -import { Divider } from '@tiny-design/react'; -import { FONT_TOKENS, TokenDef } from '../constants/default-tokens'; -import { - ALL_BODY_FONTS, - ALL_MONO_FONTS, - FontDef, - loadGoogleFont, -} from '../utils/font-loader'; - -interface FontControlsProps { - seeds: Record; - onSeedChange: (key: string, value: string) => void; - isOverridden: (key: string) => boolean; - onResetToken: (key: string) => void; -} - -const FontOption = ({ - font, - isActive, - onClick, -}: { - font: FontDef; - isActive: boolean; - onClick: () => void; -}): React.ReactElement => { - // Load the font when it becomes visible so preview text renders correctly - useEffect(() => { - loadGoogleFont(font); - }, [font]); - - return ( - - ); -}; - -const FontGroup = ({ - title, - fonts, - currentValue, - onSelect, -}: { - title: string; - fonts: FontDef[]; - currentValue: string; - onSelect: (value: string) => void; -}): React.ReactElement => ( -
-
{title}
-
- {fonts.map((font) => ( - onSelect(font.value)} - /> - ))} -
-
-); - -const FontSelector = ({ - token, - value, - isOverridden, - onChange, - onReset, -}: { - token: TokenDef; - value: string; - isOverridden: boolean; - onChange: (value: string) => void; - onReset: () => void; -}): React.ReactElement => { - const isMono = token.key === 'font-family-monospace'; - const fonts = isMono ? ALL_MONO_FONTS : ALL_BODY_FONTS; - - // Group body fonts by category - const groups = isMono - ? [{ title: 'Monospace', fonts }] - : [ - { title: 'Sans-serif', fonts: fonts.filter((f) => f.category === 'sans-serif') }, - { title: 'Serif', fonts: fonts.filter((f) => f.category === 'serif') }, - { title: 'Display', fonts: fonts.filter((f) => f.category === 'display') }, - ]; - - return ( -
-
- - {isOverridden && ( - - )} -
- {groups.map((group, i) => ( - - {i > 0 && } - - - ))} -
- ); -}; - -export const FontControls = ({ - seeds, - onSeedChange, - isOverridden, - onResetToken, -}: FontControlsProps): React.ReactElement => { - return ( -
- {FONT_TOKENS.map((token) => ( - onSeedChange(token.key, value)} - onReset={() => onResetToken(token.key)} - /> - ))} -
- ); -}; diff --git a/apps/docs/src/containers/theme-editor/components/preset-selector.tsx b/apps/docs/src/containers/theme-editor/components/preset-selector.tsx deleted file mode 100644 index 36d2027e..00000000 --- a/apps/docs/src/containers/theme-editor/components/preset-selector.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import { PRESETS, ThemePreset, getPresetSeeds } from '../constants/presets'; - -interface PresetSelectorProps { - activePresetId?: string; - isDark: boolean; - onSelect: (presetSeeds: Record, presetId: string) => void; -} - -const PresetCard = ({ - preset, - isActive, - onClick, -}: { - preset: ThemePreset; - isActive: boolean; - onClick: () => void; -}): React.ReactElement => ( - -); - -export const PresetSelector = ({ - activePresetId, - isDark, - onSelect, -}: PresetSelectorProps): React.ReactElement => { - return ( -
-
- {PRESETS.map((preset) => ( - onSelect(getPresetSeeds(preset, isDark), preset.id)} - /> - ))} -
-
- ); -}; diff --git a/apps/docs/src/containers/theme-editor/components/preview-panel.tsx b/apps/docs/src/containers/theme-editor/components/preview-panel.tsx deleted file mode 100644 index c734f19f..00000000 --- a/apps/docs/src/containers/theme-editor/components/preview-panel.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React from 'react'; -import { - Alert, - Avatar, - Badge, - Breadcrumb, - Button, - Card, - Checkbox, - Collapse, - Divider, - Dropdown, - Flex, - Input, - InputNumber, - Keyboard, - Link, - Menu, - Pagination, - Popover, - Progress, - Radio, - Rate, - Segmented, - Select, - Skeleton, - Slider, - Space, - Statistic, - Steps, - Switch, - Table, - Tabs, - Tag, - Textarea, - Timeline, - Tooltip, - Typography, -} from '@tiny-design/react'; - -const tableColumns = [ - { title: 'Name', dataIndex: 'name', key: 'name' }, - { title: 'Age', dataIndex: 'age', key: 'age' }, - { title: 'Role', dataIndex: 'role', key: 'role' }, -]; - -const tableData = [ - { key: '1', name: 'Alice', age: 28, role: 'Designer' }, - { key: '2', name: 'Bob', age: 32, role: 'Developer' }, - { key: '3', name: 'Carol', age: 25, role: 'Manager' }, -]; - -const dropdownMenu = ( - - Action 1 - Action 2 - Action 3 - -); - -export const PreviewPanel = (): React.ReactElement => { - return ( -
-

Live Preview

- - {/* Buttons */} -
-

Buttons

- - - - - - - - - - - - - - - - - - -
- - - - {/* Form Controls */} -
-

Form Controls

- - - - - - - Checkbox - - Radio A - Radio B - - - - - -