diff --git a/configurator/src/components/CategoryHeader.svelte b/configurator/src/components/CategoryHeader.svelte new file mode 100644 index 00000000..1020863b --- /dev/null +++ b/configurator/src/components/CategoryHeader.svelte @@ -0,0 +1,33 @@ + + +
+
+
+

Visual framework editor

+

{domain.label}

+

{domain.blurb}

+
+
+ {modifiedCount} overrides + {tokenCount} variables + {#if modifiedCount > 0 && onreset} + + {/if} +
+
+ + diff --git a/configurator/src/components/ControlPreview.svelte b/configurator/src/components/ControlPreview.svelte new file mode 100644 index 00000000..fdcdf56a --- /dev/null +++ b/configurator/src/components/ControlPreview.svelte @@ -0,0 +1,63 @@ + + +
+ {#if type === 'color'} +
+ AaButton +
+ {:else if type === 'font'} +

Ag — The quick brown fox

+ {:else if type === 'radius'} +
+ {:else if type === 'shadow'} +
+ {:else if type === 'motion'} +
motion
+ {:else if type === 'line-height'} +

Readable rhythm
across multiple
text lines.

+ {:else if type === 'wrap'} +

A heading wraps cleanly in a narrow preview.

+ {:else if type === 'opacity'} +
+ {:else if type === 'gradient'} +
+ {:else if type === 'border'} +
+ {:else if type === 'media'} +
+ {:else if type === 'scale'} +
+ {:else} +
+ {/if} +
+ + diff --git a/configurator/src/components/ControlSection.svelte b/configurator/src/components/ControlSection.svelte new file mode 100644 index 00000000..c3f18a19 --- /dev/null +++ b/configurator/src/components/ControlSection.svelte @@ -0,0 +1,26 @@ + + +
+ + + {title} + {#if hint}{hint}{/if} + {#if modifiedCount}{modifiedCount} changed{/if} + +
{@render children?.()}
+
+ + diff --git a/configurator/src/components/DomainPanel.svelte b/configurator/src/components/DomainPanel.svelte index a45dd94b..fd65aea5 100644 --- a/configurator/src/components/DomainPanel.svelte +++ b/configurator/src/components/DomainPanel.svelte @@ -26,22 +26,25 @@ import { domainOf, KNOBS_BY_DOMAIN, DOCS_BASE_URL } from '../lib/domains.js'; import { smartSettingsFor } from '../lib/domainSettings.js'; import { BASIC_BY_DOMAIN } from '../lib/basics.js'; - import { BRAND_COLOR_KEYS } from '../lib/brandColors.js'; import { ui, overrides, patchOverrides } from '../lib/store.svelte.js'; import { STYLE_PRESETS_BY_DOMAIN } from '../lib/stylePresets.js'; import TokenGroup from './TokenGroup.svelte'; import TokenRow from './TokenRow.svelte'; - import BrandColorRow from './BrandColorRow.svelte'; import ScaleGenerator from './ScaleGenerator.svelte'; import QuickKnobs from './QuickKnobs.svelte'; import StylePresetRow from './StylePresetRow.svelte'; - import ColorAssignments from './ColorAssignments.svelte'; - import ShadeRamp from './ShadeRamp.svelte'; import SmartSettings from './SmartSettings.svelte'; - import HeadingEditor from './HeadingEditor.svelte'; - import RadiusEditor from './RadiusEditor.svelte'; - import ContainerBars from './ContainerBars.svelte'; - import Icon from './Icon.svelte'; + import CategoryHeader from './CategoryHeader.svelte'; + import ControlSection from './ControlSection.svelte'; + import FriendlyControl from './FriendlyControl.svelte'; + import TypographyStudio from './editors/TypographyStudio.svelte'; + import ColorStudio from './editors/ColorStudio.svelte'; + import SpacingStudio from './editors/SpacingStudio.svelte'; + import LayoutStudio from './editors/LayoutStudio.svelte'; + import ShapeStudio from './editors/ShapeStudio.svelte'; + import ShadowStudio from './editors/ShadowStudio.svelte'; + import MotionStudio from './editors/MotionStudio.svelte'; + import EffectsStudio from './editors/EffectsStudio.svelte'; /** @type {{ domain: { id:string, label:string, icon:string, blurb:string, intro?:string, scaleIntro?:string, essentials?:string[], basicGenerators?:string[], brandColors?:boolean, docsPath?:string } }} */ let { domain } = $props(); @@ -83,6 +86,8 @@ const generators = $derived(domain.basicGenerators ?? []); const knobs = $derived(KNOBS_BY_DOMAIN[domain.id] ?? []); const smartSections = $derived(smartSettingsFor(domain.id)); + const redesignedDomains = new Set(['colors', 'typography', 'spacing', 'layout', 'borders', 'shadows', 'motion', 'effects']); + const usesVisualStudio = $derived(redesignedDomains.has(domain.id)); // Domains with a scale generator (typography, spacing): preview goes BELOW // the generator so the specimen updates right next to the controls. @@ -100,19 +105,6 @@ let showAll = $state(false); - // Colors panel accordion states. - let showSecondary = $state(false); - let showStatus = $state(false); - let showColorRoles = $state(true); - let showShadeRamp = $state(false); - - const BRAND_PRIMARY = BRAND_COLOR_KEYS.filter((c) => ['base', 'neutral', 'primary'].includes(c.key)); - const BRAND_SECONDARY = BRAND_COLOR_KEYS.filter((c) => ['secondary', 'tertiary', 'action'].includes(c.key)); - const BRAND_STATUS = BRAND_COLOR_KEYS.filter((c) => c.group === 'status'); - - const hasBrandOverride = (key) => - overrides[`--sf-color-${key}-source-light`] != null || - overrides[`--sf-color-${key}-source-dark`] != null; const modifiedHere = $derived( domainTokens.filter((t) => overrides[t.name] != null) @@ -124,20 +116,6 @@ } -{#snippet expandSummary(title, count, hint = '')} - - - {title} - {#if typeof count === 'number'} - {count} - {:else if count} - {count} - {/if} - {#if hint} - {hint} - {/if} - -{/snippet} {#snippet filters()}
@@ -200,57 +178,41 @@ {/snippet}
-
-
- -
-

{domain.label}

-

{domain.blurb}

-
-
- -
- {#if modifiedHere.length} - - {/if} -
-
-
+ {#if searching} {@render filters()} {@render catalogue()} {:else} - - {#if domain.brandColors} -
- {@render expandSummary('Semantic roles', 'How your brand colors surface')} - -
- {#if knobs.length} - - {/if} + {#if domain.id === 'typography'} + + {:else if domain.id === 'colors'} + + {:else if domain.id === 'spacing'} + + {:else if domain.id === 'layout'} + + {:else if domain.id === 'borders'} + + {:else if domain.id === 'shadows'} + + {:else if domain.id === 'motion'} + + {:else if domain.id === 'effects'} + + {/if} + + - {:else if hasGenerators} + {#if !usesVisualStudio && hasGenerators} {#each generators as g (g)} {/each} - {#if knobs.length} - - {/if} - - {:else if knobs.length} + {/if} + {#if knobs.length} {/if} @@ -271,98 +233,25 @@ {/if} - - {#if domain.brandColors} - -
- {@render expandSummary('Core brand colors', BRAND_PRIMARY.length, 'Light values — dark mode is auto-derived. Click the dark swatch to pin.')} -
- {#each BRAND_PRIMARY as { key, label } (key)} - - {/each} -
-
- - -
- - - Extended brand colors - {BRAND_SECONDARY.length} colors - {#if BRAND_SECONDARY.some((c) => hasBrandOverride(c.key))} - modified - {/if} - -
- {#each BRAND_SECONDARY as { key, label } (key)} - - {/each} -
-
- - -
- - - Status colors - {BRAND_STATUS.length} colors - {#if BRAND_STATUS.some((c) => hasBrandOverride(c.key))} - modified - {/if} - -
- {#each BRAND_STATUS as { key, label } (key)} - - {/each} -
-
- - -
- - - Shade ramp - 7-step scale per brand color - - -
- - {:else if domain.id === 'typography'} - - - - {:else if domain.id === 'borders'} - - - - {:else if domain.id === 'layout'} - - - - {:else if basicGroups.length} - + + {#if !usesVisualStudio && basicGroups.length} {#each basicGroups as group (group.title)} -
- {@render expandSummary(group.title, group.controls.length)} + overrides[c.token] != null).length}>
{#each group.controls as c (c.token)} - + {/each}
-
+ {/each} - - {:else if essentials.length} -
- {@render expandSummary('Essentials', essentials.length, 'Curated for most projects.')} + {:else if !usesVisualStudio && essentials.length} + overrides[t.name] != null).length}>
{#each essentials as token (token.name)} - + {/each}
-
+ {/if} @@ -405,44 +294,6 @@ height: 100%; min-height: 0; } - .panel__head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - padding: 14px 20px 12px; - border-bottom: 1px solid var(--cfg-border); - background: var(--cfg-surface); - flex-wrap: wrap; - } - .panel__title-wrap { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - flex: 1 1 auto; - } - .panel__icon { - display: inline-grid; - place-items: center; - width: 32px; - height: 32px; - border-radius: var(--cfg-radius); - background: linear-gradient(135deg, rgba(79, 140, 255, 0.18), rgba(120, 80, 220, 0.18)); - border: 1px solid var(--cfg-border-strong); - font-size: 16px; - flex-shrink: 0; - } - .panel__title { margin: 0; font-size: 16px; font-weight: 600; } - .panel__blurb { margin: 2px 0 0; color: var(--cfg-text-muted); font-size: 12px; max-width: 70ch; } - - .panel__actions { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - flex-shrink: 0; - } .panel__usage-seg :global(.cfg-seg__btn) { padding: 4px 10px; font-size: 11.5px; } .panel__check { display: flex; @@ -464,38 +315,6 @@ min-height: 0; } - .panel__card { overflow: clip; } - /* The live-preview card that leads each category gets a faint accent edge. */ - .panel__card--lead { border-color: var(--cfg-border-strong); } - .panel__card--lead > summary { border-left: 3px solid var(--cfg-accent-strong); } - .panel__card-head { - display: flex; - align-items: baseline; - gap: 12px; - padding: 12px 16px; - border-bottom: 1px solid var(--cfg-border); - background: var(--cfg-surface-2); - flex-wrap: wrap; - } - .panel__card-title { - font-size: 12.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--cfg-text); - } - .panel__card-count { - font-family: var(--cfg-mono); - font-size: 11px; - color: var(--cfg-text-faint); - } - .panel__card-hint { - flex: 1 1 auto; - text-align: right; - font-size: 11.5px; - color: var(--cfg-text-faint); - } - .panel__intro { margin: 0; padding: 10px 14px; @@ -587,71 +406,8 @@ } .panel__docs a:hover { color: var(--cfg-accent); text-decoration: underline; } - /* Progressive disclosure for collapsible section cards (brand colors, basic groups, essentials). */ - .panel__expand { - border: 1px solid var(--cfg-border); - border-radius: var(--cfg-radius); - background: var(--cfg-surface); - overflow: clip; - } - .panel__expand-summary { - display: flex; - align-items: center; - gap: 10px; - padding: 11px 16px; - cursor: pointer; - list-style: none; - user-select: none; - } - .panel__expand-summary::-webkit-details-marker { display: none; } - .panel__expand-summary:hover { background: var(--cfg-surface-2); } - .panel__expand-chev { - font-size: 15px; - line-height: 1; - color: var(--cfg-text-faint); - transition: transform 0.14s; - } - /* Chevron rotation when any collapsible card is open */ - .panel__expand[open] .panel__expand-chev, - details.cfg-card[open] .panel__expand-chev { transform: rotate(90deg); } - .panel__expand-title { - font-size: 12.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--cfg-text); - } - .panel__expand-count { - font-family: var(--cfg-mono); - font-size: 11px; - color: var(--cfg-text-faint); - } - .panel__expand-badge { - font-size: 9.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--cfg-accent-strong); - border: 1px solid var(--cfg-accent-strong); - border-radius: 999px; - padding: 1px 7px; - line-height: 1.6; - } - /* When the disclosure card is open its summary gets a border */ - details.cfg-card[open] > .panel__card-head.panel__expand-summary, - details.panel__expand[open] > .panel__card-head.panel__expand-summary { - border-bottom: 1px solid var(--cfg-border); - } - @media (max-width: 600px) { .panel__body { padding: 12px 10px 60px; gap: 12px; } - .panel__head { padding: 10px 12px 8px; gap: 8px; } - .panel__title { font-size: 14px; } - .panel__blurb { display: none; } - .panel__actions { - flex-basis: 100%; - gap: 8px; - } .panel__usage-seg :global(.cfg-seg__btn) { padding: 4px 8px; font-size: 10.5px; diff --git a/configurator/src/components/FriendlyControl.svelte b/configurator/src/components/FriendlyControl.svelte new file mode 100644 index 00000000..a984054d --- /dev/null +++ b/configurator/src/components/FriendlyControl.svelte @@ -0,0 +1,60 @@ + + +
+
+
+ {title} + {#if modified}modified{/if} +
+ {#if desc}

{desc}

{/if} + {#if showToken || rawOpen}{token.name}{/if} +
+
+ {#if meta.control === 'select' && meta.options?.length} + + {:else} + + {/if} +
+ +
+ + +
+
+ + diff --git a/configurator/src/components/Header.svelte b/configurator/src/components/Header.svelte index 4225a833..3e057089 100644 --- a/configurator/src/components/Header.svelte +++ b/configurator/src/components/Header.svelte @@ -1,26 +1,14 @@
-
+
{#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 card

Text, 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)} +

{role.label}{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 & blur

Readable 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 overlay

Text over image

Direction and color keep content readable without manual gradients.

+
Without scrim

Harder to read

Preview makes the difference obvious.

+
+ +
+ Mask fade +
+
+ + +
+
+ + 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 @@ + + + +
+ + +
+
+ Container map + Compare narrow, prose, default and wide widths against the same viewport. +
+
+ {#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 shadow

Subtle depth for normal content.

+
PopoverFloating layer

Medium elevation above UI.

+
DialogModal overlay

Strong depth for interruption.

+
+ +
+

Media card glow

Text and image overlays use the same shadow language.

+
Text shadows

Readable over media

Drop and text shadows protect contrast on rich backgrounds.

+
+ + +
+
+ + 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 rhythm

Content 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

+ +
+
+ +
+
Card

Component padding

+
Form
+
Cluster
ABC
+
+ + +
+
+ + 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 @@ + + + +
+
+
+ {#each TYPOGRAPHY_PANELS as p (p.id)} + + {/each} +
+

{panel.description}

+
+ +
+
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']); + }); +});