Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion configurator/src/components/DomainPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@

// Domains with a scale generator (typography, spacing): preview goes BELOW
// the generator so the specimen updates right next to the controls.
const hasGenerators = $derived(generators.length > 0);
const hasGenerators = $derived(generators.length > 0 && !(usesVisualStudio && domain.id === 'typography'));

const hasSettings = $derived(
!!domain.brandColors ||
Expand Down
95 changes: 74 additions & 21 deletions configurator/src/components/editors/TypographyStudio.svelte
Original file line number Diff line number Diff line change
@@ -1,23 +1,64 @@
<script>
import { overrides } from '../../lib/store.svelte.js';
import { TYPOGRAPHY_PANELS, resolveStudioGroups } from '../../lib/studioSchema.js';
import { resolveStudioGroups } from '../../lib/studioSchema.js';
import ScaleGenerator from '../ScaleGenerator.svelte';
import StudioFrame from './StudioFrame.svelte';
import StudioControls from './StudioControls.svelte';

let active = $state('Overview');
const panel = $derived(TYPOGRAPHY_PANELS.find((p) => p.id === active) ?? TYPOGRAPHY_PANELS[0]);
const groups = $derived(resolveStudioGroups([{ title: panel.label, hint: panel.description, tokens: panel.tokens }]));
const HEADING_LEVELS = ['All', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
const PANELS = [
{ id: 'Headings', label: 'Headings', description: 'Framework-style controls for h1–h6 aliases, rhythm, tracking and wrapping.' },
{ id: 'Text', label: 'Text', description: 'Body copy, prose rhythm, text wrapping, line-height and reading measure.' },
{ id: 'Fonts', label: 'Fonts', description: 'Font stacks for body, headings and mono, plus OpenType feature and variation settings.' },
{ id: 'Scale', label: 'Scale', description: 'Generate the fluid type and display ramps directly inside Typography Studio.' },
{ id: 'Advanced', label: 'Advanced', description: 'Fine tune weights, display line heights, numeric variants and size-specific outputs.' },
];

const HEADING_TOKENS = ['size', 'line-height', 'font-weight', 'letter-spacing', 'max-width'];
const TEXT_GROUPS = [
{ title: 'Body', hint: 'Base paragraph defaults.', tokens: ['--sf-body-font-size', '--sf-body-line-height', '--sf-body-font-weight', '--sf-body-font-family', '--sf-body-color'] },
{ title: 'Prose', hint: 'Long-form content spacing.', tokens: ['--sf-prose-paragraph', '--sf-prose-heading-gap', '--sf-prose-list-gap', '--sf-prose-block-margin', '--sf-prose-media-margin'] },
{ title: 'Wrap', hint: 'Text wrapping behavior.', tokens: ['--sf-body-text-wrap', '--sf-heading-text-wrap'] },
{ title: 'Line-height', hint: 'Reusable leading scale.', tokens: ['--sf-leading-tight', '--sf-leading-snug', '--sf-leading-normal', '--sf-leading-relaxed', '--sf-leading-taper'] },
{ title: 'Measure', hint: 'Readable line lengths by text size.', tokens: ['--sf-text-xs-max-width', '--sf-text-s-max-width', '--sf-text-m-max-width', '--sf-text-l-max-width', '--sf-text-xl-max-width'] },
];
const FONT_GROUPS = [
{ title: 'Font stacks', hint: 'Primary body, heading and monospace families.', tokens: ['--sf-font-body', '--sf-font-heading', '--sf-font-mono', '--sf-font-display'] },
{ title: 'Feature settings', hint: 'OpenType features, variation axes and numeric variants.', tokens: ['--sf-font-features', '--sf-font-variation', '--sf-font-numeric'] },
{ title: 'Role weights', hint: 'Weights applied to type roles.', tokens: ['--sf-font-weight-body', '--sf-font-weight-heading', '--sf-font-weight-display', '--sf-font-weight-strong'] },
];
const ADVANCED_GROUPS = [
{ title: 'Display text', hint: 'Hero/display aliases and line-height.', tokens: ['--sf-text-display-s', '--sf-text-display-m', '--sf-text-display-l', '--sf-display-s-line-height', '--sf-display-m-line-height', '--sf-display-l-line-height', '--sf-text-display-scale'] },
{ title: 'Text outputs', hint: 'Generated text steps and per-size metadata.', tokens: ['--sf-text-2xs', '--sf-text-xs', '--sf-text-s', '--sf-text-m', '--sf-text-l', '--sf-text-xl', '--sf-text-2xl', '--sf-text-3xl', '--sf-text-4xl'] },
{ title: 'Emphasis', hint: 'Strong/em defaults and interactive weight.', tokens: ['--sf-body-strong-weight', '--sf-body-em-style', '--sf-font-weight-interactive'] },
];

let active = $state('Headings');
let headingScope = $state('All');
const panel = $derived(PANELS.find((p) => p.id === active) ?? PANELS[0]);
const headingGroups = $derived(resolveStudioGroups(headingScope === 'All'
? [
{ title: 'All headings', hint: 'Shared heading behavior.', tokens: ['--sf-heading-font-family', '--sf-heading-color', '--sf-heading-text-wrap', '--sf-font-weight-heading'] },
...['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].map((level) => ({
title: level,
hint: `${level} size, leading, weight, tracking and measure.`,
tokens: HEADING_TOKENS.map((suffix) => `--sf-${level.toLowerCase()}-${suffix}`),
})),
]
: [{ title: headingScope, hint: `${headingScope} size, leading, weight, tracking and measure.`, tokens: HEADING_TOKENS.map((suffix) => `--sf-${headingScope.toLowerCase()}-${suffix}`) }]
));
const groups = $derived(active === 'Headings' ? headingGroups : active === 'Text' ? resolveStudioGroups(TEXT_GROUPS) : active === 'Fonts' ? resolveStudioGroups(FONT_GROUPS) : active === 'Advanced' ? resolveStudioGroups(ADVANCED_GROUPS) : []);
const headingFont = $derived(overrides['--sf-font-heading'] || 'var(--sf-font-heading)');
const bodyFont = $derived(overrides['--sf-font-body'] || 'var(--sf-font-body)');
const monoFont = $derived(overrides['--sf-font-mono'] || 'var(--sf-font-mono)');
const scale = $derived(overrides['--sf-text-scale'] || 'var(--sf-text-scale, 1)');
</script>

