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
161 changes: 161 additions & 0 deletions configurator/src/components/ColorAssignments.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<script>
/**
* Semantic color role swatch grid.
*
* Shows how the current brand choices surface through the framework's
* semantic consumption tokens (body bg, text, border, link, …). Each swatch
* is resolved via the hidden probe host so var(), light-dark(), and
* oklch(from …) all compute correctly against the active overrides.
*
* Reactive: re-measures whenever overrides or the preview theme change.
*/
import { overrides, ui } from '../lib/store.svelte.js';
import { measureBackground } from '../lib/probeHost.js';
import { COLOR_ROLE_GROUPS, ALL_ROLE_TOKENS } from '../lib/colorRoles.js';

/** @type {Record<string, string>} token → resolved rgb() string */
let resolved = $state({});

$effect(() => {
// Read each override value so in-place key mutations retrigger this effect
// — `void overrides` alone subscribes to the binding, not its properties.
for (const key in overrides) void overrides[key];
void ui.previewTheme; // reactive dep
queueMicrotask(() => {
const next = {};
for (const token of ALL_ROLE_TOKENS) {
const rgb = measureBackground(`var(${token})`);
if (rgb && rgb !== 'rgba(0, 0, 0, 0)') next[token] = rgb;
}
resolved = next;
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Perceived lightness to decide swatch text color (0–255). */
function perceived(rgb) {
if (!rgb) return 128;
const m = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(rgb);
if (!m) return 128;
return 0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3];
}
</script>

<div class="ca">
{#each COLOR_ROLE_GROUPS as group (group.section)}
<div class="ca__section">
<h3 class="ca__section-title">{group.section}</h3>
<div class="ca__roles">
{#each group.roles as role (role.token)}
{@const bg = resolved[role.token]}
{@const light = bg ? perceived(bg) > 128 : true}
<div class="ca__role">
<div
class="ca__swatch"
class:ca__swatch--light={light}
class:ca__swatch--empty={!bg}
style:background={bg ?? 'transparent'}
title="{role.token}: {bg ?? 'resolving…'}"
>
{#if !bg}
<span class="ca__swatch-dots" aria-hidden="true">···</span>
{/if}
</div>
<div class="ca__meta">
<span class="ca__label">{role.label}</span>
<code class="ca__token">{role.token}</code>
</div>
</div>
{/each}
</div>
</div>
{/each}

<p class="ca__note">
Theme: <strong>{ui.previewTheme}</strong> · toggle in the preview pane to see the other mode.
</p>
</div>

<style>
.ca {
display: flex;
flex-direction: column;
gap: 16px;
padding: 14px 16px;
}

.ca__section-title {
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--cfg-text-faint);
}

.ca__roles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}

.ca__role {
display: flex;
flex-direction: column;
gap: 5px;
}

.ca__swatch {
height: 44px;
border-radius: var(--cfg-radius-s);
border: 1px solid var(--cfg-border-strong);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
background-image:
conic-gradient(#444 25%, #2a2a2a 0 50%, #444 0 75%, #2a2a2a 0);
background-size: 10px 10px;
}
.ca__swatch:not(.ca__swatch--empty) {
background-image: none;
}

.ca__swatch-dots {
font-size: 14px;
color: var(--cfg-text-faint);
letter-spacing: 2px;
}

.ca__meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.ca__label {
font-size: 11.5px;
font-weight: 600;
color: var(--cfg-text);
line-height: 1.3;
}
.ca__token {
font-family: var(--cfg-mono);
font-size: 9.5px;
color: var(--cfg-text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.ca__note {
margin: 0;
font-size: 11px;
color: var(--cfg-text-faint);
border-top: 1px solid var(--cfg-border);
padding-top: 10px;
}

@media (max-width: 600px) {
.ca { padding: 10px 12px; }
.ca__roles { grid-template-columns: repeat(2, 1fr); }
}
</style>
122 changes: 119 additions & 3 deletions configurator/src/components/DomainPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import ScaleGenerator from './ScaleGenerator.svelte';
import QuickKnobs from './QuickKnobs.svelte';
import StylePresetRow from './StylePresetRow.svelte';
import ColorAssignments from './ColorAssignments.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();
Expand Down Expand Up @@ -104,6 +105,22 @@
// "Show all variables" disclosure state (only meaningful when hasSettings).
let showAll = $state(false);

// Colors panel: progressive disclosure for secondary brand / status groups.
let showSecondary = $state(false);
let showStatus = $state(false);
let showColorRoles = $state(false);

// Partition brand color keys for the Colors panel accordion.
const BRAND_PRIMARY = BRAND_COLOR_KEYS.filter((c) => ['base', 'neutral', 'primary'].includes(c.key));
const BRAND_SECONDARY = BRAND_COLOR_KEYS.filter((c) => ['secondary', 'tertiary'].includes(c.key));
const BRAND_STATUS = BRAND_COLOR_KEYS.filter((c) => c.group === 'status');

// A brand color is "modified" if EITHER its light or dark variant is pinned
// (a dark-only override must still surface the badge).
const hasBrandOverride = (key) =>
overrides[`--sf-color-${key}-light`] != null ||
overrides[`--sf-color-${key}-dark`] != null;

// Modified tokens within this domain — drives the header "Reset N" button.
const modifiedHere = $derived(
domainTokens.filter((t) => overrides[t.name] != null)
Expand Down Expand Up @@ -232,18 +249,63 @@

<!-- Curated input surfaces: brand colors, friendly forms, or essentials. -->
{#if domain.brandColors}
<!-- Core brand colors — always shown -->
<section class="cfg-card panel__card">
{@render cardHead(
'Brand & status colors',
BRAND_COLOR_KEYS.length,
'Core brand colors',
BRAND_PRIMARY.length,
'Set light-mode values — dark mode is auto-derived. Click the dark swatch to pin a custom value.'
)}
<div class="panel__card-rows">
{#each BRAND_COLOR_KEYS as { key, label } (key)}
{#each BRAND_PRIMARY as { key, label } (key)}
<BrandColorRow colorKey={key} {label} />
{/each}
</div>
</section>

<!-- Extended brand colors (secondary / tertiary) — opt-in -->
<details class="panel__expand" bind:open={showSecondary}>
<summary class="panel__expand-summary">
<span class="panel__expand-chev" aria-hidden="true">›</span>
<span class="panel__expand-title">Extended brand colors</span>
<span class="panel__expand-count">{BRAND_SECONDARY.length} colors</span>
{#if BRAND_SECONDARY.some((c) => hasBrandOverride(c.key))}
<span class="panel__expand-badge">modified</span>
{/if}
</summary>
<div class="panel__card-rows">
{#each BRAND_SECONDARY as { key, label } (key)}
<BrandColorRow colorKey={key} {label} />
{/each}
</div>
</details>

<!-- Status colors — opt-in -->
<details class="panel__expand" bind:open={showStatus}>
<summary class="panel__expand-summary">
<span class="panel__expand-chev" aria-hidden="true">›</span>
<span class="panel__expand-title">Status colors</span>
<span class="panel__expand-count">{BRAND_STATUS.length} colors</span>
{#if BRAND_STATUS.some((c) => hasBrandOverride(c.key))}
<span class="panel__expand-badge">modified</span>
{/if}
</summary>
<div class="panel__card-rows">
{#each BRAND_STATUS as { key, label } (key)}
<BrandColorRow colorKey={key} {label} />
{/each}
</div>
</details>

<!-- Semantic role preview (where your brand colors surface) -->
<details class="cfg-card panel__card" bind:open={showColorRoles}>
<summary class="panel__card-head panel__expand-summary">
<span class="panel__expand-chev" aria-hidden="true">›</span>
<span class="panel__card-title">Semantic roles</span>
<span class="panel__expand-count">How your brand colors surface</span>
</summary>
<ColorAssignments />
</details>
{:else if basicGroups.length}
{#each basicGroups as group (group.title)}
<section class="cfg-card panel__card">
Expand Down Expand Up @@ -496,6 +558,60 @@
}
.panel__docs a:hover { color: var(--cfg-accent); text-decoration: underline; }

/* Progressive disclosure for Colors panel brand-color groups. */
.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;
}
.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 (Semantic roles) is open, its summary gets a border. */
details.cfg-card[open] > .panel__card-head.panel__expand-summary {
border-bottom: 1px solid var(--cfg-border);
}

/* Tighter horizontal padding on narrow phones recovers ~16px of content
width, reducing the chance of token editors overflowing their container. */
@media (max-width: 600px) {
Expand Down
Loading