Files
token-tools/src/components/Workbench.tsx
T
zemion c305f3da43
Verify / verify (push) Canceled after 0s
Release Token Tools 0.2.0
2026-09-02 12:38:34 +02:00

556 lines
17 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
deleteToken,
exportTokens,
MAX_TEXT,
parseTokenDocument,
resolveTheme,
updateToken,
type TokenRecord,
type TokenType,
} from "../core/tokens";
const EXAMPLE = `{
"$sets": {
"core": {
"color": {
"$type": "color",
"brand": { "$value": "#6546d7" },
"text": { "$value": "#202332" },
"surface": { "$value": "#ffffff" }
},
"space": {
"$type": "dimension",
"sm": { "$value": { "value": 8, "unit": "px" } },
"md": { "$value": "{space.sm}" }
},
"heading": {
"$type": "typography",
"$value": { "fontFamily": ["Inter", "sans-serif"], "fontSize": "24px", "fontWeight": 700, "lineHeight": "32px" }
}
},
"dark": {
"color": {
"$type": "color",
"text": { "$value": "#f4f2ff" },
"surface": { "$value": "#171522" }
}
}
},
"$themes": {
"light": ["core"],
"dark": ["core", "dark"]
}
}`;
type ExportFormat = "dtcg" | "css" | "sass" | "android" | "swift";
export function Workbench() {
const [draft, setDraft] = useState(EXAMPLE);
const [documentText, setDocumentText] = useState(EXAMPLE);
const [parseError, setParseError] = useState("");
const document = useMemo(
() => parseTokenDocument(documentText),
[documentText],
);
const [theme, setTheme] = useState("light");
const [mode, setMode] = useState("default");
const [setName, setSetName] = useState("core");
const [selectedPath, setSelectedPath] = useState<string | null>(
"color.brand",
);
const [path, setPath] = useState("color.brand");
const [type, setType] = useState<TokenType>("color");
const [value, setValue] = useState('"#6546d7"');
const [editorError, setEditorError] = useState("");
const [format, setFormat] = useState<ExportFormat>("css");
const [filter, setFilter] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const activeTheme = document.themes[theme]
? theme
: (Object.keys(document.themes)[0] ?? "default");
const activeMode = document.modes[activeTheme]?.[mode]
? mode
: (Object.keys(document.modes[activeTheme] ?? {})[0] ?? "default");
const resolution = useMemo(
() => resolveTheme(document, activeTheme, activeMode),
[document, activeTheme, activeMode],
);
const exported = useMemo(
() => exportTokens(format, resolution.tokens),
[format, resolution.tokens],
);
const visibleTokens = (document.sets[setName] ?? []).filter((token) =>
token.path.toLowerCase().includes(filter.toLowerCase()),
);
function applyDocument(): void {
try {
const parsed = parseTokenDocument(draft);
setDocumentText(draft);
const nextTheme = parsed.themes[theme]
? theme
: (Object.keys(parsed.themes)[0] ?? "default");
const nextSet = parsed.sets[setName]
? setName
: (Object.keys(parsed.sets)[0] ?? "default");
setTheme(nextTheme);
setMode(Object.keys(parsed.modes[nextTheme] ?? {})[0] ?? "default");
setSetName(nextSet);
setSelectedPath(null);
setParseError("");
} catch (caught) {
setParseError(caught instanceof Error ? caught.message : String(caught));
}
}
function chooseToken(token: TokenRecord): void {
setSelectedPath(token.path);
setPath(token.path);
setType(token.type);
setValue(JSON.stringify(token.value, null, 2));
setEditorError("");
}
function saveToken(): void {
try {
let parsedValue: unknown;
try {
parsedValue = JSON.parse(value) as unknown;
} catch {
parsedValue = value;
}
const next = updateToken(
document,
setName,
selectedPath,
path.trim(),
type,
parsedValue,
);
setDocumentText(next);
setDraft(next);
setSelectedPath(path.trim());
setEditorError("");
} catch (caught) {
setEditorError(caught instanceof Error ? caught.message : String(caught));
}
}
function removeToken(): void {
if (!selectedPath) return;
try {
const next = deleteToken(document, setName, selectedPath);
setDocumentText(next);
setDraft(next);
setSelectedPath(null);
setPath("");
setValue("");
setEditorError("");
} catch (caught) {
setEditorError(caught instanceof Error ? caught.message : String(caught));
}
}
async function importDocument(file: File | undefined): Promise<void> {
if (!file) return;
try {
if (file.size > MAX_TEXT)
throw new RangeError("Token documents are limited to 1 MiB.");
const text = await file.text();
parseTokenDocument(text);
setDraft(text);
setDocumentText(text);
setSelectedPath(null);
setParseError("");
} catch (caught) {
setParseError(caught instanceof Error ? caught.message : String(caught));
} finally {
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<main className="workbench">
<section className="hero">
<div>
<p className="eyebrow">Design-system workbench</p>
<h1>One token source, honest platform output.</h1>
<p>
Edit nested sets and themes, resolve aliases and see exactly what
each target cannot preserve.
</p>
</div>
<span className="privacy-pill">Local only</span>
</section>
<section className="panel" aria-labelledby="document-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Source of truth</p>
<h2 id="document-title">Token document</h2>
</div>
<span className="count-pill">
{Object.values(document.sets).reduce(
(sum, tokens) => sum + tokens.length,
0,
)}{" "}
tokens
</span>
</div>
<textarea
rows={12}
value={draft}
spellCheck={false}
onChange={(event) => setDraft(event.target.value)}
aria-label="Token document JSON"
/>
<div className="button-row">
<button
type="button"
className="primary-button"
onClick={applyDocument}
>
Apply JSON
</button>
<button type="button" onClick={() => inputRef.current?.click()}>
Open JSON file
</button>
<button
type="button"
onClick={() =>
triggerBlobDownload(
new Blob([documentText], { type: "application/json" }),
"design-tokens.json",
)
}
>
Save source
</button>
<input
ref={inputRef}
className="sr-only"
type="file"
aria-label="Open design token JSON file"
accept="application/json,.json"
onChange={(event) => void importDocument(event.target.files?.[0])}
/>
</div>
{parseError && (
<p className="error" role="alert">
{parseError}
</p>
)}
</section>
<section className="editor-grid">
<section className="panel" aria-labelledby="tokens-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Nested editor</p>
<h2 id="tokens-title">Tokens</h2>
</div>
<button
type="button"
onClick={() => {
setSelectedPath(null);
setPath("");
setType("color");
setValue('"#000000"');
}}
>
New token
</button>
</div>
<div className="field-grid">
<label>
Set
<select
value={setName}
onChange={(event) => {
setSetName(event.target.value);
setSelectedPath(null);
}}
>
{Object.keys(document.sets).map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
<label>
Filter
<input
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="color.brand"
/>
</label>
</div>
<div className="token-list" role="list">
{visibleTokens.map((token) => (
<button
role="listitem"
type="button"
className={selectedPath === token.path ? "selected" : ""}
key={token.path}
onClick={() => chooseToken(token)}
>
<span>
<strong>{token.path}</strong>
<small>
{token.type}
{typeof token.value === "string" &&
/^\{.+\}$/u.test(token.value)
? ` · alias ${token.value}`
: ""}
</small>
</span>
<code>{preview(token.value)}</code>
</button>
))}
</div>
</section>
<section className="panel" aria-labelledby="edit-title">
<div className="panel-heading">
<div>
<p className="eyebrow">
{selectedPath ? "Edit token" : "Add token"}
</p>
<h2 id="edit-title">Value</h2>
</div>
</div>
<label>
Dot path
<input
value={path}
onChange={(event) => setPath(event.target.value)}
placeholder="color.brand"
/>
</label>
<label>
Type
<select
value={type}
onChange={(event) => setType(event.target.value as TokenType)}
>
{[
"color",
"dimension",
"duration",
"number",
"string",
"boolean",
"fontFamily",
"fontWeight",
"cubicBezier",
"strokeStyle",
"border",
"transition",
"gradient",
"typography",
"shadow",
"unknown",
].map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
<label>
JSON value or plain text
<textarea
rows={10}
value={value}
spellCheck={false}
onChange={(event) => setValue(event.target.value)}
/>
</label>
<div className="button-row">
<button
type="button"
className="primary-button"
onClick={saveToken}
>
Save token
</button>
<button
type="button"
disabled={!selectedPath}
onClick={removeToken}
>
Delete token
</button>
</div>
{editorError && (
<p className="error" role="alert">
{editorError}
</p>
)}
</section>
</section>
<section className="panel" aria-labelledby="theme-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Alias graph and validation</p>
<h2 id="theme-title">Theme preview</h2>
</div>
<label className="inline-label">
Theme
<select
value={activeTheme}
onChange={(event) => {
const nextTheme = event.target.value;
setTheme(nextTheme);
setMode(
Object.keys(document.modes[nextTheme] ?? {})[0] ?? "default",
);
}}
>
{Object.keys(document.themes).map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
<label className="inline-label">
Mode
<select
value={activeMode}
onChange={(event) => setMode(event.target.value)}
>
{Object.keys(document.modes[activeTheme] ?? { default: [] }).map(
(name) => (
<option key={name}>{name}</option>
),
)}
</select>
</label>
</div>
<p className="muted">
Sets:{" "}
{(
document.modes[activeTheme]?.[activeMode] ??
document.themes[activeTheme] ??
[]
).join(" → ") || "none"}
. Later sets override matching paths.
</p>
{resolution.diagnostics.length > 0 ? (
<ul className="diagnostics">
{resolution.diagnostics.map((item, index) => (
<li className={item.level} key={`${item.path}-${index}`}>
<strong>{item.level}</strong>
<code>{item.path}</code>
<span>{item.message}</span>
</li>
))}
</ul>
) : (
<p className="success">No alias or value errors found.</p>
)}
<div className="resolved-table" tabIndex={0}>
<table>
<thead>
<tr>
<th>Path</th>
<th>Type</th>
<th>Resolved value</th>
<th>Source set</th>
</tr>
</thead>
<tbody>
{resolution.tokens.map((token) => (
<tr key={token.path}>
<td>
<code>{token.path}</code>
</td>
<td>{token.type}</td>
<td>
<code>{preview(token.resolvedValue)}</code>
</td>
<td>{token.set}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="export-grid">
<section className="panel" aria-labelledby="export-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Resolved theme</p>
<h2 id="export-title">Export</h2>
</div>
<label className="inline-label">
Target
<select
value={format}
onChange={(event) =>
setFormat(event.target.value as ExportFormat)
}
>
<option value="dtcg">DTCG JSON</option>
<option value="css">CSS variables</option>
<option value="sass">Sass variables</option>
<option value="android">Android XML</option>
<option value="swift">Swift / UIKit</option>
</select>
</label>
</div>
<textarea
rows={18}
readOnly
value={exported.content}
aria-label="Export output"
/>
<button
type="button"
onClick={() =>
triggerBlobDownload(
new Blob([exported.content], { type: exported.mediaType }),
exported.extension,
)
}
>
Download {exported.extension}
</button>
</section>
<section className="panel" aria-labelledby="loss-title">
<div className="panel-heading">
<div>
<p className="eyebrow">No silent coercion</p>
<h2 id="loss-title">Loss report</h2>
</div>
<span className="count-pill">{exported.losses.length}</span>
</div>
{exported.losses.length === 0 ? (
<p className="success">
This target preserves every emitted token in the supported model.
</p>
) : (
<ul className="loss-list">
{exported.losses.map((loss) => (
<li key={loss}>{loss}</li>
))}
</ul>
)}
<p className="muted">
A zero-loss report covers this tool's supported token model. It
cannot guarantee that downstream frameworks interpret a value
identically.
</p>
</section>
</section>
</main>
);
}
function preview(value: unknown): string {
const output = typeof value === "string" ? value : JSON.stringify(value);
return output.length > 100 ? `${output.slice(0, 97)}…` : output;
}