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
32 changes: 22 additions & 10 deletions configurator/scripts/check-curation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,45 @@
* Every PUBLIC / PUBLIC-ADVANCED knob token must map to a named domain so it
* shows up in the right panel rather than an uncategorised fallback.
*
* Patterns kept in sync with src/lib/domains.ts — if you update the domain
* patterns there, update DOMAIN_PATTERNS here as well.
* Classification is driven by src/data/domain-map.json — the same file
* src/lib/domains.ts uses at runtime — so this guard and the app can never
* drift. If the framework adds a token in a brand-new namespace, add that
* namespace to domain-map.json.
*
* Usage: node scripts/check-curation.mjs # exits 1 on any orphan
* Also asserted by tests/curation.test.js so CI catches it either way.
*/
import { pathToFileURL } from 'url';
import data from '../src/data/api-index.generated.json' with { type: 'json' };
import DOMAIN_PATTERNS from '../src/data/domain-patterns.json' with { type: 'json' };
import DOMAIN_MAP from '../src/data/domain-map.json' with { type: 'json' };

/**
* Tokens that legitimately live outside the named domains.
* @type {Set<string>}
*/
const ALLOWLIST = new Set([]);

const NAMESPACE_DOMAIN = DOMAIN_MAP.namespaces;
const EXCEPTIONS = DOMAIN_MAP.exceptions;

/** The `--sf-<namespace>-…` segment of a token name. */
function inferNamespace(name) {
const m = /^--sf-([a-z0-9]+)/.exec(name || '');
return m ? m[1] : '';
}

/**
* Returns true when a token name matches any domain pattern.
* @param {{name:string}} token
* Returns true when a token is explicitly classified into a domain — i.e. it
* has a per-token exception or its (manifest-authored) namespace is mapped.
* Mirrors src/lib/domains.ts's classifyKnown() from the same domain-map.json.
* @param {{name:string, namespace?:string}} token
* @returns {boolean}
*/
function isExplicitlyClassified(token) {
const name = token.name || '';
for (const patterns of Object.values(DOMAIN_PATTERNS)) {
if (patterns.some(p => name.includes(p))) return true;
}
return false;
if (EXCEPTIONS[name]) return true;
const ns = token.namespace || inferNamespace(name);
return Boolean(NAMESPACE_DOMAIN[ns]);
}

/**
Expand All @@ -54,7 +66,7 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
`[configurator:curation] ${orphans.length} knob token(s) match no domain ` +
`pattern and would only appear in an uncategorised bucket:\n` +
orphans.map(n => ` - ${n}`).join('\n') +
`\n\nAdd a name pattern to src/data/domain-patterns.json.`
`\n\nAdd the token's namespace to src/data/domain-map.json.`
);
process.exit(1);
}
Expand Down
12 changes: 4 additions & 8 deletions configurator/src/components/DomainPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { SlidersHorizontal, List } from '@lucide/svelte';
import type { SlashedToken } from '../types';
import { DOMAIN_PATTERNS, domainOf } from '../lib/domains';
import { domainOf } from '../lib/domains';
import HomePanel from './panels/HomePanel.svelte';
import ColorsPanel from './panels/ColorsPanel.svelte';
import TypographyPanel from './panels/TypographyPanel.svelte';
Expand Down Expand Up @@ -45,12 +45,8 @@
view = "controls";
});

let patterns = $derived(DOMAIN_PATTERNS[domain] ?? [domain]);

// Uses domainOf() rather than the raw patterns list so this badge always
// agrees with App.svelte's "Reset N" count — matching against a single
// domain's patterns in isolation over-counts where patterns overlap (e.g.
// layout's "-bg-" also appears in color tokens like --sf-color-bg--active).
// domainOf() is the single classifier shared with the sidebar badge and the
// category Reset, so this count always agrees with them.
let domainOverridesInTokenTab = $derived(
tokens.filter((t) => domainOf(t.name) === domain && t.name in overrides).length
);
Expand Down Expand Up @@ -104,7 +100,7 @@
<AllTokensTab
{tokens}
{overrides}
{patterns}
{domain}
{onSet}
{onReset}
/>
Expand Down
75 changes: 73 additions & 2 deletions configurator/src/components/inputs/TokenRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,33 @@
import type { SlashedToken } from '../../types';
import { resolveColor, previewVersion } from '../../lib/previewResolver.svelte';
import { scaleForValue } from '../../lib/variableScales';
import { roleOf, aliasTargetOf, tokenState, ROLE_LABEL, type TokenRole, type TokenState } from '../../lib/tokenModel';

let { token, overrideValue, onSet, onReset }: {
let { token, overrideValue, onSet, onReset, dependentsCount = 0 }: {
token: SlashedToken;
overrideValue?: string;
onSet: (value: string) => void;
onReset: () => void;
/** How many other tokens reference this one (from the dependency graph). */
dependentsCount?: number;
} = $props();

