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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ jobs:
- run: node scripts/check-hook-tokens.js
- run: node scripts/check-mirrors.js
- run: node scripts/check-bundle-defs.js
# Strings that must never ship: hardcoded colour literals outside the
# token source files, and external URLs in a built bundle. Runs after
# check-artifacts.js, which rebuilds dist/ — so the bundle rules have
# something to scan.
- run: npm run check:forbidden-strings
# docs/token-renames.json must stay truthful: every rename target live,
# no old name still live. A stale map migrates theme files onto dead
# tokens, which is worse than having no map at all.
- run: npm run check:token-renames

dependency-audit:
name: Dependency vulnerability audit
Expand Down
37 changes: 37 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ requires a rebuild+redeploy, not just a file edit.
| `npm run check:layer-order` | Verify `docs/architecture.md`'s `@layer` block and specificity ladder match `core/layers.css` (CI gate) |
| `npm run check:macros` | Verify `.sf-*` macro classes match `docs/macros.md` (CI gate) |
| `npm run check:registry` | Verify `token-registry.json` is in sync with source (CI gate) |
| `npm run check:forbidden-strings` | Verify no hardcoded colour literal sits outside the token source files and no shipped bundle contains an external URL (CI gate) |
| `npm run check:token-renames` | Verify `docs/token-renames.json` is truthful — every rename target is a live token, no old name still is (CI gate) |
| `npm run migrate:theme -- <file> [--write]` | Migrate a `*.slashed-theme.json` theme file onto the current token API |
| `npm run audit:check` | Verify `docs/registry.json` matches source without writing (CI gate) |
| `npm run lint:css` | Lint all CSS source with stylelint (CI gate) |
| `npm run lint:css:fix` | Lint CSS source and auto-fix violations |
Expand Down Expand Up @@ -140,6 +143,40 @@ instance token, an example of a component the framework does not ship) — recor
it in `docs/ref-allowlist.json` with a reason. `docs/migration.md` (historical)
and `docs/roadmap.md` (forward-looking) are whole-doc exclusions.

## Token renames — MANDATORY

`docs/token-renames.json` is the machine-readable mirror of `docs/migration.md`.
It is what lets a **theme file** (`*.slashed-theme.json` — the portable,
name-keyed override snapshot, see `scripts/lib/theme-file.js`) survive a rename:
`npm run migrate:theme -- <file> --write` rewrites old names, drops removed ones
with the reason, and never discards an override it does not recognise.

**Any PR that renames or removes a `--sf-*` token must add the corresponding
entry**, in the same PR as the CSS change:

- **Renamed** → add to `renames` as `"--sf-old": "--sf-new"`. Record the
*fully resolved* destination, never an intermediate name: rename targets must
be live, so a chain (`a → b → c`) fails the gate by construction.
- **Removed with no replacement** → add to `removals` with a reason saying what
to use instead. A removal without a reason is rejected.

This cannot be generated. `token-registry.json` keeps ids permanent, but
`check-token-registry.js` deliberately permits renames as *in-place name
updates on the same id* — so after a rename the old name is simply gone, with
nothing to look it up by. That is harmless for the share-link codec (it stores
ids) and fatal for a name-keyed theme file. A rename and a delete+add pair are
also indistinguishable to a generator, so the map is curated by hand.

```bash
npm run check:token-renames # must pass — CI fails if it doesn't
```

The gate holds the map to three invariants: every rename target is live, no old
name is still live, and renames and removals are disjoint. Note that "live"
includes tokens merely *declared* in `core/`/`optional/` CSS — so a leftover
declaration of a supposedly-renamed token will fail this gate, which is how it
catches a half-finished rename.

## Tests