<StudioFrame title="Typography Studio" description="The live specimen is your canvas: pick a scope, set the font, scale, rhythm and wrap — and see the result next to the controls." tone="type">
<StudioFrame title="Typography Studio" description="Framework-inspired typography controls: tune headings, text, fonts, fluid scale and advanced details in one focused canvas." tone="type">
<div class="type-studio">
<div class="type-studio__toolbar">
<div class="tabs" role="tablist" aria-label="Typography editing scope">
{#each TYPOGRAPHY_PANELS as p (p.id)}
{#each PANELS as p (p.id)}
<button role="tab" aria-selected={active === p.id} class:active={active === p.id} onclick={() => (active = p.id)}>{p.label}</button>
{/each}
</div>
Expand All @@ -26,45 +67,57 @@

<div class="specimen" style:--type-scale={scale}>
<div class="specimen__measure"><span>mobile</span><span>tablet</span><span>desktop</span></div>
{#if active === 'Overview' || active === 'Fluid scale' || active === 'Headings'}
{#if active === 'Headings' || active === 'Scale' || active === 'Advanced'}
<h1 style:font-family={headingFont}>The quick brown fox</h1>
<h2 style:font-family={headingFont}>Jumps over the lazy dog</h2>
<h3 style:font-family={headingFont}>Typography at every scale</h3>
<h4 style:font-family={headingFont}>Fluid, readable, precise</h4>
<h5 style:font-family={headingFont}>Composed hierarchy</h5>
<h6 style:font-family={headingFont}>Small heading label</h6>
{/if}
{#if active === 'Overview' || active === 'Body'}
{#if active === 'Text' || active === 'Fonts'}
<p style:font-family={bodyFont}>Body: The quick brown fox jumps over the lazy dog. A short paragraph shows body rhythm, line height, measure and spacing at a glance.</p>
{/if}
{#if active === 'Overview' || active === 'Code'}
<code style:font-family={monoFont}>Pixel-perfect control — const token = true;</code>
{/if}
</div>

<div class="mini-previews">
<article><b>Heading wrap</b><span>Balanced title blocks reduce awkward one-word lines.</span></article>
<article><b>Body rhythm</b><span>Line height and content gap define reading comfort.</span></article>
<article><b>Code / mono</b><code style:font-family={monoFont}>const token = true;</code></article>
</div>

<StudioControls {groups} />
{#if active === 'Scale'}
<ScaleGenerator kinds={['type', 'display']} />
{:else}
{#if active === 'Headings'}
<div class="scope" role="group" aria-label="Heading level">
{#each HEADING_LEVELS as level (level)}
<button class:active={headingScope === level} aria-pressed={headingScope === level} onclick={() => (headingScope = level)}>{level}</button>
{/each}
</div>
{/if}
<div class="mini-previews">
<article><b>Heading wrap</b><span>Balanced title blocks reduce awkward one-word lines.</span></article>
<article><b>Body rhythm</b><span>Line height and content gap define reading comfort.</span></article>
<article><b>Code / mono</b><code style:font-family={monoFont}>const token = true;</code></article>
</div>
<StudioControls {groups} />
{/if}
</div>
</StudioFrame>

<style>
.type-studio { display: grid; gap: 12px; }
.type-studio__toolbar { display: grid; gap: 8px; }
.type-studio__toolbar p { margin: 0; color: var(--cfg-text-muted); font-size: 13px; }
.tabs { display: flex; gap: 0; overflow: auto; border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius-s); background: var(--cfg-bg-2); }
.tabs button { border: 0; border-right: 1px solid var(--cfg-border); background: transparent; color: var(--cfg-text-muted); padding: 8px 12px; font-weight: 800; font-size: 11px; text-transform: uppercase; white-space: nowrap; }
.tabs button.active { background: var(--cfg-accent-strong); color: white; }
.tabs, .scope { display: flex; gap: 0; overflow: auto; border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius-s); background: var(--cfg-bg-2); }
.tabs button, .scope button { border: 0; border-right: 1px solid var(--cfg-border); background: transparent; color: var(--cfg-text-muted); padding: 8px 12px; font-weight: 800; font-size: 11px; text-transform: uppercase; white-space: nowrap; }
.tabs button.active, .scope button.active { background: var(--cfg-accent-strong); color: white; }
.specimen { padding: clamp(16px, 4vw, 28px); border-radius: var(--cfg-radius); background: var(--sf-color-bg); color: var(--sf-color-text); border: 1px solid var(--cfg-border); overflow: hidden; }
.specimen__measure { display: grid; grid-template-columns: .5fr .75fr 1fr; gap: 6px; margin-bottom: 16px; color: var(--cfg-text-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.specimen__measure span { border-top: 2px solid var(--cfg-border-strong); padding-top: 4px; }
h1,h2,h3,h4,p { margin: 0; }
h1,h2,h3,h4,h5,h6,p { margin: 0; }
h1 { font-size: calc(clamp(42px, 7vw, 74px) * var(--type-scale, 1)); line-height: .95; text-wrap: var(--sf-heading-text-wrap, balance); letter-spacing: var(--sf-h1-letter-spacing, -.02em); }
h2 { font-size: calc(clamp(32px, 5vw, 54px) * var(--type-scale, 1)); line-height: 1; text-wrap: var(--sf-heading-text-wrap, balance); }
h3 { font-size: calc(clamp(24px, 3.5vw, 38px) * var(--type-scale, 1)); margin-top: 8px; }
h4 { font-size: calc(clamp(19px, 2.6vw, 28px) * var(--type-scale, 1)); margin-top: 8px; }
h5 { font-size: calc(clamp(16px, 2vw, 22px) * var(--type-scale, 1)); margin-top: 8px; }
h6 { font-size: calc(clamp(13px, 1.6vw, 18px) * var(--type-scale, 1)); margin-top: 8px; text-transform: uppercase; letter-spacing: .08em; }
code { display: block; margin-top: 8px; font-size: 18px; }
p { margin-top: 12px; color: var(--sf-color-text--muted); font-size: 16px; line-height: var(--sf-leading-normal, 1.5); max-width: 68ch; text-wrap: var(--sf-body-text-wrap, pretty); }
.mini-previews { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
Expand Down
6 changes: 3 additions & 3 deletions configurator/tests-components/studios.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { clearAll, overrides } from '../src/lib/store.svelte.js';
beforeEach(() => clearAll());

const STUDIOS = [
['Typography Studio', TypographyStudio, ['The quick brown fox', 'Fluid scale']],
['Typography Studio', TypographyStudio, ['The quick brown fox', 'Headings', 'Text', 'Fonts', 'Scale', 'Advanced']],
['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']],
Expand All @@ -42,13 +42,13 @@ describe('visual studios', () => {
for (const text of expectedText) {
expect(getAllByText(text).length).toBeGreaterThan(0);
}
});
}, 10000);
}

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(getAllByText('Framework-style controls for h1–h6 aliases, rhythm, tracking and wrapping.').length).toBeGreaterThan(0);
expect(getByText('--sf-h1-size')).toBeInTheDocument();
});

Expand Down
Loading