Skip to content
Merged
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
54 changes: 52 additions & 2 deletions configurator/src/components/BrandColorRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
* Layout:
* [label] [light swatch | text input] → [dark swatch | "auto" or value] [⟲]
*/
import { overrides, setOverride, clearOverride } from '../lib/store.svelte.js';
import { overrides, setOverride, clearOverride, ui } from '../lib/store.svelte.js';
import { defaultsByName } from '../lib/model.js';
import { computeAutoDark } from '../lib/brandColors.js';
import { measureBackground } from '../lib/probeHost.js';
import OklchPicker from './OklchPicker.svelte';

/** @type {{ colorKey: string, label: string }} */
Expand Down Expand Up @@ -72,6 +73,23 @@

function resetLight() { clearOverride(lightName); }
function resetDark() { clearOverride(darkName); }

// ── Inline shade strip ───────────────────────────────────────────────────
const SHADE_SUFFIXES = ['-superlight', '-xlight', '-lighter', '', '-darker', '-xdark', '-superdark'];
let shadeColors = $state([]);

$effect(() => {
void overrides[lightName];
void overrides[darkName];
void ui.previewTheme;
queueMicrotask(() => {
shadeColors = SHADE_SUFFIXES.map((s) => {
const rgb = measureBackground(`var(--sf-color-${colorKey}${s})`);
return rgb && rgb !== 'rgba(0, 0, 0, 0)' ? rgb : null;
});
});
});

</script>

<div class="bcr" class:bcr--light-mod={lightModified} class:bcr--dark-mod={darkModified}>
Expand Down Expand Up @@ -140,6 +158,18 @@
<span class="bcr__auto" title="Auto-derived from the light color via OKLCH. Click the swatch to pin a custom value.">auto</span>
{/if}
</div>

<!-- Inline shade strip -->
<div class="bcr__strip" aria-label="{label} shade ramp">
{#each shadeColors as bg, i (i)}
<div
class="bcr__strip-swatch"
class:bcr__strip-swatch--empty={!bg}
style:background-color={bg ?? 'transparent'}
title="--sf-color-{colorKey}{SHADE_SUFFIXES[i] || ' (base)'}"
></div>
{/each}
</div>
</div>

<!-- Floating picker (shared for light and dark) -->
Expand All @@ -158,9 +188,10 @@
.bcr {
display: grid;
grid-template-columns: 80px 1fr 18px 1fr;
grid-template-rows: auto auto;
gap: 8px;
align-items: center;
padding: 8px 16px;
padding: 8px 16px 10px;
border-bottom: 1px solid var(--cfg-border);
transition: background 0.15s;
}
Expand Down Expand Up @@ -237,6 +268,25 @@
cursor: default;
}

.bcr__strip {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
border-radius: var(--cfg-radius-s, 4px);
overflow: clip;
}