```bash
Expand Down
46 changes: 46 additions & 0 deletions configurator/scripts/sync-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ const SOURCE =
const ANNOTATIONS_FILE = path.join(FRAMEWORK_ROOT, 'docs', 'token-annotations.json');
const BUNDLE_CONFIG_FILE = path.join(FRAMEWORK_ROOT, 'bundle.config.json');
const REGISTRY_FILE = path.join(FRAMEWORK_ROOT, 'token-registry.json');
const RENAMES_FILE = path.join(FRAMEWORK_ROOT, 'docs', 'token-renames.json');

const OUT_DIR = path.join(CONFIGURATOR_ROOT, 'src', 'data');
const OUT = path.join(OUT_DIR, 'api-index.generated.json');
const CLASSES_OUT = path.join(OUT_DIR, 'classes.generated.json');
const BUNDLES_OUT = path.join(OUT_DIR, 'bundles.generated.json');
const REGISTRY_OUT = path.join(OUT_DIR, 'token-registry.generated.json');
const RENAMES_OUT = path.join(OUT_DIR, 'token-renames.generated.json');

// jsDelivr serves the published dist branch (see .github/workflows/publish-dist.yml)
// at the repo root, so a bundle's minified file is <CDN_BASE>/slashed.<id>.min.css.
Expand Down Expand Up @@ -314,6 +316,10 @@ function main() {
// verbatim so the configurator imports it the same way model.js imports the
// api-index — and so the runtime can never drift from the committed registry.
syncRegistry();

// Rename/removal map for theme-file import (src/lib/themeFile.ts), so an
// override set authored against an older SLASHED can be migrated on load.
syncRenames();
}

/**
Expand Down Expand Up @@ -348,4 +354,44 @@ function syncRegistry() {
);
}

/**
* Copy docs/token-renames.json → src/data/token-renames.generated.json, so the
* configurator's theme-file import can migrate an old override set without
* reaching outside its own package at runtime (the @framework-css alias is
* remapped by the WP plugin, so cross-boundary runtime imports are not safe
* here — a generated data file is).
*
* The map's truthfulness is guaranteed upstream by scripts/check-token-renames.js.
*/
function syncRenames() {
if (!fs.existsSync(RENAMES_FILE)) {
console.error(
`[configurator:sync] token-renames.json not found at ${RENAMES_FILE}\n` +
`It is a hand-maintained mirror of docs/migration.md — it should be committed.`
);
process.exit(1);
}
let map;
try {
map = JSON.parse(fs.readFileSync(RENAMES_FILE, 'utf8'));
} catch (err) {
console.error(`[configurator:sync] ${RENAMES_FILE} is not valid JSON (${err.message}).`);
process.exit(1);
}
const out = {
_sync: {
generatedBy: 'configurator/scripts/sync-api.mjs',
source: 'docs/token-renames.json',
},
renames: map.renames ?? {},
removals: map.removals ?? {},
};
fs.writeFileSync(RENAMES_OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
console.log(
`[configurator:sync] ${path.relative(FRAMEWORK_ROOT, RENAMES_OUT)} ← ` +
`docs/token-renames.json (${Object.keys(out.renames).length} renames, ` +
`${Object.keys(out.removals).length} removals)`
);
}

main();
2 changes: 1 addition & 1 deletion configurator/src/components/DomainPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
{:else if domain === "wcag"}
<WcagPanel {tokens} {overrides} {onSet} {onBulkChange} />
{:else if domain === "setup"}
<ExportPanel {overrides} />
<ExportPanel {overrides} {tokens} {onApplyTheme} />
{:else if domain === "cheatsheet"}
<CheatsheetPanel />
{/if}
Expand Down
107 changes: 105 additions & 2 deletions configurator/src/components/panels/ExportPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,16 +1,61 @@
<script lang="ts">
import { Check, Copy, Download, Link } from '@lucide/svelte';
import { Check, Copy, Download, Link, Upload, TriangleAlert } from '@lucide/svelte';
import { generateCSS, buildShareUrl } from '../../lib/codec';
import { getShareBaseUrl } from '../../lib/persistence';
import { serializeThemeFile, importThemeFile } from '../../lib/themeFile';
import type { SlashedToken } from '../../types';

let { overrides }: {
let { overrides, tokens = [], onApplyTheme }: {
overrides: Record<string, string>;
tokens?: SlashedToken[];
onApplyTheme?: (overrides: Record<string, string>) => void;
} = $props();

// Declared globally in src/vite-env.d.ts, injected by Vite at build time.
const frameworkVersion =
typeof __SLASHED_VERSION__ !== "undefined" ? __SLASHED_VERSION__ : undefined;

let outputMode = $state<"layer" | "root">("layer");
let copied = $state(false);
let copiedLink = $state(false);

// Theme-file import feedback: what the migration did, or why it refused.
let importNotes = $state<string[]>([]);
let importErrors = $state<string[]>([]);
let fileInput = $state<HTMLInputElement | null>(null);

let liveTokenNames = $derived(new Set(tokens.map((t) => t.name)));

function handleDownloadTheme() {
const json = serializeThemeFile({ overrides, slashedVersion: frameworkVersion });
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "slashed-theme.json";
a.click();
URL.revokeObjectURL(url);
}

async function handleImportTheme(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;

importNotes = [];
importErrors = [];

const result = await importThemeFile(file, liveTokenNames);
if (!result.overrides) {
importErrors = result.errors;
} else {
importNotes = result.notes;
onApplyTheme?.(result.overrides);
}
// Allow re-selecting the same file after a fix.
input.value = "";
}

let css = $derived(generateCSS(overrides, { mode: outputMode, banner: true }));
let count = $derived(Object.keys(overrides).length);

Expand Down Expand Up @@ -139,6 +184,64 @@
</div>
</div>

<!-- Portable theme file: the reviewable, committable form of this override set -->
<div class="space-y-2">
<div class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Theme file</div>
<p class="text-[10px] text-slate-600 dark:text-slate-400 leading-relaxed">
A named, sorted JSON snapshot you can commit next to your CSS and review in a diff.
Importing one migrates tokens renamed since it was written.
</p>

<div class="flex gap-2">
<button
onclick={handleDownloadTheme}
disabled={count === 0}
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border border-black/8 dark:border-white/8 bg-black/4 dark:bg-white/4 text-[10px] font-bold text-slate-600 dark:text-slate-400 hover:bg-black/8 dark:hover:bg-white/8 hover:text-slate-800 dark:hover:text-slate-200 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<Download class="w-3 h-3" />
Download .json
</button>
<button
onclick={() => fileInput?.click()}
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border border-black/8 dark:border-white/8 bg-black/4 dark:bg-white/4 text-[10px] font-bold text-slate-600 dark:text-slate-400 hover:bg-black/8 dark:hover:bg-white/8 hover:text-slate-800 dark:hover:text-slate-200 transition-all cursor-pointer"
>
<Upload class="w-3 h-3" />
Import…
</button>
</div>

<input
bind:this={fileInput}
type="file"
accept="application/json,.json"
onchange={handleImportTheme}
class="hidden"
/>

{#if importErrors.length}
<div class="rounded-lg bg-red-500/8 border border-red-500/20 p-3 space-y-1">
<div class="flex items-center gap-1.5 text-[10px] font-bold text-red-700 dark:text-red-300">
<TriangleAlert class="w-3 h-3" />
Import refused — nothing was changed
</div>
{#each importErrors as err}
<div class="text-[10px] text-red-700/80 dark:text-red-300/80 leading-relaxed font-mono">{err}</div>
{/each}
</div>
{/if}

{#if importNotes.length}
<div class="rounded-lg bg-amber-500/8 border border-amber-500/20 p-3 space-y-1">
<div class="text-[10px] font-bold text-amber-700 dark:text-amber-300">
Imported with {importNotes.length} adjustment{importNotes.length !== 1 ? "s" : ""}
</div>
{#each importNotes as note}
<div class="text-[10px] text-amber-700/80 dark:text-amber-300/80 leading-relaxed font-mono">{note}</div>
{/each}
</div>
{/if}
</div>

<!-- W3C Design Tokens export -->
<button
onclick={handleDownloadW3C}
Expand Down
86 changes: 86 additions & 0 deletions configurator/src/data/token-renames.generated.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"_sync": {
"generatedBy": "configurator/scripts/sync-api.mjs",
"source": "docs/token-renames.json"
},
"renames": {
"--sf-color-primary-light": "--sf-color-primary-source-light",
"--sf-color-primary-dark": "--sf-color-primary-source-dark",
"--sf-color-secondary-light": "--sf-color-secondary-source-light",
"--sf-color-secondary-dark": "--sf-color-secondary-source-dark",
"--sf-color-tertiary-light": "--sf-color-tertiary-source-light",
"--sf-color-tertiary-dark": "--sf-color-tertiary-source-dark",
"--sf-color-action-light": "--sf-color-action-source-light",
"--sf-color-action-dark": "--sf-color-action-source-dark",
"--sf-color-neutral-light": "--sf-color-neutral-source-light",
"--sf-color-neutral-dark": "--sf-color-neutral-source-dark",
"--sf-color-base-light": "--sf-color-base-source-light",
"--sf-color-base-dark": "--sf-color-base-source-dark",
"--sf-color-success-light": "--sf-color-success-source-light",
"--sf-color-success-dark": "--sf-color-success-source-dark",
"--sf-color-warning-light": "--sf-color-warning-source-light",
"--sf-color-warning-dark": "--sf-color-warning-source-dark",
"--sf-color-info-light": "--sf-color-info-source-light",
"--sf-color-info-dark": "--sf-color-info-source-dark",
"--sf-color-danger-light": "--sf-color-danger-source-light",
"--sf-color-danger-dark": "--sf-color-danger-source-dark",
"--sf-color-error": "--sf-color-danger",
"--sf-color-error-subtle": "--sf-color-danger-subtle",
"--sf-color-error-muted": "--sf-color-danger-muted",
"--sf-color-error-strong": "--sf-color-danger-strong",
"--sf-color-error-source-light": "--sf-color-danger-source-light",
"--sf-color-error-source-dark": "--sf-color-danger-source-dark",
"--sf-color-primary-ghost": "--sf-color-primary-tint",
"--sf-color-secondary-ghost": "--sf-color-secondary-tint",
"--sf-color-tertiary-ghost": "--sf-color-tertiary-tint",
"--sf-color-action-ghost": "--sf-color-action-tint",
"--sf-color-neutral-ghost": "--sf-color-neutral-tint",
"--sf-color-base-ghost": "--sf-color-base-tint",
"--sf-color-base-superlight": "--sf-color-base-50",
"--sf-color-base-xlight": "--sf-color-base-200",
"--sf-color-base-lighter": "--sf-color-base-400",
"--sf-color-base-darker": "--sf-color-base-600",
"--sf-color-base-xdark": "--sf-color-base-800",
"--sf-color-base-superdark": "--sf-color-base-950",
"--sf-color-text--on-surface": "--sf-color-text--on-base",
"--sf-color-text--secondary": "--sf-color-text--subtle",
"--sf-z-low": "--sf-z-sticky",
"--sf-z-mid": "--sf-z-fixed",
"--sf-z-high": "--sf-z-dropdown",
"--sf-z-top": "--sf-z-toast",
"--sf-avatar-size": "--sf-card-avatar-size"
},
"removals": {
"--sf-z-max": "Ambiguous by design: 0.6.0 split the old top rung into --sf-z-overlay (1030) and --sf-z-modal (1040). Pick the one matching your element's role — this is not auto-migrated because the wrong choice silently reorders your stacking context.",
"--sf-blur-xs": "Removed in 0.6.0 — use --sf-blur (single default) or an inline blur(Npx).",
"--sf-blur-s": "Removed in 0.6.0 — use --sf-blur (single default) or an inline blur(Npx).",
"--sf-blur-m": "Removed in 0.6.0 — use --sf-blur (single default) or an inline blur(Npx).",
"--sf-blur-l": "Removed in 0.6.0 — use --sf-blur (single default) or an inline blur(Npx).",
"--sf-blur-xl": "Removed in 0.6.0 — use --sf-blur (single default) or an inline blur(Npx).",
"--sf-opacity-0": "Removed in 0.6.0 — use --sf-opacity-muted / --sf-opacity-disabled, or a literal value.",
"--sf-opacity-10": "Removed in 0.6.0 — use --sf-opacity-muted / --sf-opacity-disabled, or a literal value.",
"--sf-opacity-25": "Removed in 0.6.0 — use --sf-opacity-muted / --sf-opacity-disabled, or a literal value.",
"--sf-opacity-50": "Removed in 0.6.0 — use --sf-opacity-muted (0.5), or a literal value.",
"--sf-opacity-75": "Removed in 0.6.0 — use --sf-opacity-muted / --sf-opacity-disabled, or a literal value.",
"--sf-opacity-100": "Removed in 0.6.0 — use --sf-opacity-muted / --sf-opacity-disabled, or a literal value.",
"--sf-stroke-thin": "Removed in 0.6.0 — use an SVG stroke-width=\"N\" attribute or a --sf-border-width-* token.",
"--sf-stroke-regular": "Removed in 0.6.0 — use an SVG stroke-width=\"N\" attribute or a --sf-border-width-* token.",
"--sf-stroke-bold": "Removed in 0.6.0 — use an SVG stroke-width=\"N\" attribute or a --sf-border-width-* token.",
"--sf-stroke-heavy": "Removed in 0.6.0 — use an SVG stroke-width=\"N\" attribute or a --sf-border-width-* token.",
"--sf-col-width-s": "Removed in 0.6.0 — set the column-width property directly.",
"--sf-col-width-m": "Removed in 0.6.0 — set the column-width property directly.",
"--sf-col-width-l": "Removed in 0.6.0 — set the column-width property directly.",
"--sf-col-rule-width-s": "Removed in 0.6.0 — set the column-rule-width property directly.",
"--sf-col-rule-width-m": "Removed in 0.6.0 — set the column-rule-width property directly.",
"--sf-col-rule-width-l": "Removed in 0.6.0 — set the column-rule-width property directly.",
"--sf-font-weight-thin": "Removed in 0.6.0 — write font-weight: 100 inline, or re-tune a role token (--sf-font-weight-{body,heading,display,interactive,strong}).",
"--sf-font-weight-extralight": "Removed in 0.6.0 — write font-weight: 200 inline, or re-tune a role token.",
"--sf-font-weight-extrabold": "Removed in 0.6.0 — write font-weight: 800 inline, or re-tune a role token.",
"--sf-font-weight-black": "Removed in 0.6.0 — write font-weight: 900 inline, or re-tune a role token.",
"--sf-fluid-custom-1": "Removed in 0.6.0 — write a clamp() directly. --sf-fluid-min-vw / --sf-fluid-max-vw are unchanged.",
"--sf-fluid-custom-2": "Removed in 0.6.0 — write a clamp() directly. --sf-fluid-min-vw / --sf-fluid-max-vw are unchanged.",
"--sf-fluid-custom-3": "Removed in 0.6.0 — write a clamp() directly. --sf-fluid-min-vw / --sf-fluid-max-vw are unchanged.",
"--sf-truncate-suffix": "Removed in 0.6.0 — it was never read. .sf-truncate uses text-overflow: ellipsis.",
"--sf-corner-scoop-size": "Removed before 1.0 together with the .sf-corner-scoop macro (issue #484): a single absolute size needed per-element tuning to read well at both button and hero scale, and the mask clipped box-shadow/border."
}
}
Loading