Release Token Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
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 = "w3c" | "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 [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 resolution = useMemo(
|
||||
() =>
|
||||
resolveTheme(
|
||||
document,
|
||||
document.themes[theme]
|
||||
? theme
|
||||
: (Object.keys(document.themes)[0] ?? "default"),
|
||||
),
|
||||
[document, theme],
|
||||
);
|
||||
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);
|
||||
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"
|
||||
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",
|
||||
"number",
|
||||
"string",
|
||||
"boolean",
|
||||
"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={
|
||||
document.themes[theme] ? theme : Object.keys(document.themes)[0]
|
||||
}
|
||||
onChange={(event) => setTheme(event.target.value)}
|
||||
>
|
||||
{Object.keys(document.themes).map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Sets: {(document.themes[theme] ?? []).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="w3c">W3C-style 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;
|
||||
}
|
||||
Reference in New Issue
Block a user