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 @@ -220,7 +220,7 @@
/>
{/if}

{#if smartSections.length}
{#if smartSections.length && domain.id !== 'colors' && domain.id !== 'gradients'}
<SmartSettings domainId={domain.id} />
{/if}

Expand Down
44 changes: 38 additions & 6 deletions configurator/src/components/ShadeRamp.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
import { BRAND_COLOR_KEYS } from '../lib/brandColors.js';
import { parseRgb } from '../lib/contrast.js';

let { colorKey = null, showIntro = true } = $props();

const BRAND_KEYS = BRAND_COLOR_KEYS.filter((k) => k.group === 'brand');
const visibleBrandKeys = $derived(colorKey ? BRAND_KEYS.filter((k) => k.key === colorKey) : BRAND_KEYS);

const SHADE_STEPS = [
{ suffix: '-superlight', label: 'superlight' },
Comment on lines +18 to 24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Shaderamp stale on colorkey 🐞 Bug ≡ Correctness

ShadeRamp.svelte now derives ALL_SHADE_TOKENS from the new colorKey prop, but the measuring
$effect does not establish a reactive dependency on that derived value because it’s only read
inside queueMicrotask. As a result, changing colorKey can leave resolved computed for the
previous token set and the UI can stay in the “resolving…” state for the newly selected family until
some other dependency changes.
Agent Prompt
### Issue description
`ALL_SHADE_TOKENS` is now `$derived(...)` from `colorKey`, but the `$effect` that measures swatches never reads `ALL_SHADE_TOKENS` synchronously, so changes to `colorKey` won’t retrigger the measuring work.

### Issue Context
The effect schedules work with `queueMicrotask`, and the only read of `ALL_SHADE_TOKENS` happens inside that callback, which does not participate in Svelte’s dependency tracking.

### Fix Focus Areas
- configurator/src/components/ShadeRamp.svelte[18-56]

### Suggested fix
Inside the `$effect`, read `ALL_SHADE_TOKENS` before scheduling the microtask (e.g. `const tokens = ALL_SHADE_TOKENS;`) and iterate `tokens` inside the microtask. This both (a) establishes reactivity on `colorKey`/`visibleBrandKeys` changes and (b) measures a stable snapshot of the token list for that effect run.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Expand All @@ -27,9 +30,9 @@
{ suffix: '-superdark', label: 'superdark' },
];

const ALL_SHADE_TOKENS = BRAND_KEYS.flatMap(({ key }) =>
const ALL_SHADE_TOKENS = $derived(visibleBrandKeys.flatMap(({ key }) =>
SHADE_STEPS.map(({ suffix }) => `--sf-color-${key}${suffix}`)
);
));

/** @type {Record<string, string>} token → resolved rgb() string */
let resolved = $state({});
Expand Down Expand Up @@ -58,14 +61,27 @@
if (!c) return 128;
return (c.r * 0.299 + c.g * 0.587 + c.b * 0.114) * 255;
}

function shadeMetrics(rgb) {
const c = parseRgb(rgb);
if (!c) return { lightness: '…', chroma: '…' };
const max = Math.max(c.r, c.g, c.b);
const min = Math.min(c.r, c.g, c.b);
return {
lightness: `${Math.round(perceived(rgb) / 2.55)}%`,
chroma: (max - min).toFixed(3),
};
}
</script>

<div class="sr">
<p class="sr__intro">
7-step ramp for each brand color, resolved against the <strong>{ui.previewTheme}</strong> theme.
</p>
{#if showIntro}
<p class="sr__intro">
7-step ramp for each brand color, resolved against the <strong>{ui.previewTheme}</strong> theme.
</p>
{/if}

{#each BRAND_KEYS as { key, label } (key)}
{#each visibleBrandKeys as { key, label } (key)}
<div class="sr__row">
<span class="sr__name">{label}</span>
<div class="sr__swatches">
Expand All @@ -90,12 +106,24 @@
</div>
{/each}
</div>
<table class="sr__table">
<thead><tr><th>Shade</th><th>Lightness</th><th>Chroma</th></tr></thead>
<tbody>
{#each SHADE_STEPS as { suffix, label: stepLabel } (`table-${key}${suffix}`)}
{@const token = `--sf-color-${key}${suffix}`}
{@const metrics = shadeMetrics(resolved[token])}
<tr><td>{stepLabel}</td><td>{metrics.lightness}</td><td>{metrics.chroma}</td></tr>
{/each}
</tbody>
</table>
</div>
{/each}

{#if showIntro}
<p class="sr__note">
Toggle in the preview pane to compare modes.
</p>
{/if}
</div>

<style>
Expand Down Expand Up @@ -181,6 +209,10 @@
max-width: 100%;
}

.sr__table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 11px; overflow: hidden; border-radius: var(--cfg-radius-s); }
.sr__table th, .sr__table td { padding: 6px 8px; border: 1px solid var(--cfg-border); text-align: left; }
.sr__table th { color: var(--cfg-text-faint); text-transform: uppercase; letter-spacing: .06em; background: var(--cfg-surface-2); }

.sr__note {
margin: 4px 0 0;
font-size: 11px;
Expand Down
199 changes: 136 additions & 63 deletions configurator/src/components/editors/ColorStudio.svelte
Original file line number Diff line number Diff line change
@@ -1,31 +1,101 @@
<script>
import { overrides, ui, patchOverrides, setOverride, dragSetOverride, endDrag } from '../../lib/store.svelte.js';
import { BRAND_COLOR_KEYS } from '../../lib/brandColors.js';
import { COLOR_ROLE_GROUPS } from '../../lib/colorRoles.js';
import { tokenByName } from '../../lib/model.js';
import { smartSettingsFor } from '../../lib/domainSettings.js';
import { buildPreviewDeclarations } from '../../lib/preview.js';
import BrandColorRow from '../BrandColorRow.svelte';
import ColorAssignments from '../ColorAssignments.svelte';
import FriendlyControl from '../FriendlyControl.svelte';
import ShadeRamp from '../ShadeRamp.svelte';
import TokenRow from '../TokenRow.svelte';
import StudioFrame from './StudioFrame.svelte';

const main = BRAND_COLOR_KEYS.filter((c) => c.group === 'brand');
const status = BRAND_COLOR_KEYS.filter((c) => c.group === 'status');
const tuning = [
'--sf-contrast-bias',
'--sf-contrast-threshold',
'--sf-palette-mix-50',
'--sf-palette-mix-500',
'--sf-palette-mix-950',
'--sf-focus-ring-width',
'--sf-focus-ring-offset',
].map((name) => tokenByName.get(name)).filter(Boolean);
const workflow = ['Source pairs', 'Role map', 'Shade ramp', 'Usage check'];
const panels = [
{ id: 'main-colors', label: 'Main colors' },
{ id: 'semantic-colors', label: 'Semantic colors' },
{ id: 'gradients', label: 'Gradients' },
{ id: 'shade-curve', label: 'Shade curve' },
{ id: 'contrast', label: 'Contrast' },
{ id: 'assignments', label: 'Assignments' },
];
const mainColorItems = [
{ key: 'primary', label: 'Primary' },
{ key: 'secondary', label: 'Secondary' },
{ key: 'tertiary', label: 'Tertiary' },
{ key: 'action', label: 'Accent/Action' },
{ key: 'base', label: 'Base' },
{ key: 'neutral', label: 'Neutral' },
].filter((item) => main.some((color) => color.key === item.key));

const stageStyle = $derived(buildPreviewDeclarations(overrides, ui.previewTheme));
const paletteCurve = smartSettingsFor('colors').find((section) => section.id === 'palette-curve');
const gradientBuilder = smartSettingsFor('gradients').find((section) => section.id === 'gradient-builder');
const exists = (name) => tokenByName.has(name);
const token = (name) => tokenByName.get(name);

let activePanel = $state('main-colors');
let activeColor = $state('primary');
let openColors = $state({ primary: true });

function toggleColor(key) {
activeColor = key;
openColors = { ...openColors, [key]: !openColors[key] };
}

function cleanPatch(section, patch) {
const names = new Set(section.controls.map((control) => control.token).filter(exists));
if (patch == null) return Object.fromEntries([...names].map((name) => [name, null]));
return Object.fromEntries(Object.entries(patch).filter(([name]) => exists(name)));
}

function applyPreset(section, preset) {
const patch = cleanPatch(section, preset.patch);
if (Object.keys(patch).length) patchOverrides(patch);
}

function numericValue(control) {
const raw = overrides[control.token] ?? token(control.token)?.value ?? '';
const match = String(raw).match(/-?\d*\.?\d+/);
const n = match ? parseFloat(match[0]) : NaN;
return Number.isFinite(n) ? n : control.min;
}

function onSlider(control, value) {
if (Number.isFinite(value)) dragSetOverride(control.token, `${value}${control.unit ?? ''}`);
}

function gradientValue(name) {
return overrides[name] ?? token(name)?.value ?? '';
}

function setGradient(name, angle, first, second) {
setOverride(name, `linear-gradient(${angle}deg, ${first} 0%, ${second} 100%)`);
}

const gradientAngles = [90, 135, 180, 225];
const gradientStops = [
['var(--sf-color-primary)', 'var(--sf-color-secondary)'],
['var(--sf-color-primary)', 'var(--sf-color-tertiary)'],
['var(--sf-color-surface)', 'var(--sf-color-bg)'],
['var(--sf-color-text)', 'transparent'],
];
</script>

<StudioFrame title="Color Studio" description="Set brand sources first, then verify semantic roles, status colors, shade ramp, contrast, and real usage." tone="color">
<div class="color-studio">
<nav class="workflow" aria-label="Color workflow">
{#each workflow as step, index (step)}<span><b>{index + 1}</b>{step}</span>{/each}
<nav class="studio-nav" aria-label="Color Studio panels">
{#each panels as panel (panel.id)}
<button type="button" class:active={activePanel === panel.id} onclick={() => (activePanel = panel.id)}>{panel.label}</button>
{/each}
</nav>

<section class="usage" aria-label="Usage preview">
Expand All @@ -34,72 +104,75 @@
<mark>Status message</mark>
</section>

<section class="theme-pair" aria-label="Light / dark pair preview">
<article class="theme-card theme-card--light"><small>Light</small><b>Readable surface</b><span>Base, neutral and brand sources.</span></article>
<article class="theme-card theme-card--dark"><small>Dark</small><b>Auto-derived pair</b><span>Override only when the automatic pair needs art direction.</span></article>
</section>

<section class="swatches" aria-label="Palette overview">
{#each main as { key, label } (key)}<span style:background={`var(--sf-color-${key})`} title={label}></span>{/each}
{#each status as { key, label } (key)}<span style:background={`var(--sf-color-${key})`} title={label}></span>{/each}
</section>

<section class="role-map" aria-label="Role map">
<div class="role-map__head"><strong>Role map</strong><span>How brand sources become real interface decisions.</span></div>
<div class="role-map__grid">
{#each COLOR_ROLE_GROUPS as group (group.section)}
<article>
<h4>{group.section}</h4>
{#each group.roles as role (role.token)}
<div class="role-chip"><span style:background={`var(${role.token})`}></span><p><b>{role.label}</b><code>{role.token}</code></p></div>
{/each}
{#if activePanel === 'main-colors'}
<section class="main-colors" aria-label="Main colors">
{#each mainColorItems as { key, label } (key)}
<article class="color-item" class:color-item--active={activeColor === key}>
<button type="button" class="color-item__summary" aria-expanded={!!openColors[key]} onclick={() => toggleColor(key)}>
<span class="color-item__toggle" aria-hidden="true"></span>
<span class="color-item__swatch" style:background={`var(--sf-color-${key})`}></span>
<span>{label}</span>
<span class="color-item__chev" aria-hidden="true">›</span>
</button>
{#if openColors[key]}
<BrandColorRow colorKey={key} {label} />
<ShadeRamp colorKey={key} showIntro={false} />
{/if}
</article>
{/each}
</div>
</section>

<details class="panel__card cfg-card" open><summary>Core brand colors</summary><div class="rows">{#each main as {key,label} (key)}<BrandColorRow colorKey={key} {label} />{/each}</div></details>
<details><summary>Semantic colors</summary><div class="rows">{#each status as {key,label} (key)}<BrandColorRow colorKey={key} {label} />{/each}</div></details>
<details><summary>Contrast & palette tuning</summary><div class="rows">{#each tuning as token (token.name)}<FriendlyControl {token} showToken />{/each}</div></details>
<details><summary>Semantic role preview</summary><ColorAssignments /></details>
<details><summary>Generated shade ramp</summary><ShadeRamp /></details>
</section>
{:else if activePanel === 'semantic-colors'}
<section class="rows">{#each status as {key,label} (key)}<BrandColorRow colorKey={key} {label} />{/each}</section>
{:else if activePanel === 'gradients'}
<section class="gradient-grid">
{#each gradientBuilder.tokens.filter(exists) as name (name)}
<article class="gradient-card">
<div class="gradient-card__swatch" style={stageStyle} style:background={gradientValue(name)}></div>
<div class="gradient-card__body"><code>{name}</code><div class="gradient-card__buttons">{#each gradientAngles as angle (angle)}<button type="button" class="cfg-btn cfg-btn--ghost cfg-btn--sm" onclick={() => setGradient(name, angle, gradientStops[0][0], gradientStops[0][1])}>{angle}°</button>{/each}</div><div class="gradient-card__buttons">{#each gradientStops as stops, i (i)}<button type="button" class="cfg-btn cfg-btn--sm" onclick={() => setGradient(name, 135, stops[0], stops[1])}>Preset {i + 1}</button>{/each}</div><TokenRow token={token(name)} label="Raw gradient" help="Power-user CSS value: linear/radial/conic, stops, color-mix(), vars." showRawInfo forceEditable /></div>
</article>
{/each}
</section>
{:else if activePanel === 'shade-curve'}
<section class="curve-panel">
<div class="curve-panel__actions">{#each paletteCurve.presets as preset (preset.label)}<button type="button" class="cfg-btn cfg-btn--sm" onclick={() => applyPreset(paletteCurve, preset)}>{preset.label}</button>{/each}</div>
<div class="curve-grid">{#each paletteCurve.controls.filter((c) => exists(c.token)) as c (c.token)}{@const val = numericValue(c)}<label class="curve-slider"><span><b>{c.label}</b><code>{c.token}</code></span><input type="range" min={c.min} max={c.max} step={c.step} value={val} oninput={(e) => onSlider(c, parseFloat(e.currentTarget.value))} onchange={endDrag} /><em>{val}{c.unit}</em></label>{/each}</div>
</section>
{:else if activePanel === 'contrast'}
<section class="rows">{#each tuning as token (token.name)}<FriendlyControl {token} showToken />{/each}</section>
{:else if activePanel === 'assignments'}
<ColorAssignments />
{/if}
</div>
</StudioFrame>

<style>
.color-studio { display: grid; gap: 12px; }
.workflow { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.workflow span { display: flex; align-items: center; gap: 8px; padding: 9px 10px; border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius-s); background: var(--cfg-bg-2); color: var(--cfg-text-muted); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; }
.workflow b { display: grid; place-items: center; inline-size: 20px; block-size: 20px; border-radius: 999px; background: var(--cfg-accent-strong); color: white; font-size: 10px; }
.studio-nav { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.studio-nav button { border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius-s); background: var(--cfg-bg-2); color: var(--cfg-text-muted); padding: 10px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; cursor: pointer; }
.studio-nav button.active { background: var(--cfg-accent-strong); color: white; border-color: transparent; }
.usage { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 14px; border-radius: 14px; background: var(--sf-color-raised); border: 1px solid var(--sf-color-border); }
.usage button { border: 0; border-radius: 999px; background: var(--sf-color-primary); color: var(--sf-color-primary-text, white); padding: 10px 14px; }
.usage p { margin: 2px 0 0; color: var(--sf-color-text--muted); }
.usage__link { color: var(--sf-color-link); cursor: pointer; text-decoration: underline; }
mark { border-radius: 999px; background: var(--sf-color-warning-subtle); color: var(--sf-color-warning); padding: 7px 10px; }
.theme-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.theme-card { min-height: 118px; display: grid; align-content: end; gap: 4px; padding: 14px; border-radius: 16px; border: 1px solid var(--cfg-border); }
.theme-card--light { background: var(--sf-color-surface); color: var(--sf-color-text); }
.theme-card--dark { background: var(--sf-color-neutral-superdark, #111); color: var(--sf-color-base-superlight, #fff); }
.theme-card small { text-transform: uppercase; letter-spacing: .08em; color: var(--cfg-text-muted); font-weight: 900; }
.theme-card b { font-size: 18px; }
.theme-card span { font-size: 12px; opacity: .75; }
.swatches { display: grid; grid-template-columns: repeat(10, 1fr); gap: 6px; padding: 10px; border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius); background: var(--cfg-bg-2); }
.swatches span { min-height: 38px; border-radius: 9px; border: 1px solid color-mix(in oklab, currentColor 15%, transparent); }
.role-map { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius); background: var(--cfg-bg-2); }
.role-map__head { display: flex; gap: 8px; justify-content: space-between; align-items: baseline; flex-wrap: wrap; }
.role-map__head strong { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; }
.role-map__head span { color: var(--cfg-text-muted); font-size: 12px; }
.role-map__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.role-map article { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius-s); background: var(--cfg-surface); }
.role-map h4 { margin: 0; font-size: 12px; }
.role-chip { display: grid; grid-template-columns: 26px 1fr; gap: 8px; align-items: center; min-width: 0; }
.role-chip > span { inline-size: 26px; block-size: 26px; border-radius: 8px; border: 1px solid var(--cfg-border-strong); }
.role-chip p { display: grid; gap: 1px; margin: 0; min-width: 0; }
.role-chip b { font-size: 11px; }
.role-chip code { color: var(--cfg-text-faint); font-size: 9.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
details { border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius); background: var(--cfg-surface); overflow: clip; }
summary { padding: 12px 14px; cursor: pointer; font-weight: 800; text-transform: uppercase; font-size: 12px; letter-spacing: .06em; background: var(--cfg-surface-2); }
.rows { display: grid; }
@media (max-width: 800px) { .workflow, .theme-pair, .role-map__grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px) { .usage, .workflow, .theme-pair, .role-map__grid { grid-template-columns: 1fr; } .swatches { grid-template-columns: repeat(5, 1fr); } }
.main-colors, .rows { display: grid; gap: 10px; }
.color-item { border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius); background: var(--cfg-surface); overflow: clip; }
.color-item--active { border-color: color-mix(in oklab, var(--cfg-accent) 65%, var(--cfg-border)); }
.color-item__summary { width: 100%; display: grid; grid-template-columns: 34px 34px 1fr auto; gap: 10px; align-items: center; padding: 12px; border: 0; background: var(--cfg-surface-2); color: inherit; text-align: left; font-weight: 850; cursor: pointer; }
.color-item__toggle { inline-size: 28px; block-size: 16px; border-radius: 999px; background: var(--cfg-accent-strong); box-shadow: inset 13px 0 0 white; }
.color-item__swatch { inline-size: 30px; block-size: 30px; border-radius: 9px; border: 1px solid var(--cfg-border-strong); }
.color-item__chev { transition: transform .14s; }
.color-item__summary[aria-expanded='true'] .color-item__chev { transform: rotate(90deg); }
.gradient-grid, .curve-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 14px; }
.gradient-card, .curve-slider { border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius); overflow: clip; background: var(--cfg-bg-2); }
.gradient-card__swatch { min-height: 90px; border-bottom: 1px solid var(--cfg-border); }
.gradient-card__body, .curve-slider { display: grid; gap: 10px; padding: 12px; }
.gradient-card__buttons, .curve-panel__actions { display: flex; gap: 8px; flex-wrap: wrap; }
.curve-panel { display: grid; gap: 12px; }
.curve-slider span { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
.curve-slider code { color: var(--cfg-text-faint); font-size: 10px; }
.curve-slider input { width: 100%; accent-color: var(--cfg-accent-strong); }
.curve-slider em { color: var(--cfg-text-muted); font-style: normal; }
@media (max-width: 900px) { .studio-nav { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 640px) { .usage, .studio-nav { grid-template-columns: 1fr; } }
</style>
Loading