556 lines
17 KiB
TypeScript
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;
|
|
}
|