+
{#each TABS as tab (tab.id)}
{@const modified = tabHasOverride(tab.id)}
{:else}
{#each section.controls.filter((c) => exists(c.token)) as c (c.token)}
-
+
{/each}
{/if}
diff --git a/configurator/src/components/editors/ColorStudio.svelte b/configurator/src/components/editors/ColorStudio.svelte
new file mode 100644
index 00000000..963b7942
--- /dev/null
+++ b/configurator/src/components/editors/ColorStudio.svelte
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+ Surface cardText, link, border and subtle background preview.
Example link
+ Status message
+
+
+
+ LightReadable surfaceBase, neutral and brand sources.
+ DarkAuto-derived pairOverride only when the automatic pair needs art direction.
+
+
+
+ {#each main as { key, label } (key)}{/each}
+ {#each status as { key, label } (key)}{/each}
+
+
+
+ Role mapHow brand sources become real interface decisions.
+
+ {#each COLOR_ROLE_GROUPS as group (group.section)}
+
+ {group.section}
+ {#each group.roles as role (role.token)}
+
+ {/each}
+
+ {/each}
+
+
+
+
Main colors
{#each main as {key,label} (key)}{/each}
+
Semantic colors
{#each status as {key,label} (key)}{/each}
+
Contrast & palette tuning
{#each tuning as token (token.name)}{/each}
+
Semantic role preview
+
Generated shade ramp
+
+
+
+
diff --git a/configurator/src/components/editors/EffectsStudio.svelte b/configurator/src/components/editors/EffectsStudio.svelte
new file mode 100644
index 00000000..f248d42f
--- /dev/null
+++ b/configurator/src/components/editors/EffectsStudio.svelte
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+ Glass & blurReadable media card
Scrim, blur and muted states preview.
+ lowmediumhigh
+
+
+
+ {#each states as state}
+ {state}{state === 'disabled' ? 'Disabled action' : state === 'pending' ? 'Pending feedback' : 'Readable state'}
+ {/each}
+
+
+
+ Scrim overlayText over image
Direction and color keep content readable without manual gradients.
+ Without scrimHarder to read
Preview makes the difference obvious.
+
+
+
+
+
+
+
+
+
diff --git a/configurator/src/components/editors/LayoutStudio.svelte b/configurator/src/components/editors/LayoutStudio.svelte
new file mode 100644
index 00000000..b7087b6a
--- /dev/null
+++ b/configurator/src/components/editors/LayoutStudio.svelte
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+ {#each devices as device (device)}{device}{/each}
+
+
+ wide
+ default
+ prose
+ narrow
+
+
+
+
+
+
+
Card 1
+
Card 2
+
Card 3
+
Responsive gridCards collapse from multi-column to readable stacks.
+
+
+
+
+
+ Reading width
+ Readable prose stays constrained
+ Text should not stretch across the full container. The prose rail keeps editorial content comfortable.
+
+
+ Global anchors
+ Sticky offset
+
+
+
+
+
viewportcontainergrid columnsreading widthsticky offsets
+
+
+
+
+
diff --git a/configurator/src/components/editors/MotionStudio.svelte b/configurator/src/components/editors/MotionStudio.svelte
new file mode 100644
index 00000000..cc151a88
--- /dev/null
+++ b/configurator/src/components/editors/MotionStudio.svelte
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+ Speed scaleMotion scale controls the feel of every duration without editing each preset by hand.
+
+ {#each durations as duration}
+
{duration}--sf-duration-{duration}
+ {/each}
+
+
+
+
+ {#each easings as easing}
+ {easing}--sf-ease-{easing}
+ {/each}
+
+
+
+ enter
+ hover
+ exit
+ shimmer
+
+
+
+ Reduced motion check
+ When motion scale approaches zero, the interface should still communicate state changes with opacity and color.
+
+
+
+
+
+
+
+
diff --git a/configurator/src/components/editors/ShadowStudio.svelte b/configurator/src/components/editors/ShadowStudio.svelte
new file mode 100644
index 00000000..64626173
--- /dev/null
+++ b/configurator/src/components/editors/ShadowStudio.svelte
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+ Elevation stackCompare every shadow level on the same surface and tune global strength/lightness once.
+
+ {#each levels as level}
{level}surface{/each}
+
+
+
+
+ CardSurface shadowSubtle depth for normal content.
+ PopoverFloating layerMedium elevation above UI.
+ DialogModal overlayStrong depth for interruption.
+
+
+
+
+
+
+
+
+
diff --git a/configurator/src/components/editors/ShapeStudio.svelte b/configurator/src/components/editors/ShapeStudio.svelte
new file mode 100644
index 00000000..b25374cb
--- /dev/null
+++ b/configurator/src/components/editors/ShapeStudio.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+ Radius systemOne scale controls components from sharp utility chips to fully pill-shaped actions.
+
+ {#each radii as radius (radius)}
+
{radius}--sf-radius-{radius}
+ {/each}
+
+
+
+
+ {#each components as item (item)}
+
+ {item}
+ {item === 'Input' ? 'Field outline' : item === 'Card' ? 'Surface radius' : 'Interactive shape'}
+
+ {/each}
+
+
+
+ Border stack
+ Divider rhythmContent before divider
Content after divider
+ Focus ringWidth + offset preview
+
+
+
+
+
+
+
diff --git a/configurator/src/components/editors/SpacingStudio.svelte b/configurator/src/components/editors/SpacingStudio.svelte
new file mode 100644
index 00000000..66b06a80
--- /dev/null
+++ b/configurator/src/components/editors/SpacingStudio.svelte
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+ Space map
+ Every step uses the same multiplier, so the whole framework keeps one rhythm.
+
+
+ {#each steps as s, i}
+
{s}
+ {/each}
+
+
+
+
+
+ Section padding
+
+
Marketing section
+
Large vertical space should feel intentional, not random.
+
+
+
+ Content gap
+ Stack rhythm
+ Paragraph spacing
+ List spacing
+
+
+
+
+
+
+
+
+
+
+
diff --git a/configurator/src/components/editors/StudioControls.svelte b/configurator/src/components/editors/StudioControls.svelte
new file mode 100644
index 00000000..d6de97bb
--- /dev/null
+++ b/configurator/src/components/editors/StudioControls.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {#each groups as group (group.title)}
+
overrides[token.name] != null).length}>
+
+ {#each group.tokens as token (token.name)}
+
+ {/each}
+
+
+ {/each}
+
+
+
diff --git a/configurator/src/components/editors/StudioFrame.svelte b/configurator/src/components/editors/StudioFrame.svelte
new file mode 100644
index 00000000..0b58a989
--- /dev/null
+++ b/configurator/src/components/editors/StudioFrame.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
{eyebrow}
+
{title}
+ {#if description}
{description}{/if}
+
+ 01 Preview02 Tune03 Verify
+
+
+ {@render children?.()}
+
+
+
diff --git a/configurator/src/components/editors/StudioWorkflow.svelte b/configurator/src/components/editors/StudioWorkflow.svelte
new file mode 100644
index 00000000..c9fdd406
--- /dev/null
+++ b/configurator/src/components/editors/StudioWorkflow.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/configurator/src/components/editors/TypographyStudio.svelte b/configurator/src/components/editors/TypographyStudio.svelte
new file mode 100644
index 00000000..7aa7c032
--- /dev/null
+++ b/configurator/src/components/editors/TypographyStudio.svelte
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
mobiletabletdesktop
+
The quick brown fox
+
Jumps over the lazy dog
+
Typography at every scale
+
Fluid, readable, precise
+
Fine-tune each heading level
+
Pixel-perfect control
+
Body: The quick brown fox jumps over the lazy dog. A short paragraph shows body rhythm, line height, measure and spacing at a glance.
+
+
+
+
Heading wrapBalanced title blocks reduce awkward one-word lines.
+
Body rhythmLine height and content gap define reading comfort.
+
Code / monoconst token = true;
+
+
+
+
+
+
+
diff --git a/configurator/src/lib/controlSchema.js b/configurator/src/lib/controlSchema.js
new file mode 100644
index 00000000..d519ae83
--- /dev/null
+++ b/configurator/src/lib/controlSchema.js
@@ -0,0 +1,52 @@
+import { parseLength } from './length.js';
+
+const EXPLICIT = {
+ '--sf-heading-text-wrap': { control: 'select', preview: 'wrap', options: ['balance', 'pretty', 'wrap', 'normal'], label: 'Heading wrap', description: 'How headings wrap when space gets tight.' },
+ '--sf-body-text-wrap': { control: 'select', preview: 'wrap', options: ['pretty', 'balance', 'wrap', 'normal'], label: 'Body wrap', description: 'Readable paragraph wrapping behaviour.' },
+ '--sf-scrim-direction': { control: 'select', preview: 'gradient', options: ['to top', 'to bottom', 'to right', 'to left', '135deg'], label: 'Scrim direction', description: 'Gradient direction used for media overlays.' },
+ '--sf-border-style': { control: 'select', preview: 'border', options: ['solid', 'dashed', 'dotted', 'double'], label: 'Border style' },
+ '--sf-divider-style': { control: 'select', preview: 'border', options: ['solid', 'dashed', 'dotted', 'double'], label: 'Divider style' },
+ '--sf-object-fit': { control: 'select', preview: 'media', options: ['cover', 'contain', 'fill', 'none', 'scale-down'], label: 'Object fit' },
+ '--sf-object-position': { control: 'select', preview: 'media', options: ['50% 50%', 'top', 'bottom', 'left', 'right', 'center'], label: 'Object position' },
+ '--sf-motion-scale': { control: 'number', preview: 'motion', label: 'Motion scale', description: 'Global multiplier for animation and transition durations.' },
+ '--sf-radius-scale': { control: 'number', preview: 'radius', label: 'Radius scale', description: 'Global multiplier for the corner radius ramp.' },
+ '--sf-space-scale': { control: 'number', preview: 'spacing', label: 'Space scale', description: 'Global multiplier for the spacing ramp.' },
+ '--sf-section-scale': { control: 'number', preview: 'spacing', label: 'Section scale', description: 'Multiplier for large section padding.' },
+};
+
+function titleFromName(name = '') {
+ return name.replace(/^--sf-/, '').replace(/-/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
+}
+
+export function controlForToken(token, override = {}) {
+ const name = token?.name ?? '';
+ const explicit = EXPLICIT[name] ?? {};
+ const value = token?.value ?? '';
+ let schema = { label: titleFromName(name), description: token?.note || token?.description || '', control: 'text', preview: 'text' };
+
+ if (/ease/.test(name)) {
+ schema = { ...schema, control: 'select', preview: 'motion', options: ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'cubic-bezier(.2,.8,.2,1)'] };
+ } else if (/text-wrap/.test(name)) {
+ schema = { ...schema, control: 'select', preview: 'wrap', options: ['balance', 'pretty', 'wrap', 'normal'] };
+ } else if (/scrim|gradient/.test(name)) {
+ schema = { ...schema, control: /direction/.test(name) ? 'select' : 'text', preview: 'gradient' };
+ } else if (/color|palette|hsl|oklch|hex|focus|link|caret|border/.test(name) && /(#[0-9a-f]|oklch|oklab|hsl|rgb|var\(--sf-color)/i.test(value + name)) {
+ schema = { ...schema, control: 'color', preview: 'color' };
+ } else if (/font/.test(name)) {
+ schema = { ...schema, control: 'font', preview: 'font' };
+ } else if (/duration|motion-scale|animation|transition/.test(name)) {
+ schema = { ...schema, control: parseLength(value) ? 'length' : 'text', preview: 'motion' };
+ } else if (/radius/.test(name)) {
+ schema = { ...schema, control: parseLength(value) ? 'length' : 'number', preview: 'radius' };
+ } else if (/shadow/.test(name)) {
+ schema = { ...schema, control: 'text', preview: 'shadow' };
+ } else if (/opacity/.test(name)) {
+ schema = { ...schema, control: 'number', preview: 'opacity' };
+ } else if (/space|gap|gutter|pad|width|height|size|container|leading|tracking|border-width|divider|offset|inset/.test(name) || parseLength(value)) {
+ schema = { ...schema, control: 'length', preview: /leading/.test(name) ? 'line-height' : 'spacing' };
+ } else if (/scale|threshold|bias|weight|z-/.test(name) || /^-?\d*\.?\d+$/.test(value)) {
+ schema = { ...schema, control: 'number', preview: /weight/.test(name) ? 'font' : 'scale' };
+ }
+
+ return { ...schema, ...explicit, ...override };
+}
diff --git a/configurator/src/lib/studioSchema.js b/configurator/src/lib/studioSchema.js
new file mode 100644
index 00000000..17f62e83
--- /dev/null
+++ b/configurator/src/lib/studioSchema.js
@@ -0,0 +1,76 @@
+import { tokenByName } from './model.js';
+
+export const TYPOGRAPHY_PANELS = [
+ {
+ id: 'Overview',
+ label: 'Overview',
+ description: 'Font stacks, global rhythm and heading behavior for the whole type system.',
+ tokens: ['--sf-font-body', '--sf-font-heading', '--sf-font-mono', '--sf-leading-normal', '--sf-font-weight-heading', '--sf-heading-text-wrap'],
+ },
+ {
+ id: 'Fluid scale',
+ label: 'Fluid scale',
+ description: 'Tune the source values that drive the generated text ramp.',
+ tokens: ['--sf-text-base-min', '--sf-text-base-max', '--sf-text-ratio-min', '--sf-text-ratio-max', '--sf-text-scale', '--sf-text-display-scale'],
+ },
+ {
+ id: 'Headings',
+ label: 'Headings',
+ description: 'Heading aliases, line-height, tracking and max-width constraints.',
+ tokens: ['--sf-h1-size', '--sf-h1-line-height', '--sf-h1-letter-spacing', '--sf-h1-max-width', '--sf-h2-size', '--sf-h2-line-height', '--sf-h3-size', '--sf-h3-line-height', '--sf-heading-text-wrap'],
+ },
+ {
+ id: 'Body',
+ label: 'Body',
+ description: 'Body copy, prose spacing and readable line length.',
+ tokens: ['--sf-body-font-size', '--sf-body-line-height', '--sf-body-text-wrap', '--sf-text-m-max-width', '--sf-prose-paragraph', '--sf-prose-heading-gap', '--sf-prose-list-gap'],
+ },
+ {
+ id: 'Code',
+ label: 'Code',
+ description: 'Monospace family and inline-code sizing/padding.',
+ tokens: ['--sf-font-mono', '--sf-code-font-size', '--sf-font-numeric', '--sf-font-features', '--sf-font-variation'],
+ },
+];
+
+export const STUDIO_GROUPS = {
+ spacing: [
+ { title: 'Global scale', hint: 'One multiplier for the complete spacing ramp.', tokens: ['--sf-space-scale', '--sf-section-scale'] },
+ { title: 'Page rhythm', hint: 'The defaults most layouts feel immediately.', tokens: ['--sf-section-pad', '--sf-content-gap', '--sf-gutter', '--sf-gap'] },
+ { title: 'Component rhythm', hint: 'Interior padding and compact UI spacing.', tokens: ['--sf-component-pad', '--sf-space-2xs', '--sf-space-xs', '--sf-space-s', '--sf-space-m'] },
+ ],
+ layout: [
+ { title: 'Containers', hint: 'Main page widths and reading measure.', tokens: ['--sf-container-narrow', '--sf-container-prose', '--sf-container-default', '--sf-container-wide'] },
+ { title: 'Grid & composition', hint: 'Breakpoint-free grid and sidebar defaults.', tokens: ['--sf-grid-min', '--sf-grid-min-s', '--sf-grid-min-l', '--sf-sidebar-width', '--sf-switcher-threshold'] },
+ { title: 'Global anchors', hint: 'Sticky offsets, header height and accessible targets.', tokens: ['--sf-header-height-mobile', '--sf-header-height-desktop', '--sf-touch-target', '--sf-sticky-offset-mobile', '--sf-sticky-offset-desktop'] },
+ ],
+ borders: [
+ { title: 'Radius system', hint: 'Scale every corner from sharp to soft.', tokens: ['--sf-radius-scale', '--sf-radius-xs', '--sf-radius-s', '--sf-radius-m', '--sf-radius-l', '--sf-radius-xl', '--sf-radius-full'] },
+ { title: 'Borders & dividers', hint: 'Strokes that frame cards, fields and rules.', tokens: ['--sf-border-scale', '--sf-border-style', '--sf-border-width-hairline', '--sf-border-width-1', '--sf-border-width-2', '--sf-divider-width', '--sf-divider-style'] },
+ { title: 'Focus ring', hint: 'Keyboard focus affordance lives with shape controls.', tokens: ['--sf-focus-ring-width', '--sf-focus-ring-offset', '--sf-color-border--focus'] },
+ ],
+ shadows: [
+ { title: 'Elevation strength', hint: 'Global tone and lightness for all elevations.', tokens: ['--sf-shadow-strength', '--sf-shadow-lightness', '--sf-shadow-color', '--sf-shadow-glow-color'] },
+ { title: 'Box shadows', hint: 'Individual elevation outputs for cards, popovers and dialogs.', tokens: ['--sf-shadow-xs', '--sf-shadow-s', '--sf-shadow-m', '--sf-shadow-l', '--sf-shadow-xl', '--sf-shadow-2xl'] },
+ { title: 'Text & drop shadows', hint: 'Media and floating asset shadow presets.', tokens: ['--sf-text-shadow-s', '--sf-text-shadow-m', '--sf-text-shadow-l', '--sf-drop-shadow-s', '--sf-drop-shadow-m', '--sf-drop-shadow-l'] },
+ ],
+ motion: [
+ { title: 'Speed scale', hint: 'Set to 0 for near-reduced motion, raise for more expressive interfaces.', tokens: ['--sf-motion-scale', '--sf-duration-none', '--sf-duration-instant', '--sf-duration-fast', '--sf-duration-normal', '--sf-duration-slow', '--sf-duration-slower'] },
+ { title: 'Easing curves', hint: 'The feel of entrance, exit and spring interactions.', tokens: ['--sf-ease-linear', '--sf-ease-in', '--sf-ease-out', '--sf-ease-in-out', '--sf-ease-spring', '--sf-ease-overshoot'] },
+ { title: 'Animation presets', hint: 'Named shorthands consumed by utility classes and components.', tokens: ['--sf-animation-fade-in', '--sf-animation-slide-in-up', '--sf-animation-scale-up', '--sf-animation-float', '--sf-animation-shimmer'] },
+ ],
+ effects: [
+ { title: 'Glass & blur', hint: 'Backdrops and soft focus effects.', tokens: ['--sf-blur'] },
+ { title: 'Opacity states', hint: 'Muted, disabled and pending UI states.', tokens: ['--sf-opacity-muted', '--sf-opacity-disabled', '--sf-state-pending-opacity'] },
+ { title: 'Scrims & masks', hint: 'Readable text over media and scroll fades.', tokens: ['--sf-scrim-color', '--sf-scrim-direction', '--sf-scrim-text-shadow', '--sf-mask-scrim-start', '--sf-mask-scrim-end'] },
+ ],
+};
+
+export function resolveStudioGroups(groups) {
+ return groups
+ .map((group) => ({
+ ...group,
+ tokens: group.tokens.map((name) => tokenByName.get(name)).filter(Boolean),
+ }))
+ .filter((group) => group.tokens.length);
+}
diff --git a/configurator/tests-components/studios.test.js b/configurator/tests-components/studios.test.js
new file mode 100644
index 00000000..e5e10d49
--- /dev/null
+++ b/configurator/tests-components/studios.test.js
@@ -0,0 +1,65 @@
+/**
+ * Component smoke tests for the redesigned Studio editors.
+ *
+ * These are intentionally structural: jsdom cannot evaluate the framework's
+ * relative-color CSS, but it can verify that each studio mounts, exposes its
+ * primary preview surface, and renders the curated control rows users need.
+ */
+import { describe, test, expect, beforeEach } from 'vitest';
+import { render, fireEvent } from '@testing-library/svelte';
+import { tick } from 'svelte';
+import TypographyStudio from '../src/components/editors/TypographyStudio.svelte';
+import ColorStudio from '../src/components/editors/ColorStudio.svelte';
+import SpacingStudio from '../src/components/editors/SpacingStudio.svelte';
+import LayoutStudio from '../src/components/editors/LayoutStudio.svelte';
+import ShapeStudio from '../src/components/editors/ShapeStudio.svelte';
+import ShadowStudio from '../src/components/editors/ShadowStudio.svelte';
+import MotionStudio from '../src/components/editors/MotionStudio.svelte';
+import EffectsStudio from '../src/components/editors/EffectsStudio.svelte';
+import FriendlyControl from '../src/components/FriendlyControl.svelte';
+import { tokenByName } from '../src/lib/model.js';
+import { clearAll, overrides } from '../src/lib/store.svelte.js';
+
+beforeEach(() => clearAll());
+
+const STUDIOS = [
+ ['Typography Studio', TypographyStudio, ['The quick brown fox', 'Fluid scale']],
+ ['Color Studio', ColorStudio, ['Source pairs', 'Role map', 'Auto-derived pair', 'Contrast & palette tuning']],
+ ['Spacing Studio', SpacingStudio, ['Space map', 'Stack rhythm', 'Input gap', 'Global scale']],
+ ['Layout Studio', LayoutStudio, ['Container map', 'Responsive grid', 'Reading width', 'Sticky offset']],
+ ['Shape Studio', ShapeStudio, ['Radius system', 'Focus ring', 'Focused action']],
+ ['Shadow Studio', ShadowStudio, ['Elevation strength', 'Box shadows', 'Media card glow']],
+ ['Motion Studio', MotionStudio, ['Speed scale', 'Easing curves', 'fast']],
+ ['Effects Studio', EffectsStudio, ['Glass & blur', 'Opacity states', 'Readable media card']],
+];
+
+describe('visual studios', () => {
+ for (const [name, Component, expectedText] of STUDIOS) {
+ test(`${name} mounts with preview and curated controls`, () => {
+ const { container, getByText, getAllByText } = render(Component);
+ expect(container.querySelector('.studio')).toBeTruthy();
+ expect(getByText(name)).toBeInTheDocument();
+ for (const text of expectedText) {
+ expect(getAllByText(text).length).toBeGreaterThan(0);
+ }
+ });
+ }
+
+ test('Typography Studio switches scopes and renders active panel controls', async () => {
+ const { getByRole, getByText, getAllByText } = render(TypographyStudio);
+ await fireEvent.click(getByRole('tab', { name: 'Headings' }));
+ expect(getAllByText('Heading aliases, line-height, tracking and max-width constraints.').length).toBeGreaterThan(0);
+ expect(getByText('--sf-h1-size')).toBeInTheDocument();
+ });
+
+ test('FriendlyControl renders schema select controls and writes overrides', async () => {
+ const token = tokenByName.get('--sf-heading-text-wrap');
+ const { getByLabelText } = render(FriendlyControl, { props: { token, showToken: true } });
+ const select = getByLabelText('--sf-heading-text-wrap');
+
+ expect(select.tagName).toBe('SELECT');
+ await fireEvent.change(select, { target: { value: 'pretty' } });
+ await tick();
+ expect(overrides['--sf-heading-text-wrap']).toBe('pretty');
+ });
+});
diff --git a/configurator/tests/studio-schema.test.js b/configurator/tests/studio-schema.test.js
new file mode 100644
index 00000000..fc66e7b0
--- /dev/null
+++ b/configurator/tests/studio-schema.test.js
@@ -0,0 +1,57 @@
+/**
+ * Tripwires for the visual Studio configuration layer.
+ *
+ * The studios are curated by hand, so every listed token must exist and every
+ * group needs enough metadata to render a useful visual editing section.
+ */
+import { test, describe } from 'node:test';
+import assert from 'node:assert/strict';
+import { TYPOGRAPHY_PANELS, STUDIO_GROUPS, resolveStudioGroups } from '../src/lib/studioSchema.js';
+import data from '../src/data/api-index.generated.json' with { type: 'json' };
+
+const known = new Set(data.tokens.map((token) => token.name));
+
+describe('studio schema', () => {
+ test('typography panels have labels, descriptions and real tokens', () => {
+ assert.ok(TYPOGRAPHY_PANELS.length >= 5, 'typography exposes multiple editing scopes');
+ const ids = TYPOGRAPHY_PANELS.map((panel) => panel.id);
+ assert.equal(new Set(ids).size, ids.length, 'typography panel ids are unique');
+
+ for (const panel of TYPOGRAPHY_PANELS) {
+ assert.ok(panel.label?.trim(), `${panel.id} has a label`);
+ assert.ok(panel.description?.trim(), `${panel.id} has a description`);
+ assert.ok(panel.tokens.length > 0, `${panel.id} has tokens`);
+ for (const name of panel.tokens) {
+ assert.ok(known.has(name), `${panel.id}: ${name} is in api-index`);
+ }
+ }
+ });
+
+ test('every studio group has a title, hint and real tokens', () => {
+ for (const [domain, groups] of Object.entries(STUDIO_GROUPS)) {
+ assert.ok(groups.length > 0, `${domain} has studio groups`);
+ const titles = groups.map((group) => group.title);
+ assert.equal(new Set(titles).size, titles.length, `${domain} group titles are unique`);
+
+ for (const group of groups) {
+ assert.ok(group.title?.trim(), `${domain}: group has a title`);
+ assert.ok(group.hint?.trim(), `${domain}/${group.title}: group has a hint`);
+ assert.ok(group.tokens.length > 0, `${domain}/${group.title}: group has tokens`);
+ for (const name of group.tokens) {
+ assert.ok(known.has(name), `${domain}/${group.title}: ${name} is in api-index`);
+ }
+ }
+ }
+ });
+
+ test('resolveStudioGroups removes dead token references without mutating labels', () => {
+ const resolved = resolveStudioGroups([
+ { title: 'Mixed', hint: 'Contains one valid token and one stale token.', tokens: ['--sf-space-scale', '--sf-not-real'] },
+ { title: 'Empty', hint: 'Should disappear.', tokens: ['--sf-not-real'] },
+ ]);
+
+ assert.equal(resolved.length, 1);
+ assert.equal(resolved[0].title, 'Mixed');
+ assert.deepEqual(resolved[0].tokens.map((token) => token.name), ['--sf-space-scale']);
+ });
+});