Card Surface
Focus Ring
diff --git a/apps/docs/src/containers/theme-studio/theme-document-adapter.ts b/apps/docs/src/containers/theme-studio/theme-document-adapter.ts
index 5aa9f469..9e346e63 100644
--- a/apps/docs/src/containers/theme-studio/theme-document-adapter.ts
+++ b/apps/docs/src/containers/theme-studio/theme-document-adapter.ts
@@ -1,4 +1,5 @@
import type { ThemeDocument } from '@tiny-design/react';
+import { validateThemeDocument } from '@tiny-design/tokens/validate-theme';
import { toRgba, tintColor, softenSurface } from './color-utils';
import { getPresetById, getPresetDraft } from './runtime-presets';
import type {
@@ -21,6 +22,11 @@ export function inferPresetIdFromThemeDocument(theme: ThemeDocument): string {
return 'default';
}
+function normalizeImportedThemeDocument(theme: ThemeDocument): ThemeDocument {
+ const validation = validateThemeDocument(theme);
+ return validation.normalizedDocument as ThemeDocument;
+}
+
export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocument {
const { fields } = draft;
const primaryHover = tintColor(fields.primary, draft.mode === 'dark' ? 0.12 : 0.08);
@@ -158,8 +164,19 @@ export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocum
'button.text.default': fields.baseForeground,
'button.text.default-hover': fields.baseForeground,
'button.text.default-active': fields.baseForeground,
+ 'control.height.sm': fields.fieldHeightSm,
+ 'control.height.md': fields.fieldHeightMd,
+ 'control.height.lg': fields.fieldHeightLg,
+ 'control.padding-inline.sm': fields.fieldPaddingSm,
+ 'control.padding-inline.md': fields.fieldPaddingMd,
+ 'control.padding-inline.lg': fields.fieldPaddingLg,
'button.radius': fields.buttonRadius,
- 'button.padding-inline-md': fields.buttonPaddingX,
+ 'button.height.sm': fields.buttonHeightSm,
+ 'button.height.md': fields.buttonHeightMd,
+ 'button.height.lg': fields.buttonHeightLg,
+ 'button.padding-inline-sm': fields.buttonPaddingSm,
+ 'button.padding-inline-md': fields.buttonPaddingMd,
+ 'button.padding-inline-lg': fields.buttonPaddingLg,
'card.bg': fields.card,
'card.bg.filled': fields.secondary,
'card.border': fields.border,
@@ -176,7 +193,12 @@ export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocum
'input.border.focus': fields.ring,
'input.shadow.focus': fields.shadowFocus,
'input.radius': fields.inputRadius,
- 'input.height.md': fields.inputHeight,
+ 'input.height.sm': fields.fieldHeightSm,
+ 'input.height.md': fields.fieldHeightMd,
+ 'input.height.lg': fields.fieldHeightLg,
+ 'input.padding-inline-sm': fields.fieldPaddingSm,
+ 'input.padding-inline-md': fields.fieldPaddingMd,
+ 'input.padding-inline-lg': fields.fieldPaddingLg,
'select.bg': fields.base,
'select.color': fields.baseForeground,
'select.border': fields.input,
@@ -184,10 +206,16 @@ export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocum
'select.border.focus': fields.ring,
'select.shadow.focus': fields.shadowFocus,
'select.radius': fields.inputRadius,
- 'select.height.md': fields.inputHeight,
+ 'select.height.sm': fields.fieldHeightSm,
+ 'select.height.md': fields.fieldHeightMd,
+ 'select.height.lg': fields.fieldHeightLg,
+ 'select.padding-inline-start.sm': fields.fieldPaddingSm,
+ 'select.padding-inline-start.md': fields.fieldPaddingMd,
+ 'select.padding-inline-start.lg': fields.fieldPaddingLg,
'select.dropdown-bg': fields.popover,
'select.option.active-bg': fields.muted,
'select.option.selected-bg': fields.accent,
+ 'table.radius': fields.radius,
'picker.input-bg': fields.base,
'picker.input-border': fields.input,
'picker.input-border-hover': fields.ring,
@@ -197,6 +225,9 @@ export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocum
'picker.input-color-placeholder': fields.mutedForeground,
'picker.input-color-muted': fields.mutedForeground,
'picker.input-radius': fields.inputRadius,
+ 'picker.input-padding.sm': `0 ${fields.fieldPaddingSm}`,
+ 'picker.input-padding.md': `0 ${fields.fieldPaddingMd}`,
+ 'picker.input-padding.lg': `0 ${fields.fieldPaddingLg}`,
'picker.dropdown-bg': fields.popover,
'picker.dropdown-radius': fields.cardRadius,
'picker.header-border': fields.border,
@@ -241,9 +272,16 @@ export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocum
'calendar.panel-cell-color-selected': fields.primaryForeground,
'calendar.panel-cell-bg-selected': fields.primary,
'calendar.today-link-color': fields.primary,
+ 'cascader.height.sm': fields.fieldHeightSm,
+ 'cascader.height.md': fields.fieldHeightMd,
+ 'cascader.height.lg': fields.fieldHeightLg,
+ 'cascader.padding.sm': `0 calc(${fields.fieldPaddingSm} + 20px) 0 ${fields.fieldPaddingSm}`,
+ 'cascader.padding.md': `0 calc(${fields.fieldPaddingMd} + 20px) 0 ${fields.fieldPaddingMd}`,
+ 'cascader.padding.lg': `0 calc(${fields.fieldPaddingLg} + 20px) 0 ${fields.fieldPaddingLg}`,
'checkbox.bg': fields.base,
'checkbox.border': fields.input,
'checkbox.border.hover': fields.ring,
+ 'checkbox.radius': fields.radius,
'checkbox.bg.checked': fields.primary,
'checkbox.border.checked': fields.primary,
'checkbox.indicator-color': fields.primaryForeground,
@@ -255,9 +293,12 @@ export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocum
'switch.bg.checked': fields.primary,
'switch.thumb-border': fields.mutedForeground,
'switch.thumb-border.checked': fields.primary,
+ 'input-number.height.sm': fields.fieldHeightSm,
+ 'input-number.height.md': fields.fieldHeightMd,
+ 'input-number.height.lg': fields.fieldHeightLg,
'segmented.bg': fields.muted,
'segmented.active-bg': fields.card,
- 'segmented.radius': fields.inputRadius,
+ 'segmented.radius': fields.radius,
'tag.bg': fields.secondary,
'tag.color': fields.secondaryForeground,
'tag.border': fields.border,
@@ -306,76 +347,126 @@ function readToken(theme: ThemeDocument, semanticKey: string, componentKey?: str
return undefined;
}
+function readComponentFirst(theme: ThemeDocument, componentKey: string, semanticKey?: string): string | undefined {
+ const component = theme.tokens?.components?.[componentKey];
+ if (component != null) return String(component);
+
+ if (semanticKey) {
+ const semantic = theme.tokens?.semantic?.[semanticKey];
+ if (semantic != null) return String(semantic);
+ }
+
+ return undefined;
+}
+
export function buildDraftFromThemeDocument(theme: ThemeDocument): ThemeEditorDraft {
- const presetId = inferPresetIdFromThemeDocument(theme);
- const mode = theme.mode === 'dark' ? 'dark' : 'light';
+ const normalizedTheme = normalizeImportedThemeDocument(theme);
+ const presetId = inferPresetIdFromThemeDocument(normalizedTheme);
+ const mode = normalizedTheme.mode === 'dark' ? 'dark' : 'light';
const baseDraft = getPresetDraft(presetId, mode);
const baseFields = { ...baseDraft.fields };
return {
...baseDraft,
meta: {
- name: theme.meta?.name ?? baseDraft.meta.name,
- author: theme.meta?.author ?? baseDraft.meta.author,
+ name: normalizedTheme.meta?.name ?? baseDraft.meta.name,
+ author: normalizedTheme.meta?.author ?? baseDraft.meta.author,
},
mode,
presetId,
fields: {
...baseFields,
- primary: readToken(theme, 'color-primary') ?? baseFields.primary,
- primaryForeground: readToken(theme, 'editor-primary-foreground', 'button.text.primary') ?? baseFields.primaryForeground,
- secondary: readToken(theme, 'editor-secondary') ?? readToken(theme, 'color-fill', 'button.bg.default') ?? baseFields.secondary,
- secondaryForeground: readToken(theme, 'editor-secondary-foreground', 'button.text.default') ?? baseFields.secondaryForeground,
- accent: readToken(theme, 'editor-accent') ?? readToken(theme, 'color-primary-bg') ?? baseFields.accent,
- accentForeground: readToken(theme, 'editor-accent-foreground') ?? baseFields.accentForeground,
- success: readToken(theme, 'editor-success') ?? readToken(theme, 'color-success') ?? baseFields.success,
- successForeground: readToken(theme, 'editor-success-foreground') ?? baseFields.successForeground,
- info: readToken(theme, 'editor-info') ?? readToken(theme, 'color-info') ?? baseFields.info,
- infoForeground: readToken(theme, 'editor-info-foreground') ?? baseFields.infoForeground,
- warning: readToken(theme, 'editor-warning') ?? readToken(theme, 'color-warning') ?? baseFields.warning,
- warningForeground: readToken(theme, 'editor-warning-foreground') ?? baseFields.warningForeground,
- danger: readToken(theme, 'editor-danger') ?? readToken(theme, 'color-danger') ?? baseFields.danger,
- dangerForeground: readToken(theme, 'editor-danger-foreground') ?? baseFields.dangerForeground,
- base: readToken(theme, 'editor-base') ?? readToken(theme, 'color-bg') ?? baseFields.base,
- baseForeground: readToken(theme, 'editor-base-foreground') ?? readToken(theme, 'color-text') ?? baseFields.baseForeground,
- card: readToken(theme, 'editor-card') ?? readToken(theme, 'color-bg-container', 'card.bg') ?? baseFields.card,
- cardForeground: readToken(theme, 'editor-card-foreground', 'card.header-color') ?? baseFields.cardForeground,
- popover: readToken(theme, 'editor-popover') ?? readToken(theme, 'color-bg-elevated') ?? baseFields.popover,
- popoverForeground: readToken(theme, 'editor-popover-foreground') ?? baseFields.popoverForeground,
- muted: readToken(theme, 'editor-muted') ?? readToken(theme, 'color-bg-spotlight') ?? baseFields.muted,
- mutedForeground: readToken(theme, 'editor-muted-foreground') ?? readToken(theme, 'color-text-secondary') ?? baseFields.mutedForeground,
- border: readToken(theme, 'editor-border') ?? readToken(theme, 'color-border') ?? baseFields.border,
- input: readToken(theme, 'editor-input') ?? readToken(theme, 'color-border', 'input.border') ?? baseFields.input,
- ring: readToken(theme, 'editor-ring', 'input.border.focus') ?? baseFields.ring,
- chart1: readToken(theme, 'chart-1') ?? baseFields.chart1,
- chart2: readToken(theme, 'chart-2') ?? baseFields.chart2,
- chart3: readToken(theme, 'chart-3') ?? baseFields.chart3,
- chart4: readToken(theme, 'chart-4') ?? baseFields.chart4,
- chart5: readToken(theme, 'chart-5') ?? baseFields.chart5,
- sidebar: readToken(theme, 'editor-sidebar', 'layout.sidebar-bg') ?? baseFields.sidebar,
- sidebarForeground: readToken(theme, 'editor-sidebar-foreground', 'layout.sidebar-color') ?? baseFields.sidebarForeground,
- sidebarPrimary: readToken(theme, 'editor-sidebar-primary') ?? baseFields.sidebarPrimary,
- sidebarPrimaryForeground: readToken(theme, 'editor-sidebar-primary-foreground') ?? baseFields.sidebarPrimaryForeground,
- sidebarAccent: readToken(theme, 'editor-sidebar-accent') ?? baseFields.sidebarAccent,
- sidebarAccentForeground: readToken(theme, 'editor-sidebar-accent-foreground') ?? baseFields.sidebarAccentForeground,
- sidebarBorder: readToken(theme, 'editor-sidebar-border') ?? baseFields.sidebarBorder,
- sidebarRing: readToken(theme, 'editor-sidebar-ring') ?? baseFields.sidebarRing,
- fontSans: readToken(theme, 'font-family') ?? baseFields.fontSans,
- fontMono: readToken(theme, 'font-family-monospace') ?? baseFields.fontMono,
- fontSizeBase: readToken(theme, 'font-size-base') ?? baseFields.fontSizeBase,
- lineHeightBase: readToken(theme, 'line-height-base') ?? baseFields.lineHeightBase,
- h1Size: readToken(theme, 'h1-font-size') ?? baseFields.h1Size,
- h2Size: readToken(theme, 'h2-font-size') ?? baseFields.h2Size,
- letterSpacing: readToken(theme, 'letter-spacing') ?? baseFields.letterSpacing,
- radius: readToken(theme, 'border-radius') ?? baseFields.radius,
- shadowCard: readToken(theme, 'shadow-card', 'card.shadow') ?? baseFields.shadowCard,
- shadowFocus: readToken(theme, 'shadow-focus', 'input.shadow.focus') ?? toRgba(baseFields.primary, 0.22),
- buttonRadius: readToken(theme, 'border-radius', 'button.radius') ?? baseFields.buttonRadius,
- inputRadius: readToken(theme, 'border-radius', 'input.radius') ?? baseFields.inputRadius,
- cardRadius: readToken(theme, 'border-radius', 'card.radius') ?? baseFields.cardRadius,
- buttonPaddingX: readToken(theme, 'spacing-4', 'button.padding-inline-md') ?? baseFields.buttonPaddingX,
- inputHeight: readToken(theme, 'height-md', 'input.height.md') ?? baseFields.inputHeight,
- cardPadding: readToken(theme, 'spacing-5', 'card.body-padding') ?? baseFields.cardPadding,
+ primary: readToken(normalizedTheme, 'color-primary') ?? baseFields.primary,
+ primaryForeground: readToken(normalizedTheme, 'editor-primary-foreground', 'button.text.primary') ?? baseFields.primaryForeground,
+ secondary: readToken(normalizedTheme, 'editor-secondary') ?? readToken(normalizedTheme, 'color-fill', 'button.bg.default') ?? baseFields.secondary,
+ secondaryForeground: readToken(normalizedTheme, 'editor-secondary-foreground', 'button.text.default') ?? baseFields.secondaryForeground,
+ accent: readToken(normalizedTheme, 'editor-accent') ?? readToken(normalizedTheme, 'color-primary-bg') ?? baseFields.accent,
+ accentForeground: readToken(normalizedTheme, 'editor-accent-foreground') ?? baseFields.accentForeground,
+ success: readToken(normalizedTheme, 'editor-success') ?? readToken(normalizedTheme, 'color-success') ?? baseFields.success,
+ successForeground: readToken(normalizedTheme, 'editor-success-foreground') ?? baseFields.successForeground,
+ info: readToken(normalizedTheme, 'editor-info') ?? readToken(normalizedTheme, 'color-info') ?? baseFields.info,
+ infoForeground: readToken(normalizedTheme, 'editor-info-foreground') ?? baseFields.infoForeground,
+ warning: readToken(normalizedTheme, 'editor-warning') ?? readToken(normalizedTheme, 'color-warning') ?? baseFields.warning,
+ warningForeground: readToken(normalizedTheme, 'editor-warning-foreground') ?? baseFields.warningForeground,
+ danger: readToken(normalizedTheme, 'editor-danger') ?? readToken(normalizedTheme, 'color-danger') ?? baseFields.danger,
+ dangerForeground: readToken(normalizedTheme, 'editor-danger-foreground') ?? baseFields.dangerForeground,
+ base: readToken(normalizedTheme, 'editor-base') ?? readToken(normalizedTheme, 'color-bg') ?? baseFields.base,
+ baseForeground: readToken(normalizedTheme, 'editor-base-foreground') ?? readToken(normalizedTheme, 'color-text') ?? baseFields.baseForeground,
+ card: readToken(normalizedTheme, 'editor-card') ?? readToken(normalizedTheme, 'color-bg-container', 'card.bg') ?? baseFields.card,
+ cardForeground: readToken(normalizedTheme, 'editor-card-foreground', 'card.header-color') ?? baseFields.cardForeground,
+ popover: readToken(normalizedTheme, 'editor-popover') ?? readToken(normalizedTheme, 'color-bg-elevated') ?? baseFields.popover,
+ popoverForeground: readToken(normalizedTheme, 'editor-popover-foreground') ?? baseFields.popoverForeground,
+ muted: readToken(normalizedTheme, 'editor-muted') ?? readToken(normalizedTheme, 'color-bg-spotlight') ?? baseFields.muted,
+ mutedForeground: readToken(normalizedTheme, 'editor-muted-foreground') ?? readToken(normalizedTheme, 'color-text-secondary') ?? baseFields.mutedForeground,
+ border: readToken(normalizedTheme, 'editor-border') ?? readToken(normalizedTheme, 'color-border') ?? baseFields.border,
+ input: readToken(normalizedTheme, 'editor-input') ?? readToken(normalizedTheme, 'color-border', 'input.border') ?? baseFields.input,
+ ring: readToken(normalizedTheme, 'editor-ring', 'input.border.focus') ?? baseFields.ring,
+ chart1: readToken(normalizedTheme, 'chart-1') ?? baseFields.chart1,
+ chart2: readToken(normalizedTheme, 'chart-2') ?? baseFields.chart2,
+ chart3: readToken(normalizedTheme, 'chart-3') ?? baseFields.chart3,
+ chart4: readToken(normalizedTheme, 'chart-4') ?? baseFields.chart4,
+ chart5: readToken(normalizedTheme, 'chart-5') ?? baseFields.chart5,
+ sidebar: readToken(normalizedTheme, 'editor-sidebar', 'layout.sidebar-bg') ?? baseFields.sidebar,
+ sidebarForeground: readToken(normalizedTheme, 'editor-sidebar-foreground', 'layout.sidebar-color') ?? baseFields.sidebarForeground,
+ sidebarPrimary: readToken(normalizedTheme, 'editor-sidebar-primary') ?? baseFields.sidebarPrimary,
+ sidebarPrimaryForeground: readToken(normalizedTheme, 'editor-sidebar-primary-foreground') ?? baseFields.sidebarPrimaryForeground,
+ sidebarAccent: readToken(normalizedTheme, 'editor-sidebar-accent') ?? baseFields.sidebarAccent,
+ sidebarAccentForeground: readToken(normalizedTheme, 'editor-sidebar-accent-foreground') ?? baseFields.sidebarAccentForeground,
+ sidebarBorder: readToken(normalizedTheme, 'editor-sidebar-border') ?? baseFields.sidebarBorder,
+ sidebarRing: readToken(normalizedTheme, 'editor-sidebar-ring') ?? baseFields.sidebarRing,
+ fontSans: readToken(normalizedTheme, 'font-family') ?? baseFields.fontSans,
+ fontMono: readToken(normalizedTheme, 'font-family-monospace') ?? baseFields.fontMono,
+ fontSizeBase: readToken(normalizedTheme, 'font-size-base') ?? baseFields.fontSizeBase,
+ lineHeightBase: readToken(normalizedTheme, 'line-height-base') ?? baseFields.lineHeightBase,
+ h1Size: readToken(normalizedTheme, 'h1-font-size') ?? baseFields.h1Size,
+ h2Size: readToken(normalizedTheme, 'h2-font-size') ?? baseFields.h2Size,
+ letterSpacing: readToken(normalizedTheme, 'letter-spacing') ?? baseFields.letterSpacing,
+ radius: readToken(normalizedTheme, 'border-radius') ?? baseFields.radius,
+ shadowCard: readToken(normalizedTheme, 'shadow-card', 'card.shadow') ?? baseFields.shadowCard,
+ shadowFocus: readToken(normalizedTheme, 'shadow-focus', 'input.shadow.focus') ?? toRgba(baseFields.primary, 0.22),
+ buttonRadius: readToken(normalizedTheme, 'border-radius', 'button.radius') ?? baseFields.buttonRadius,
+ inputRadius: readToken(normalizedTheme, 'border-radius', 'input.radius') ?? baseFields.inputRadius,
+ cardRadius: readToken(normalizedTheme, 'border-radius', 'card.radius') ?? baseFields.cardRadius,
+ fieldPaddingSm:
+ readComponentFirst(normalizedTheme, 'input.padding-inline-sm', 'control.padding-inline.sm')
+ ?? baseFields.fieldPaddingSm,
+ fieldPaddingMd:
+ readComponentFirst(normalizedTheme, 'input.padding-inline-md', 'control.padding-inline.md')
+ ?? readToken(normalizedTheme, 'spacing-4')
+ ?? baseFields.fieldPaddingMd,
+ fieldPaddingLg:
+ readComponentFirst(normalizedTheme, 'input.padding-inline-lg', 'control.padding-inline.lg')
+ ?? baseFields.fieldPaddingLg,
+ buttonPaddingSm:
+ readComponentFirst(normalizedTheme, 'button.padding-inline-sm', 'control.padding-inline.sm')
+ ?? baseFields.buttonPaddingSm,
+ buttonPaddingMd:
+ readComponentFirst(normalizedTheme, 'button.padding-inline-md', 'control.padding-inline.md')
+ ?? baseFields.buttonPaddingMd,
+ buttonPaddingLg:
+ readComponentFirst(normalizedTheme, 'button.padding-inline-lg', 'control.padding-inline.lg')
+ ?? baseFields.buttonPaddingLg,
+ fieldHeightSm:
+ readComponentFirst(normalizedTheme, 'input.height.sm', 'control.height.sm')
+ ?? baseFields.fieldHeightSm,
+ fieldHeightMd:
+ readComponentFirst(normalizedTheme, 'input.height.md', 'control.height.md')
+ ?? readToken(normalizedTheme, 'height-md')
+ ?? baseFields.fieldHeightMd,
+ fieldHeightLg:
+ readComponentFirst(normalizedTheme, 'input.height.lg', 'control.height.lg')
+ ?? baseFields.fieldHeightLg,
+ buttonHeightSm:
+ readComponentFirst(normalizedTheme, 'button.height.sm', 'control.height.sm')
+ ?? baseFields.buttonHeightSm,
+ buttonHeightMd:
+ readComponentFirst(normalizedTheme, 'button.height.md', 'control.height.md')
+ ?? readToken(normalizedTheme, 'height-md')
+ ?? baseFields.buttonHeightMd,
+ buttonHeightLg:
+ readComponentFirst(normalizedTheme, 'button.height.lg', 'control.height.lg')
+ ?? baseFields.buttonHeightLg,
+ cardPadding: readToken(normalizedTheme, 'spacing-5', 'card.body-padding') ?? baseFields.cardPadding,
},
};
}
diff --git a/apps/docs/src/containers/theme-studio/theme-studio.scss b/apps/docs/src/containers/theme-studio/theme-studio.scss
index 4b200041..fd11b4ae 100644
--- a/apps/docs/src/containers/theme-studio/theme-studio.scss
+++ b/apps/docs/src/containers/theme-studio/theme-studio.scss
@@ -76,10 +76,7 @@
}
.theme-studio__import-textarea {
- min-height: min(56vh, 620px);
- max-height: min(56vh, 620px);
- overflow: auto;
- resize: vertical;
+ min-height: 50vh;
}
.theme-studio__topbar-actions {
@@ -234,6 +231,18 @@
gap: 8px;
}
+.theme-studio__subgroup-title {
+ display: block;
+ margin-top: 2px;
+ margin-bottom: 2px;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.4;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--editor-muted-foreground);
+}
+
.theme-studio__field {
display: block;
}
@@ -416,6 +425,7 @@
}
.theme-studio__advanced-collapse {
+ margin-bottom: 12px;
border: 1px solid var(--ty-color-border-secondary, var(--ty-color-border));
border-radius: 12px;
background: linear-gradient(
@@ -598,6 +608,144 @@
gap: var(--theme-studio-block-gap);
}
+.theme-studio__cards-scene {
+ display: grid;
+ grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.theme-studio__cards-column {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
+}
+
+.theme-studio__cards-top-grid,
+.theme-studio__cards-top-pair,
+.theme-studio__cards-bottom-pair {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+ align-items: stretch;
+}
+
+.theme-studio__cards-bottom-pair {
+ margin-top: auto;
+}
+
+.theme-studio__cards-panel.ty-card,
+.theme-studio__cards-inline-card.ty-card {
+ border-radius: calc(var(--editor-card-radius) + 2px);
+}
+
+.theme-studio__cards-panel.ty-card > .ty-card__body,
+.theme-studio__cards-inline-card.ty-card > .ty-card__body {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px;
+}
+
+.theme-studio__cards-panel .ty-typography-heading {
+ margin: 0;
+}
+
+.theme-studio__cards-panel .ty-input {
+ width: 100%;
+}
+
+.theme-studio__cards-panel > .ty-card__body > .ty-btn,
+.theme-studio__cards-panel .theme-studio__form-stack > .ty-btn {
+ width: 100%;
+}
+
+.theme-studio__cards-panel_revenue.ty-card > .ty-card__body {
+ gap: 6px;
+ min-height: 132px;
+}
+
+.theme-studio__cards-panel_goal.ty-card > .ty-card__body {
+ gap: 10px;
+ min-height: 172px;
+}
+
+.theme-studio__cards-panel-head,
+.theme-studio__cards-copy-block {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.theme-studio__cards-panel-head .ty-typography-text-secondary,
+.theme-studio__cards-copy-block .ty-typography-text-secondary,
+.theme-studio__cards-inline-card .ty-typography-text-secondary {
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.theme-studio__cards-plan-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.theme-studio__cards-plan-option {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ padding: 8px;
+ border-radius: 8px;
+ border: 1px solid var(--editor-border);
+ background: color-mix(in srgb, var(--editor-card), var(--editor-base) 8%);
+}
+
+.theme-studio__cards-plan-option strong,
+.theme-studio__cards-plan-option small {
+ display: block;
+}
+
+.theme-studio__cards-plan-option small {
+ margin-top: 2px;
+ color: var(--editor-muted-foreground);
+}
+
+.theme-studio__cards-actions-end,
+.theme-studio__cards-auth-actions,
+.theme-studio__cards-share-row,
+.theme-studio__cards-goal-actions {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+
+.theme-studio__cards-actions-end {
+ justify-content: flex-end;
+}
+
+.theme-studio__cards-auth-actions .ty-btn {
+ flex: 1 1 0;
+}
+
+.theme-studio__cards-divider-label {
+ font-size: 10px;
+ color: var(--editor-muted-foreground);
+}
+
+.theme-studio__cards-panel_calendar.ty-card > .ty-card__body {
+ min-height: 172px;
+ padding: 10px;
+}
+
+.theme-studio__cards-panel_calendar .ty-calendar {
+ width: 100%;
+}
+
+.theme-studio__cards-panel_calendar .ty-calendar_card {
+ border: 0;
+}
+
.theme-studio__preview-grid_cards > * {
min-height: 188px;
}
@@ -644,22 +792,151 @@
.theme-studio__goal-display {
display: flex;
flex-direction: column;
- margin: 14px 0;
+ margin: 0;
+ align-items: center;
}
.theme-studio__goal-display span {
- font-size: 36px;
+ font-size: 34px;
font-weight: 700;
+ line-height: 1;
}
.theme-studio__goal-display small {
+ font-size: 10px;
+ letter-spacing: 0.08em;
color: var(--editor-muted-foreground);
}
+.theme-studio__cards-goal-header {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr) 28px;
+ align-items: center;
+ gap: 8px;
+}
+
+.theme-studio__cards-goal-circle.ty-btn {
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+ padding: 0;
+ border-radius: 999px;
+}
+
+.theme-studio__cards-goal-circle.ty-btn .ty-btn__content {
+ justify-content: center;
+}
+
.theme-studio__goal-footer {
margin-top: 10px;
}
+.theme-studio__cards-table {
+ display: flex;
+ flex-direction: column;
+}
+
+.theme-studio__cards-table-head,
+.theme-studio__cards-table-row {
+ display: grid;
+ grid-template-columns: 18px 88px minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+}
+
+.theme-studio__cards-table-head {
+ padding: 0 0 8px 0;
+ font-size: 11px;
+ color: var(--editor-muted-foreground);
+}
+
+.theme-studio__cards-table-row {
+ padding: 9px 0;
+ border-top: 1px solid var(--editor-border);
+}
+
+.theme-studio__cards-table-row .ty-checkbox {
+ justify-self: start;
+}
+
+.theme-studio__cards-table-status,
+.theme-studio__cards-table-email {
+ min-width: 0;
+}
+
+.theme-studio__cards-table-email {
+ overflow: hidden;
+ color: var(--editor-muted-foreground);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.theme-studio__cards-people-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.theme-studio__cards-people-list .theme-studio__member-row {
+ padding-block: 10px;
+ border-top: 1px solid var(--editor-border);
+}
+
+.theme-studio__cards-people-list .theme-studio__member-row:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.theme-studio__cards-chat-head {
+ margin-bottom: 2px;
+}
+
+.theme-studio__member-row_compact {
+ padding: 0;
+}
+
+.theme-studio__cards-panel .theme-studio__member-row {
+ gap: 10px;
+ padding: 8px 0;
+ align-items: center;
+}
+
+.theme-studio__cards-panel .theme-studio__member-copy .ty-typography-text-secondary {
+ font-size: 10px;
+}
+
+.theme-studio__cards-panel .ty-avatar {
+ flex: 0 0 auto;
+}
+
+.theme-studio__cards-member-select,
+.theme-studio__cards-inline-action,
+.theme-studio__cards-report-select,
+.theme-studio__cards-share-row .ty-btn {
+ width: auto !important;
+ flex: 0 0 auto;
+}
+
+.theme-studio__cards-member-select {
+ min-width: 86px;
+}
+
+.theme-studio__cards-report-select {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.theme-studio__cards-inline-action.ty-btn {
+ min-width: 78px;
+}
+
+.theme-studio__cards-panel .ty-radio {
+ align-items: flex-start;
+}
+
+.theme-studio__cards-panel .ty-radio__label {
+ width: 100%;
+}
+
.theme-studio__member-row {
display: flex;
align-items: center;
@@ -1233,6 +1510,11 @@
.theme-studio__pricing-grid {
grid-template-columns: 1fr;
}
+
+ .theme-studio__cards-scene,
+ .theme-studio__cards-top-grid {
+ grid-template-columns: 1fr;
+ }
}
@media (max-width: 900px) {
diff --git a/apps/docs/src/containers/theme-studio/tweakcn-runtime-presets.ts b/apps/docs/src/containers/theme-studio/tweakcn-runtime-presets.ts
index 444367ef..033345ef 100644
--- a/apps/docs/src/containers/theme-studio/tweakcn-runtime-presets.ts
+++ b/apps/docs/src/containers/theme-studio/tweakcn-runtime-presets.ts
@@ -41,7 +41,7 @@ export const TWEAKCN_RUNTIME_PRESET_SOURCES: TweakcnRuntimePresetSource[] = [
"chart-3": "#52c41a",
"chart-4": "#ff9800",
"chart-5": "#f44336",
- "radius": "0.625rem",
+ "radius": "0.3rem",
"sidebar": "#fafafa",
"sidebar-foreground": "rgba(0, 0, 0, 0.85)",
"sidebar-primary": "#6e41bf",
@@ -93,7 +93,7 @@ export const TWEAKCN_RUNTIME_PRESET_SOURCES: TweakcnRuntimePresetSource[] = [
"chart-3": "#49aa19",
"chart-4": "#d89614",
"chart-5": "#d32029",
- "radius": "0.625rem",
+ "radius": "0.3rem",
"sidebar": "#1f1f1f",
"sidebar-foreground": "rgba(255, 255, 255, 0.85)",
"sidebar-primary": "#9065d0",
diff --git a/apps/docs/src/containers/theme-studio/types.ts b/apps/docs/src/containers/theme-studio/types.ts
index 8bde0d5a..173db777 100644
--- a/apps/docs/src/containers/theme-studio/types.ts
+++ b/apps/docs/src/containers/theme-studio/types.ts
@@ -57,8 +57,18 @@ export interface ThemeEditorFields {
buttonRadius: string;
inputRadius: string;
cardRadius: string;
- buttonPaddingX: string;
- inputHeight: string;
+ fieldPaddingSm: string;
+ fieldPaddingMd: string;
+ fieldPaddingLg: string;
+ buttonPaddingSm: string;
+ buttonPaddingMd: string;
+ buttonPaddingLg: string;
+ fieldHeightSm: string;
+ fieldHeightMd: string;
+ fieldHeightLg: string;
+ buttonHeightSm: string;
+ buttonHeightMd: string;
+ buttonHeightLg: string;
cardPadding: string;
}
@@ -88,7 +98,7 @@ export type SliderFieldConfig = {
min: number;
max: number;
step: number;
- unit?: 'px' | 'em';
+ unit?: 'px' | 'em' | 'rem';
};
export type ThemeEditorColorGroup = {
diff --git a/apps/docs/src/utils/theme-document.ts b/apps/docs/src/utils/theme-document.ts
index a2d54efe..76f130dd 100644
--- a/apps/docs/src/utils/theme-document.ts
+++ b/apps/docs/src/utils/theme-document.ts
@@ -1,16 +1,6 @@
import type { ThemeDocument } from '@tiny-design/react';
-import tokenRegistry from '@tiny-design/tokens/registry';
-import lightTheme from '../../../../packages/tokens/source/themes/light.json';
-import darkTheme from '../../../../packages/tokens/source/themes/dark.json';
-
-type TokenRegistryEntry = {
- key: string;
- defaultValue: string | number;
-};
-
-type TokenRegistryDocument = {
- tokens: TokenRegistryEntry[];
-};
+import presets from '@tiny-design/tokens/presets';
+import { resolveTheme } from '@tiny-design/tokens/resolve-theme';
export type ThemeTokenChange = {
key: string;
@@ -24,55 +14,20 @@ export type ThemeTokenComparison = ThemeTokenChange & {
};
const STUDIO_THEME_DOCUMENT_KEY = 'ty-theme-studio-document';
-const BASE_THEME_BY_ID = {
- 'tiny-light': lightTheme,
- 'tiny-dark': darkTheme,
-} as const;
-
-const REGISTRY_TOKENS = (tokenRegistry as TokenRegistryDocument).tokens;
-const SOURCE_VALUES = REGISTRY_TOKENS.reduce
>((acc, token) => {
- acc[token.key] = String(token.defaultValue);
- return acc;
-}, {});
-
-function componentTokenKeyToCssVar(key: string): string {
+const BASE_THEME_BY_ID = presets as Record;
+
+function tokenKeyToCssVar(key: string): string {
return `--ty-${key.replace(/\./g, '-')}`;
}
function getBaseTheme(theme: ThemeDocument): ThemeDocument {
if (theme.extends && theme.extends in BASE_THEME_BY_ID) {
- return BASE_THEME_BY_ID[theme.extends as keyof typeof BASE_THEME_BY_ID] as ThemeDocument;
+ return BASE_THEME_BY_ID[theme.extends];
}
return theme.mode === 'dark'
- ? darkTheme as ThemeDocument
- : lightTheme as ThemeDocument;
-}
-
-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;
+ ? BASE_THEME_BY_ID['tiny-dark']
+ : BASE_THEME_BY_ID['tiny-light'];
}
export function buildThemeDocumentFromSeeds(
@@ -123,36 +78,7 @@ export function mergeThemeDocuments(
}
export function resolveThemeDocument(theme: ThemeDocument): Record {
- const baseTheme = getBaseTheme(theme);
- const baseSemantic = baseTheme.tokens?.semantic ?? {};
- const baseComponents = baseTheme.tokens?.components ?? {};
- const semantic = theme.tokens?.semantic ?? {};
- const components = theme.tokens?.components ?? {};
-
- const rawValues: Record = { ...SOURCE_VALUES };
-
- for (const [key, value] of Object.entries(baseSemantic)) rawValues[key] = String(value);
- for (const [key, value] of Object.entries(baseComponents)) rawValues[key] = String(value);
- for (const [key, value] of Object.entries(semantic)) rawValues[key] = String(value);
- for (const [key, value] of Object.entries(components)) rawValues[key] = String(value);
-
- const cache = new Map();
- const resolved: Record = {};
-
- for (const token of REGISTRY_TOKENS) {
- const value = resolveTokenValue(token.key, rawValues, cache, new Set());
- resolved[token.key.includes('.') ? componentTokenKeyToCssVar(token.key) : `--ty-${token.key}`] = value;
- }
-
- for (const [key, value] of Object.entries(semantic)) {
- if (!(key in SOURCE_VALUES)) resolved[`--ty-${key}`] = String(value);
- }
-
- for (const [key, value] of Object.entries(components)) {
- if (!(key in SOURCE_VALUES)) resolved[componentTokenKeyToCssVar(key)] = String(value);
- }
-
- return resolved;
+ return resolveTheme(theme).cssVars;
}
export function generateThemeDocumentJSON(theme: ThemeDocument): string {
@@ -171,7 +97,7 @@ export function listChangedThemeTokens(theme: ThemeDocument): ThemeTokenChange[]
key,
category: 'component' as const,
value: String(value),
- cssVar: componentTokenKeyToCssVar(key),
+ cssVar: tokenKeyToCssVar(key),
}));
return [...semanticEntries, ...componentEntries];
@@ -187,7 +113,7 @@ export function generateThemeCssVariables(theme: ThemeDocument): string {
}
export function compareThemeAgainstBase(theme: ThemeDocument): ThemeTokenComparison[] {
- const baseResolved = resolveThemeDocument(getBaseTheme(theme));
+ const baseResolved = resolveTheme(getBaseTheme(theme)).cssVars;
return listChangedThemeTokens(theme).map((change) => ({
...change,
diff --git a/apps/pro/src/app/globals.scss b/apps/pro/src/app/globals.scss
index 3c62bdd0..95bb9c30 100644
--- a/apps/pro/src/app/globals.scss
+++ b/apps/pro/src/app/globals.scss
@@ -1,6 +1,13 @@
@use '@tiny-design/tokens/scss/base' as *;
@use 'style/component' as *;
+:root {
+ --font-heading:
+ 'Avenir Next', avenir, montserrat, 'Segoe UI', 'Helvetica Neue', arial, sans-serif;
+ --font-body:
+ 'Aptos', 'Avenir Next', 'Segoe UI', inter, roboto, 'Helvetica Neue', arial, sans-serif;
+}
+
*,
*::before,
*::after {
diff --git a/apps/pro/src/app/layout.tsx b/apps/pro/src/app/layout.tsx
index fe2f40b1..549eb9bf 100644
--- a/apps/pro/src/app/layout.tsx
+++ b/apps/pro/src/app/layout.tsx
@@ -1,23 +1,8 @@
import type { Metadata } from 'next';
-import { Bricolage_Grotesque, DM_Sans } from 'next/font/google';
import { ThemeScript } from '../components/theme-script';
import { SiteHeader } from '../components/layout/site-header';
import './globals.scss';
-const heading = Bricolage_Grotesque({
- subsets: ['latin'],
- variable: '--font-heading',
- display: 'swap',
- weight: ['400', '500', '600', '700', '800'],
-});
-
-const body = DM_Sans({
- subsets: ['latin'],
- variable: '--font-body',
- display: 'swap',
- weight: ['400', '500', '600', '700'],
-});
-
export const metadata: Metadata = {
title: 'Tiny Design Pro',
description: 'Beautiful, ready-to-use UI blocks built with Tiny Design components.',
@@ -25,7 +10,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
+
diff --git a/packages/react/jest.config.js b/packages/react/jest.config.js
index 3ce176f5..f7df5739 100644
--- a/packages/react/jest.config.js
+++ b/packages/react/jest.config.js
@@ -15,7 +15,6 @@ module.exports = {
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.test.json',
- isolatedModules: true,
}],
},
};
diff --git a/packages/react/src/auto-complete/__tests__/__snapshots__/autocomplete.test.tsx.snap b/packages/react/src/auto-complete/__tests__/__snapshots__/autocomplete.test.tsx.snap
index e01d6fcb..e45f95ea 100644
--- a/packages/react/src/auto-complete/__tests__/__snapshots__/autocomplete.test.tsx.snap
+++ b/packages/react/src/auto-complete/__tests__/__snapshots__/autocomplete.test.tsx.snap
@@ -9,8 +9,11 @@ exports[` should match the snapshot 1`] = `
class="ty-input ty-input_md"
>
diff --git a/packages/react/src/auto-complete/__tests__/autocomplete.test.tsx b/packages/react/src/auto-complete/__tests__/autocomplete.test.tsx
index 69243628..b2806306 100644
--- a/packages/react/src/auto-complete/__tests__/autocomplete.test.tsx
+++ b/packages/react/src/auto-complete/__tests__/autocomplete.test.tsx
@@ -1,4 +1,4 @@
-import { render, fireEvent } from '@testing-library/react';
+import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import AutoComplete from '../index';
const options = [
@@ -69,6 +69,21 @@ describe('
', () => {
expect(onChange).toHaveBeenCalledWith('Banana');
});
+ it('should expose combobox aria relationships when navigating options', () => {
+ const { container } = render(
);
+ const input = container.querySelector('input') as HTMLInputElement;
+ const wrapper = container.firstChild as HTMLElement;
+
+ fireEvent.keyDown(wrapper, { key: 'ArrowDown' });
+
+ const listboxId = input.getAttribute('aria-controls');
+ expect(input).toHaveAttribute('role', 'combobox');
+ expect(input).toHaveAttribute('aria-expanded', 'true');
+ expect(listboxId).toBeTruthy();
+ expect(input).toHaveAttribute('aria-activedescendant', `${listboxId}-option-0`);
+ expect(document.getElementById(`${listboxId}-option-0`)).toHaveTextContent('Apple');
+ });
+
it('should close on Escape', () => {
const { container } = render(
);
const wrapper = container.firstChild as HTMLElement;
@@ -76,6 +91,22 @@ describe('
', () => {
expect(wrapper).not.toHaveClass('ty-auto-complete_open');
});
+ it('should close on outside click', async () => {
+ const { container } = render(
+
+ );
+
+ expect(getOptions().length).toBeGreaterThan(0);
+ fireEvent.click(screen.getByText('Outside'));
+
+ await waitFor(() => {
+ expect(container.querySelector('.ty-auto-complete')).not.toHaveClass('ty-auto-complete_open');
+ });
+ });
+
it('should handle disabled state', () => {
const { container } = render(
);
expect(container.firstChild).toHaveClass('ty-auto-complete_disabled');
@@ -110,6 +141,39 @@ describe('
', () => {
expect(container.firstChild).toHaveClass('ty-auto-complete_open');
});
+ it('should call onOpenChange when focus opens and outside click closes the popup', async () => {
+ const onOpenChange = jest.fn();
+ const { container } = render(
+
+ );
+
+ const input = container.querySelector('input') as HTMLInputElement;
+ fireEvent.focus(input);
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+
+ fireEvent.click(screen.getByText('Outside'));
+
+ await waitFor(() => {
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ it('should respect controlled open when Escape is pressed', () => {
+ const onOpenChange = jest.fn();
+ const { container } = render(
+
+ );
+
+ const wrapper = container.firstChild as HTMLElement;
+ fireEvent.keyDown(wrapper, { key: 'Escape' });
+
+ expect(container.firstChild).toHaveClass('ty-auto-complete_open');
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
it('should call onSearch callback', () => {
const onSearch = jest.fn();
const { container } = render(
', () => {
expect(items[0]).toHaveTextContent('Cherry');
});
+ it('should render empty content as a listbox option when no results match', () => {
+ const { container } = render(
+
+ );
+ const input = container.querySelector('input')!;
+ fireEvent.change(input, { target: { value: 'zzz' } });
+
+ const listbox = document.querySelector('.ty-auto-complete__dropdown');
+ const emptyItem = document.querySelector('.ty-auto-complete__empty');
+
+ expect(listbox?.querySelector('li.ty-auto-complete__empty')).toBeInTheDocument();
+ expect(emptyItem).toHaveAttribute('role', 'option');
+ expect(emptyItem).toHaveAttribute('aria-disabled', 'true');
+ expect(emptyItem).toHaveTextContent('No data');
+ });
+
it('should not select disabled option', () => {
const onSelect = jest.fn();
const disabledOptions = [
diff --git a/packages/react/src/auto-complete/auto-complete.tsx b/packages/react/src/auto-complete/auto-complete.tsx
index 270a35dd..cda40458 100644
--- a/packages/react/src/auto-complete/auto-complete.tsx
+++ b/packages/react/src/auto-complete/auto-complete.tsx
@@ -1,6 +1,5 @@
-import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useContext, useEffect, useId, useRef, useState } from 'react';
import classNames from 'classnames';
-import { useClickOutside } from '../_utils/hooks';
import { useCombobox } from '../_utils/useCombobox';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
@@ -23,6 +22,7 @@ const AutoComplete = React.forwardRef