.bcr__strip-swatch {
height: 8px;
background-image: conic-gradient(#444 25%, #2a2a2a 0 50%, #444 0 75%, #2a2a2a 0);
background-size: 6px 6px;
transition: background-color 0.2s ease;
}
.bcr__strip-swatch:not(.bcr__strip-swatch--empty) {
background-image: none;
}

@media (max-width: 720px) {
.bcr {
grid-template-columns: 60px 1fr 14px 1fr;
Expand Down
117 changes: 117 additions & 0 deletions configurator/src/components/ContainerBars.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<script>
/**
* Visual ruler for container-width tokens.
* Renders a proportional bar for each --sf-container-* token so users can
* compare relative widths at a glance and see the live value alongside it.
*/
import { overrides } from '../lib/store.svelte.js';
import { tokenByName, defaultsByName } from '../lib/model.js';

const CONTAINERS = [
{ name: '--sf-container-narrow', label: 'Narrow', help: 'Asides and narrow columns' },
{ name: '--sf-container-prose', label: 'Prose', help: 'Long-form readable line length' },
{ name: '--sf-container-default',label: 'Default', help: 'Main content container' },
{ name: '--sf-container-wide', label: 'Wide', help: 'Marketing & docs sections' },
{ name: '--sf-container-full', label: 'Full', help: 'Edge-to-edge / fluid' },
];

const exists = (name) => tokenByName.has(name);

/** Extract a pixel value from a CSS value string (handles rem, ch, px, %). */
function parsePx(raw) {
if (!raw) return null;
const n = parseFloat(raw);
if (!Number.isFinite(n)) return null;
if (/rem/.test(raw)) return n * 16;
if (/ch/.test(raw)) return n * 9; // ~9px per ch at 16px base
if (/em/.test(raw)) return n * 16;
if (/%/.test(raw)) return 1200 * (n / 100);
return n; // assume px
}

/** Live effective value string for display, pulling overrides → defaults. */
function effectiveValue(name) {
return overrides[name] ?? defaultsByName.get(name) ?? '';
}

const rows = $derived(
CONTAINERS.filter((c) => exists(c.name)).map((c) => {
const raw = effectiveValue(c.name);
return { ...c, raw, px: parsePx(raw) };
})
);

const maxPx = $derived(Math.max(...rows.map((r) => r.px ?? 0), 1));
const modified = (name) => overrides[name] != null;
</script>

<div class="cbars">
{#each rows as row (row.name)}
{@const pct = row.px != null ? Math.min(100, (row.px / maxPx) * 100) : 0}
{@const mod = modified(row.name)}
<div class="cbars__row" class:cbars__row--mod={mod}>
<div class="cbars__meta">
<span class="cbars__label">{row.label}</span>
<code class="cbars__val" class:cbars__val--default={!mod}>{row.raw || '—'}</code>
</div>
<div class="cbars__track">
<div class="cbars__bar" style:width="{pct}%" title="{row.name}: {row.raw}"></div>
{#if row.px != null}
<span class="cbars__px">~{Math.round(row.px)}px</span>
{/if}
</div>
{#if row.help}
<p class="cbars__help">{row.help}</p>
{/if}
</div>
{/each}
</div>

<style>
.cbars {
display: flex; flex-direction: column; gap: 0;
padding: 12px 16px; border: 1px solid var(--cfg-border);
border-radius: var(--cfg-radius); background: var(--cfg-bg-2);
}

.cbars__row {
padding: 10px 0;
border-bottom: 1px solid var(--cfg-border);
}
.cbars__row:last-child { border-bottom: none; }
.cbars__row--mod { box-shadow: inset 3px 0 0 var(--cfg-accent-strong); padding-left: 6px; }

.cbars__meta {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
margin-bottom: 6px;
}
.cbars__label {
font-size: 12px; font-weight: 700; color: var(--cfg-text);
}
.cbars__val {
font-size: 11px; color: var(--cfg-accent-strong);
}
.cbars__val--default { color: var(--cfg-text-faint); }

.cbars__track {
position: relative; height: 12px; background: var(--cfg-border);
border-radius: 999px; overflow: visible; display: flex; align-items: center;
}

.cbars__bar {
height: 100%; border-radius: 999px;
background: var(--cfg-accent-strong); opacity: 0.75;
transition: width 0.25s ease;
min-width: 4px;
}
.cbars__row--mod .cbars__bar { opacity: 1; }

.cbars__px {
position: absolute; left: calc(100% + 6px);
font-size: 9.5px; color: var(--cfg-text-faint); white-space: nowrap;
}

.cbars__help {
margin: 4px 0 0; font-size: 11px; color: var(--cfg-text-faint);
}
</style>
64 changes: 28 additions & 36 deletions configurator/src/components/DomainPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@
import StylePresetRow from './StylePresetRow.svelte';
import ColorAssignments from './ColorAssignments.svelte';
import ShadeRamp from './ShadeRamp.svelte';
import DomainPreview from './DomainPreview.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 { DOMAIN_PREVIEWS } from '../lib/domainPreviews.js';

/** @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();
Expand Down Expand Up @@ -105,11 +106,6 @@
let showColorRoles = $state(true);
let showShadeRamp = $state(false);

// Preview disclosure (open by default; for generator domains it appears below generators).
let showPreview = $state(true);

const previewSpec = $derived(DOMAIN_PREVIEWS[domain.id]);

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');
Expand Down Expand Up @@ -231,47 +227,31 @@
{@render catalogue()}
{:else}

<!-- ── ZONE 1: LIVE PREVIEW (always leads the panel) ─────────────────
Colors: Semantic-roles swatch grid.
All token domains: DomainPreview card, open by default.
For generator domains (typography/spacing) the generators are
placed immediately BELOW the preview so the specimen updates in
direct visual response to Apply — no scrolling required.
<!-- ── ZONE 1: CONTROLS (live preview now lives in the right Preview Hub) ──
Colors: Semantic-roles swatch grid (editing UI, not just preview).
Generators (typography/spacing): collapsible ScaleGenerator.
All domains: QuickKnobs (scaling multipliers) if present.
─────────────────────────────────────────────────────────────────────── -->

{#if domain.brandColors}
<details class="cfg-card panel__card panel__card--lead" bind:open={showColorRoles}>
{@render expandSummary('Semantic roles', 'How your brand colors surface')}
<ColorAssignments />
</details>
<!-- Contrast/focus knobs right after the roles they control -->
{#if knobs.length}
<QuickKnobs {knobs} title="Scaling" blurb={domain.scaleIntro ?? ''} />
{/if}

{:else if previewSpec}
<!-- Preview leads for every token domain — generator or not -->
<details class="cfg-card panel__card panel__card--lead" bind:open={showPreview}>
{@render expandSummary('Preview', previewSpec.blurb)}
<DomainPreview domain={domain.id} />
</details>

<!-- Generator domains: scale generators immediately below the preview
so the specimen is in direct view while the user tunes the ramp. -->
{#if hasGenerators}
{#each generators as g (g)}
<ScaleGenerator kinds={[g]} />
{/each}
<!-- Scaling knobs follow the generator controls -->
{#if knobs.length}
<QuickKnobs {knobs} title="Scaling" blurb={domain.scaleIntro ?? ''} />
{/if}
{:else}
<!-- Non-generator domains: knobs follow the preview directly -->
{#if knobs.length}
<QuickKnobs {knobs} title="Scaling" blurb={domain.scaleIntro ?? ''} />
{/if}
{:else if hasGenerators}
{#each generators as g (g)}
<ScaleGenerator kinds={[g]} collapsible />
{/each}
{#if knobs.length}
<QuickKnobs {knobs} title="Scaling" blurb={domain.scaleIntro ?? ''} />
{/if}

{:else if knobs.length}
<QuickKnobs {knobs} title="Scaling" blurb={domain.scaleIntro ?? ''} />
{/if}

<!-- ── ZONE 2: SETTINGS (inputs-first) ────────────────────────────── -->
Expand Down Expand Up @@ -349,6 +329,18 @@
<ShadeRamp />
</details>

{:else if domain.id === 'typography'}
<!-- Heading-level tab editor replaces flat basicGroups -->
<HeadingEditor />

{:else if domain.id === 'borders'}
<!-- Radius level tab editor with shape specimens -->
<RadiusEditor />

{:else if domain.id === 'layout'}
<!-- Container width comparison bars -->
<ContainerBars />

{:else if basicGroups.length}
<!-- Curated groups — each group is now a collapsible card -->
{#each basicGroups as group (group.title)}
Expand Down
Loading