// --- Token model: role, alias target and override state -------------------
// These make the row honest about *what kind of token* is being edited and
// whether the current override quietly disconnects it from the system that
// produces it (a generated output/alias/scale step). See lib/tokenModel.ts.
let role = $derived<TokenRole>(roleOf(token));
let aliasTarget = $derived(aliasTargetOf(token));
let overrideState = $derived<TokenState>(
overrideValue === undefined ? "default" : tokenState(token, { [token.name]: overrideValue })
);
const ROLE_STYLE: Record<TokenRole, string> = {
source: "text-slate-500 dark:text-slate-400 bg-black/5 dark:bg-white/8",
alias: "text-sky-700 dark:text-sky-300 bg-sky-500/10",
output: "text-violet-700 dark:text-violet-300 bg-violet-500/10",
};
let aliasShort = $derived(aliasTarget ? aliasTarget.replace("--sf-", "") : "");

const CUSTOM = "__sf_custom__";
let expanded = $state(false);

Expand Down Expand Up @@ -73,9 +92,22 @@
style:background={swatchColor}
></div>
{/if}
<div class="text-[10px] font-mono text-slate-700 dark:text-slate-300 truncate flex-1" title={token.name}>
<div class="text-[10px] font-mono text-slate-700 dark:text-slate-300 truncate min-w-0 flex-1" title={token.name}>
{shortName}
</div>
<!-- Role badge: source (settable) · alias (re-export) · output (generated).
Makes it obvious when you're about to edit something the system derives
rather than a knob you own. -->
<span
class={`shrink-0 px-1 py-px rounded text-[7px] font-bold uppercase tracking-wider ${ROLE_STYLE[role]}`}
title={role === "source"
? "Source — a value you set"
: role === "alias"
? `Alias — re-exports ${aliasShort}`
: "Output — generated from other tokens"}
>
{ROLE_LABEL[role]}
</span>
{#if isOverridden}
<button
onclick={onReset}
Expand All @@ -91,6 +123,45 @@
<div class="text-[9px] text-slate-400 dark:text-slate-600 leading-snug mt-0.5 pl-3.5">{token.description}</div>
{/if}

<!-- Relationship context: where this token inherits from, and what depends on
it. Turns the flat list into a navigable graph the audit found missing. -->
{#if aliasTarget || dependentsCount > 0}
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-0.5 pl-3.5 text-[8px] text-slate-400 dark:text-slate-600">
{#if aliasTarget}
<span class="font-mono">↳ inherits <span class="text-sky-600 dark:text-sky-400">{aliasShort}</span></span>
{/if}
{#if dependentsCount > 0}
<span title={`${dependentsCount} token${dependentsCount !== 1 ? "s" : ""} reference this token in the framework defaults`}>
used by {dependentsCount} default{dependentsCount !== 1 ? "s" : ""}
</span>
{/if}
</div>
{/if}

<!-- Detached / invalid warning: only when the override actually disconnects
the token from its generator, or fails validation. -->
{#if overrideState === "detached"}
<div class="flex items-start gap-1 mt-1 ml-3.5 px-1.5 py-1 rounded bg-amber-500/10 text-[8px] text-amber-700 dark:text-amber-300 leading-snug">
<span class="font-bold shrink-0">Detached</span>
<span>
{role === "alias"
? `frozen — no longer follows ${aliasShort}.`
: role === "output"
? "frozen — no longer derived from its source tokens."
: "a generated scale step is pinned; its source knob won't move it."}
<button onclick={onReset} class="underline hover:text-amber-900 dark:hover:text-amber-100 cursor-pointer">Restore link</button>
</span>
</div>
{:else if overrideState === "invalid"}
<div class="flex items-start gap-1 mt-1 ml-3.5 px-1.5 py-1 rounded bg-rose-500/10 text-[8px] text-rose-700 dark:text-rose-300 leading-snug">
<span class="font-bold shrink-0">Invalid</span>
<span>
this value can't be applied safely.
<button onclick={onReset} class="underline hover:text-rose-900 dark:hover:text-rose-100 cursor-pointer">Reset</button>
</span>
</div>
{/if}

<div class="mt-1 pl-3.5">
{#if showScalePicker && scaleOpts}
<select
Expand Down
15 changes: 12 additions & 3 deletions configurator/src/components/panels/AllTokensTab.svelte
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
<script lang="ts">
import type { SlashedToken } from '../../types';
import TokenRow from '../inputs/TokenRow.svelte';
import { buildDependencyGraph } from '../../lib/tokenModel';
import { domainOf } from '../../lib/domains';

let { tokens, overrides, patterns, onSet, onReset }: {
let { tokens, overrides, domain, onSet, onReset }: {
tokens: SlashedToken[];
overrides: Record<string, string>;
patterns: string[];
domain: string;
onSet: (name: string, value: string) => void;
onReset: (name: string) => void;
} = $props();

const TIER_ORDER: Record<string, number> = { PUBLIC: 0, "PUBLIC-ADVANCED": 1, INTERNAL: 2 };

// Whole-catalogue dependency graph (built once from the full token set, not
// just this domain's slice) so every row can show how many tokens read it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Dependency counts ignore overrides

The dependency graph is built exclusively from catalogue defaults, so relinking a token or replacing a reference with a concrete override leaves the “used by N” guidance stale. Build the graph from effective values or label these counts as default-catalogue relationships.

let graph = $derived(buildDependencyGraph(tokens));

let query = $state("");
let showInternal = $state(false);
let onlyModified = $state(false);

// Filter by the SAME classifier the sidebar badge and category Reset use, so
// this list can never disagree with them about what belongs to the domain.
let domainTokens = $derived(
tokens
.filter((t) => patterns.some((p) => t.name.includes(p)))
.filter((t) => domainOf(t.name) === domain)
.sort((a, b) => {
const ta = TIER_ORDER[a.tier ?? "INTERNAL"] ?? 2;
const tb = TIER_ORDER[b.tier ?? "INTERNAL"] ?? 2;
Expand Down Expand Up @@ -141,6 +149,7 @@
<TokenRow
token={t}
overrideValue={overrides[t.name]}
dependentsCount={graph.usedBy[t.name]?.length ?? 0}
onSet={(v) => onSet(t.name, v)}
onReset={() => onReset(t.name)}
/>
Expand Down
14 changes: 6 additions & 8 deletions configurator/src/components/panels/GenericTokenPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
<script lang="ts">
import type { SlashedToken } from '../../types';
import TokenRow from '../inputs/TokenRow.svelte';
import { domainOf } from '../../lib/domains';

let { domain, tokens, overrides, onSet, onReset, patterns }: {
let { domain, tokens, overrides, onSet, onReset }: {
domain: string;
tokens: SlashedToken[];
overrides: Record<string, string>;
onSet: (name: string, value: string) => void;
onReset: (name: string) => void;
patterns?: string[];
} = $props();

let query = $state("");

let domainTokens = $derived(() => {
const pats = patterns ?? [domain];
return tokens.filter((t) =>
pats.some((p) => t.name.includes(p)) && t.tier !== "INTERNAL"
);
});
// Same classifier as the sidebar badge / Reset / All-tokens list.
let domainTokens = $derived(() =>
tokens.filter((t) => domainOf(t.name) === domain && t.tier !== "INTERNAL")
);

let filtered = $derived(() => {
if (!query) return domainTokens();
Expand Down
106 changes: 106 additions & 0 deletions configurator/src/data/domain-map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
{
"_doc": "Authoritative, metadata-driven token→domain classification. Keyed on the framework-authored `namespace` (the segment after --sf-), which is precise and never drifts from the framework — unlike the old substring matching, where overlapping fragments (e.g. 'color' in --sf-focus-ring-color) routed a token to the wrong panel and made the sidebar badge, the category Reset, and the All-tokens list disagree. A namespace maps to exactly one domain; the handful of genuinely mixed namespaces are resolved per-token in `exceptions`. Every domain here must also exist in domains.ts's DOMAINS list. check-curation.mjs consumes the same file, so the build guard and the runtime can never diverge.",
"namespaces": {
"color": "colors",
"contrast": "colors",
"palette": "colors",
"lumlocker": "colors",
"gradient": "colors",
"marker": "colors",

"text": "typography",
"font": "typography",
"body": "typography",
"heading": "typography",
"h1": "typography",
"h2": "typography",
"h3": "typography",
"h4": "typography",
"h5": "typography",
"h6": "typography",
"leading": "typography",
"tracking": "typography",
"link": "typography",
"optical": "typography",
"display": "typography",
"code": "typography",
"fluid": "typography",

"space": "spacing",
"gap": "spacing",
"gutter": "spacing",
"section": "spacing",
"component": "spacing",
"content": "spacing",

"container": "layout",
"grid": "layout",
"center": "layout",
"cluster": "layout",
"cover": "layout",
"equal": "layout",
"frame": "layout",
"imposter": "layout",
"reel": "layout",
"sidebar": "layout",
"switcher": "layout",
"alternate": "layout",
"bento": "layout",
"stack": "layout",
"header": "layout",
"sticky": "layout",
"safe": "layout",
"touch": "layout",
"breakout": "layout",
"box": "layout",
"bg": "layout",
"ratio": "layout",

"radius": "borders",
"border": "borders",
"divider": "borders",
"field": "borders",
"media": "borders",

"shadow": "shadows",

"motion": "motion",
"duration": "motion",
"ease": "motion",
"transition": "motion",
"stagger": "motion",
"scroll": "motion",
"hover": "motion",
"theme": "motion",
"animation": "motion",

"blur": "effects",
"opacity": "effects",
"drop": "effects",

"prose": "macros",
"flow": "macros",
"line": "macros",
"scrim": "macros",
"surface": "macros",
"mask": "macros",

"btn": "components",
"card": "components",

"z": "misc",
"icon": "misc",
"size": "misc",
"object": "misc",
"scrollbar": "misc",
"focus": "misc",
"is": "misc"
},
"exceptions": {
"--sf-content-width": "layout",
"--sf-content-intrinsic-size": "macros",
"--sf-scroll-shadow-size": "macros",
"--sf-scroll-offset-gap": "layout",
"--sf-field-required-marker": "misc"
}
}
Loading