@theme-registry/refract 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6070,15 +6070,15 @@ const DEFAULT_FILE = "preview.html";
6070
6070
  // ---------------------------------------------------------------------------
6071
6071
  // Escaping — every value below originates in user-authored theme content
6072
6072
  // ---------------------------------------------------------------------------
6073
- const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6073
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6074
6074
  /**
6075
6075
  * A token value going into a `style="…"` attribute. Declaration/rule terminators are dropped so a
6076
6076
  * value can never break out of its declaration, then the result is HTML-attribute escaped.
6077
6077
  */
6078
- const cssValue = (v) => escapeHtml(String(v).replace(/[;{}<>]/g, "").trim());
6078
+ const cssValue = (v) => esc(String(v).replace(/[;{}<>]/g, "").trim());
6079
6079
  /** Inline `<style>`/`<script>` bodies can't contain a literal `</` without ending the element early. */
6080
6080
  const escapeTextElement = (s) => s.replace(/<\/(?=[a-zA-Z])/g, "<\\/");
6081
- /** Collect every `$value` leaf, threading the nearest declared `$type` down the tree. */
6081
+ const bytes = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`);
6082
6082
  function collectLeaves(node, path, inherited, out) {
6083
6083
  if (!node || typeof node !== "object" || Array.isArray(node))
6084
6084
  return;
@@ -6094,45 +6094,9 @@ function collectLeaves(node, path, inherited, out) {
6094
6094
  collectLeaves(child, [...path, key], type, out);
6095
6095
  }
6096
6096
  }
6097
- /** Sample text for the plates that demonstrate a value by rendering type in it. */
6098
- const TYPE_SPECIMEN = "The quick brown fox";
6099
- /**
6100
- * The visual for one token. A `dimension` under `typography` is a font size (render the ramp);
6101
- * elsewhere it's a length (render a bar). Anything unrecognized falls back to its literal value,
6102
- * which is still the honest answer for a duration or a cubic-bezier.
6103
- */
6104
- function renderSwatch(leaf) {
6105
- const value = leaf.value;
6106
- const label = `<code class="rfp-val">${escapeHtml(String(value))}</code>`;
6107
- switch (leaf.type) {
6108
- case "color":
6109
- return `<span class="rfp-chip" style="background:${cssValue(value)}"></span>${label}`;
6110
- case "shadow":
6111
- return `<span class="rfp-box" style="box-shadow:${cssValue(value)}"></span>${label}`;
6112
- case "border":
6113
- return `<span class="rfp-box" style="border:${cssValue(value)}"></span>${label}`;
6114
- case "fontFamily":
6115
- return `<span class="rfp-specimen" style="font-family:${cssValue(value)}">${TYPE_SPECIMEN}</span>${label}`;
6116
- case "fontWeight":
6117
- return `<span class="rfp-specimen" style="font-weight:${cssValue(value)}">${TYPE_SPECIMEN}</span>${label}`;
6118
- case "dimension":
6119
- // `typography.fontSize.*` is the type ramp — show it as type. Every other dimension (spacing,
6120
- // radius, breakpoints, and typography's own `letterSpacing`) reads better as a length bar.
6121
- return leaf.path[0] === "typography" && leaf.path[1] === "fontSize"
6122
- ? `<span class="rfp-specimen" style="font-size:${cssValue(value)}">${TYPE_SPECIMEN}</span>${label}`
6123
- : `<span class="rfp-bar" style="width:${cssValue(value)}"></span>${label}`;
6124
- default:
6125
- return label;
6126
- }
6127
- }
6128
- /** One plate per top-level DTCG group (`color`, `spacing`, `shadow`, …), rows sorted by source order. */
6129
- function renderTokenPlates(tokens) {
6097
+ /** `<group>` its leaves, in source order. The group is the token's first path segment. */
6098
+ function groupLeaves(leaves) {
6130
6099
  var _a;
6131
- const leaves = [];
6132
- collectLeaves(tokens, [], undefined, leaves);
6133
- if (leaves.length === 0) {
6134
- return `<p class="rfp-note">This theme declares no tokens.</p>`;
6135
- }
6136
6100
  const groups = new Map();
6137
6101
  for (const leaf of leaves) {
6138
6102
  const key = (_a = leaf.path[0]) !== null && _a !== void 0 ? _a : "tokens";
@@ -6142,238 +6106,819 @@ function renderTokenPlates(tokens) {
6142
6106
  else
6143
6107
  groups.set(key, [leaf]);
6144
6108
  }
6145
- const sections = [];
6146
- for (const [group, rows] of groups) {
6147
- const items = rows
6148
- .map(leaf => `<li class="rfp-row"><span class="rfp-name">${escapeHtml(leaf.path.slice(1).join(".") || group)}</span>` +
6149
- `<span class="rfp-swatch">${renderSwatch(leaf)}</span></li>`)
6150
- .join("\n");
6151
- sections.push(`<section class="rfp-plate">\n<h3 class="rfp-plate-title">${escapeHtml(group)} ` +
6152
- `<span class="rfp-count">${rows.length}</span></h3>\n<ul class="rfp-rows">\n${items}\n</ul>\n</section>`);
6109
+ return groups;
6110
+ }
6111
+ const SECTIONS = [
6112
+ { id: "palette", title: "Colour", eyebrow: "Palette",
6113
+ note: "Every rung is a token you can reference. A ladder is an absolute lightness scale and the seed is <em>not</em> snapped onto it &mdash; the marked rung is where the family's <code>base</code> lands, not a rung it equals. Click any identifier to copy it." },
6114
+ { id: "type", title: "Type scale", eyebrow: "Typography" },
6115
+ { id: "space", title: "Spacing and size", eyebrow: "Space",
6116
+ note: "There is no separate <code>padding</code> token — spacing <em>is</em> the padding scale, so it is shown both as a measure and as an applied inset." },
6117
+ { id: "shape", title: "Borders, radius and elevation", eyebrow: "Shape &amp; depth" },
6118
+ { id: "motion", title: "Transitions", eyebrow: "Motion" },
6119
+ { id: "layout", title: "Breakpoints", eyebrow: "Layout",
6120
+ note: "These drive the Width control at the top — pick one to reflow the whole specimen at that viewport." },
6121
+ { id: "other", title: "Other tokens", eyebrow: "Additional" },
6122
+ ];
6123
+ /** Group → section. Anything unmapped falls into `other`, so a new subsystem is never dropped. */
6124
+ const SECTION_OF = {
6125
+ color: "palette",
6126
+ typography: "type",
6127
+ spacing: "space", gutters: "space", sizes: "space", aspectRatio: "space",
6128
+ radius: "shape", borderWidth: "shape", borderStyle: "shape", outlineOffset: "shape",
6129
+ shadow: "shape", blur: "shape", opacity: "shape", zIndex: "shape",
6130
+ transition: "motion",
6131
+ breakpoint: "layout",
6132
+ };
6133
+ // ---------------------------------------------------------------------------
6134
+ // Specimen builders
6135
+ // ---------------------------------------------------------------------------
6136
+ const SPECIMEN_TEXT = "Precision is a design decision";
6137
+ const LEADING_TEXT = "Leading is the quiet half of a type scale. Two lines are the minimum needed to judge it, so this specimen wraps.";
6138
+ const idButton = (path, varName) => `<button class="rfp-id" type="button">${esc(path)}</button>` +
6139
+ (varName ? `<span class="rfp-var">${esc(varName)}</span>` : "");
6140
+ const leafPath = (leaf) => leaf.path.join(".");
6141
+ const leafLabel = (leaf) => leaf.path.slice(1).join(".") || leaf.path[0];
6142
+ /** A three-column row: identifier · specimen · value. */
6143
+ const rowOf = (id, specimen, value, centred = false) => `<div class="rfp-row${centred ? " rfp-centred" : ""}"><div>${id}</div>${specimen}` +
6144
+ `<div class="rfp-rowval">${esc(value)}</div></div>`;
6145
+ /** Is this colour-family child a numeric ladder rung (`50`…`900`)? */
6146
+ const isRung = (name) => /^\d+$/.test(name);
6147
+ /** OKLCH lightness (0–100) of a hex colour, or `undefined` for anything else. */
6148
+ function lightnessOf(value) {
6149
+ if (!/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value.trim()))
6150
+ return undefined;
6151
+ try {
6152
+ return rgbToOklch(convertHexToRGB(value.trim())).L;
6153
+ }
6154
+ catch {
6155
+ return undefined;
6153
6156
  }
6154
- return sections.join("\n");
6155
6157
  }
6158
+ /**
6159
+ * Which rung the family's `base` LANDS on — never which rung it *is*.
6160
+ *
6161
+ * A numeric ladder is an absolute lightness scale (`L = (1000 − label) / 10`) and refract
6162
+ * deliberately **does not snap** the seed onto it: `refract create` reports where a seed falls
6163
+ * (`#4c6ef5` → L 59.1% ≈ 400) rather than moving it. So the base almost never equals a rung, and
6164
+ * testing for equality marks nothing. Report the nearest rung by lightness instead — true, and the
6165
+ * question a reader actually has.
6166
+ */
6167
+ function baseLandsOn(base, rungs) {
6168
+ const baseL = base === undefined ? undefined : lightnessOf(base);
6169
+ if (baseL === undefined || rungs.length === 0)
6170
+ return undefined;
6171
+ const nominalL = (step) => (1000 - Number(step)) / 10;
6172
+ return rungs.reduce((best, [step]) => (Math.abs(nominalL(step) - baseL) < Math.abs(nominalL(best) - baseL) ? step : best), rungs[0][0]);
6173
+ }
6174
+ /** Nearest px magnitude of a dimension value, for bar widths. `undefined` when it has no magnitude. */
6175
+ function pxOf(value) {
6176
+ const match = /^(-?[\d.]+)\s*(px|rem|em)?$/.exec(String(value).trim());
6177
+ if (!match)
6178
+ return undefined;
6179
+ const n = Number(match[1]);
6180
+ if (!Number.isFinite(n))
6181
+ return undefined;
6182
+ return match[2] === "rem" || match[2] === "em" ? n * 16 : n;
6183
+ }
6184
+ // ── Colour ─────────────────────────────────────────────────────────────────
6185
+ function renderPalette(leaves, tokenName) {
6186
+ var _a, _b, _c;
6187
+ // family → member → hex. `color.<family>.<member>`; a family may also be a bare leaf.
6188
+ const families = new Map();
6189
+ for (const leaf of leaves) {
6190
+ const family = (_a = leaf.path[1]) !== null && _a !== void 0 ? _a : "color";
6191
+ const member = leaf.path.slice(2).join(".") || "base";
6192
+ const bucket = families.get(family);
6193
+ if (bucket)
6194
+ bucket.push([member, String(leaf.value)]);
6195
+ else
6196
+ families.set(family, [[member, String(leaf.value)]]);
6197
+ }
6198
+ const ladders = [];
6199
+ const pairs = [];
6200
+ for (const [family, members] of families) {
6201
+ const rungs = members.filter(([name]) => isRung(name));
6202
+ const base = (_b = members.find(([name]) => name === "base")) === null || _b === void 0 ? void 0 : _b[1];
6203
+ if (rungs.length >= 3) {
6204
+ // A ladder: contiguous rungs read as a ramp in a way a row-per-token list never does.
6205
+ rungs.sort((a, b) => Number(a[0]) - Number(b[0]));
6206
+ const lands = baseLandsOn(base, rungs);
6207
+ const strip = rungs
6208
+ .map(([step, hex]) => {
6209
+ const isLanding = lands !== undefined && step === lands;
6210
+ return (`<div class="rfp-rung"${isLanding ? ' data-lands="true"' : ""}` +
6211
+ ` title="colors.${esc(family)}.${esc(step)} · ${esc(hex)}${isLanding ? " · base lands here" : ""}">` +
6212
+ `<div class="rfp-rung-chip" data-hex="${esc(hex)}" style="background:${cssValue(hex)}"></div>` +
6213
+ `<div class="rfp-rung-foot">${esc(step)}</div></div>`);
6214
+ })
6215
+ .join("");
6216
+ ladders.push(`<div class="rfp-ladder"><div><div class="rfp-ladder-name">${esc(family)}</div>` +
6217
+ (base
6218
+ ? `<div class="rfp-ladder-base">base · ${esc(base)}` +
6219
+ (lands ? `<br>lands &asymp; ${esc(lands)}` : "") +
6220
+ `</div>`
6221
+ : "") +
6222
+ idButton(`colors.${family}`, tokenName === null || tokenName === void 0 ? void 0 : tokenName(`colors.${family}`)) +
6223
+ `</div><div class="rfp-rungs">${strip}</div></div>`);
6224
+ continue;
6225
+ }
6226
+ // Not a ladder — render each member as a swatch card.
6227
+ //
6228
+ // The contrast readout goes ONLY on the member that genuinely declares the pairing: the family
6229
+ // base against its own `text`. Derived tints (`brand.dark`, `surface.lighter`, …) were never
6230
+ // meant to carry that text, so scoring them produces "fail" badges that read as a defect in the
6231
+ // user's theme when nothing is wrong. On a page whose whole job is to tell the truth about a
6232
+ // theme, inventing a pairing to score is the worst kind of noise.
6233
+ const text = (_c = members.find(([name]) => name === "text")) === null || _c === void 0 ? void 0 : _c[1];
6234
+ for (const [member, hex] of members) {
6235
+ if (member === "text")
6236
+ continue;
6237
+ const isBase = member === "base";
6238
+ const paired = isBase ? text : undefined;
6239
+ const path = isBase ? `colors.${family}` : `colors.${family}.${member}`;
6240
+ pairs.push(`<div class="rfp-pair"><div class="rfp-pair-swatch" data-bg="${esc(hex)}"${paired ? ` data-fg="${esc(paired)}"` : ""}` +
6241
+ ` style="background:${cssValue(hex)}${paired ? `;color:${cssValue(paired)}` : ""}">` +
6242
+ (paired ? `<span class="rfp-pair-sample">Aa</span><span class="rfp-pair-ratio"></span>` : "") +
6243
+ `</div><div class="rfp-pair-foot">${idButton(path, tokenName === null || tokenName === void 0 ? void 0 : tokenName(path))}` +
6244
+ `<span class="rfp-hex">${esc(hex)}${paired ? ` on ${esc(paired)}` : ""}</span></div></div>`);
6245
+ }
6246
+ }
6247
+ let html = "";
6248
+ if (ladders.length) {
6249
+ html += plate("Families", `${ladders.length} · ${leaves.length} tokens`, ladders.join(""));
6250
+ }
6251
+ if (pairs.length) {
6252
+ html += plate("Swatches", "contrast scored only where a <code>text</code> pairing is declared — derived tints carry none", `<div class="rfp-pairs">${pairs.join("")}</div>`);
6253
+ }
6254
+ return html;
6255
+ }
6256
+ // ── Typography ─────────────────────────────────────────────────────────────
6257
+ function renderTypography(leaves, tokenName) {
6258
+ var _a;
6259
+ const byProp = new Map();
6260
+ for (const leaf of leaves) {
6261
+ const prop = (_a = leaf.path[1]) !== null && _a !== void 0 ? _a : "typography";
6262
+ const bucket = byProp.get(prop);
6263
+ if (bucket)
6264
+ bucket.push(leaf);
6265
+ else
6266
+ byProp.set(prop, [leaf]);
6267
+ }
6268
+ const out = [];
6269
+ for (const [prop, items] of byProp) {
6270
+ const rows = items
6271
+ .map(leaf => {
6272
+ const path = leafPath(leaf);
6273
+ const id = idButton(path, tokenName === null || tokenName === void 0 ? void 0 : tokenName(path));
6274
+ const value = String(leaf.value);
6275
+ if (prop === "fontSize") {
6276
+ return rowOf(id, `<div class="rfp-specimen" style="font-size:${cssValue(value)}">${SPECIMEN_TEXT}</div>`, value);
6277
+ }
6278
+ if (prop === "fontFamily") {
6279
+ return rowOf(id, `<div class="rfp-specimen" style="font-family:${cssValue(value)}">${SPECIMEN_TEXT}</div>`, value);
6280
+ }
6281
+ if (prop === "fontWeight") {
6282
+ return rowOf(id, `<div class="rfp-specimen" style="font-weight:${cssValue(value)}">${SPECIMEN_TEXT}</div>`, value);
6283
+ }
6284
+ if (prop === "lineHeight") {
6285
+ // One line tells you nothing about leading — the specimen has to wrap.
6286
+ return rowOf(id, `<div class="rfp-leading" style="line-height:${cssValue(value)}">${LEADING_TEXT}</div>`, value);
6287
+ }
6288
+ if (prop === "letterSpacing") {
6289
+ return rowOf(id, `<div class="rfp-specimen" style="font-size:22px;letter-spacing:${cssValue(value)}">${SPECIMEN_TEXT}</div>`, value);
6290
+ }
6291
+ return rowOf(id, `<div class="rfp-specimen">${esc(value)}</div>`, value);
6292
+ })
6293
+ .join("");
6294
+ out.push(plate(`typography.${prop}`, `${items.length} token(s)`, `<div class="rfp-rows">${rows}</div>`));
6295
+ }
6296
+ return out.join("");
6297
+ }
6298
+ // ── Space ──────────────────────────────────────────────────────────────────
6299
+ function renderSpace(groups, tokenName) {
6300
+ const out = [];
6301
+ for (const group of ["spacing", "gutters", "sizes", "aspectRatio"]) {
6302
+ const leaves = groups.get(group);
6303
+ if (!(leaves === null || leaves === void 0 ? void 0 : leaves.length))
6304
+ continue;
6305
+ if (group === "aspectRatio") {
6306
+ const tiles = leaves
6307
+ .map(leaf => {
6308
+ const path = leafPath(leaf);
6309
+ const ratio = String(leaf.value);
6310
+ return tile(`<div class="rfp-obj" style="width:84px;aspect-ratio:${cssValue(ratio)};height:auto;border-radius:4px"></div>`, idButton(path, tokenName === null || tokenName === void 0 ? void 0 : tokenName(path)), ratio);
6311
+ })
6312
+ .join("");
6313
+ out.push(plate(`layout.${group}`, `${leaves.length} token(s)`, `<div class="rfp-tiles">${tiles}</div>`));
6314
+ continue;
6315
+ }
6316
+ const max = Math.max(...leaves.map(l => { var _a; return (_a = pxOf(l.value)) !== null && _a !== void 0 ? _a : 0; }), 1);
6317
+ const rows = leaves
6318
+ .map(leaf => {
6319
+ const path = leafPath(leaf);
6320
+ const px = pxOf(leaf.value);
6321
+ const width = px === undefined ? "100%" : `${Math.max(1, (px / max) * 100)}%`;
6322
+ return rowOf(idButton(path, tokenName === null || tokenName === void 0 ? void 0 : tokenName(path)), `<div class="rfp-bar${group === "spacing" ? "" : " rfp-ghost"}" style="width:${width}"></div>`, String(leaf.value), true);
6323
+ })
6324
+ .join("");
6325
+ out.push(plate(`layout.${group}`, `${leaves.length} steps · measure`, `<div class="rfp-rows">${rows}</div>`));
6326
+ // Spacing gets two applied views on top of the measure: there is no `padding` token, so this is
6327
+ // the only place a reader can see what a step feels like as an inset or a gap.
6328
+ if (group === "spacing") {
6329
+ const applied = leaves.filter(l => { var _a; return ((_a = pxOf(l.value)) !== null && _a !== void 0 ? _a : 0) > 0; }).slice(0, 6);
6330
+ if (applied.length) {
6331
+ const insets = applied
6332
+ .map(leaf => {
6333
+ const path = leafPath(leaf);
6334
+ return (`<div class="rfp-tile"><div class="rfp-inset" style="padding:${cssValue(leaf.value)}">` +
6335
+ `<div class="rfp-inset-core">${esc(String(leaf.value))}</div></div>` +
6336
+ `<div>${idButton(path, tokenName === null || tokenName === void 0 ? void 0 : tokenName(path))}</div></div>`);
6337
+ })
6338
+ .join("");
6339
+ out.push(plate(`layout.${group}`, "applied as padding — hatching is the inset, solid is the content box", `<div class="rfp-tiles">${insets}</div>`));
6340
+ const gaps = applied
6341
+ .slice(0, 4)
6342
+ .map(leaf => rowOf(idButton(leafPath(leaf), tokenName === null || tokenName === void 0 ? void 0 : tokenName(leafPath(leaf))), `<div class="rfp-gap" style="gap:${cssValue(leaf.value)}"><i></i><i></i><i></i><i></i></div>`, String(leaf.value), true))
6343
+ .join("");
6344
+ out.push(plate(`layout.${group}`, "applied as gap", `<div class="rfp-rows">${gaps}</div>`));
6345
+ }
6346
+ }
6347
+ }
6348
+ return out.join("");
6349
+ }
6350
+ // ── Shape & depth ──────────────────────────────────────────────────────────
6351
+ function renderShape(groups, tokenName) {
6352
+ const out = [];
6353
+ const tilesFor = (group, build, sub) => {
6354
+ const leaves = groups.get(group);
6355
+ if (!(leaves === null || leaves === void 0 ? void 0 : leaves.length))
6356
+ return;
6357
+ const tiles = leaves
6358
+ .map(leaf => tile(build(leaf), idButton(leafPath(leaf), tokenName === null || tokenName === void 0 ? void 0 : tokenName(leafPath(leaf))), String(leaf.value)))
6359
+ .join("");
6360
+ out.push(plate(group, sub !== null && sub !== void 0 ? sub : `${leaves.length} token(s)`, `<div class="rfp-tiles">${tiles}</div>`));
6361
+ };
6362
+ tilesFor("radius", l => `<div class="rfp-obj" style="border-radius:${cssValue(l.value)}"></div>`);
6363
+ // The stroke is the subject here, so the box must NOT be filled.
6364
+ tilesFor("borderWidth", l => `<div class="rfp-obj rfp-outlined" style="border:${cssValue(l.value)} solid currentColor;border-radius:8px"></div>`);
6365
+ tilesFor("borderStyle", l => `<div class="rfp-obj rfp-outlined" style="border:3px ${cssValue(l.value)} currentColor;border-radius:8px"></div>`);
6366
+ tilesFor("outlineOffset", l => `<div class="rfp-obj rfp-outlined" style="border-radius:8px;outline:2px solid currentColor;outline-offset:${cssValue(l.value)}"></div>`);
6367
+ tilesFor("shadow", l => `<div class="rfp-obj rfp-raised" style="border-radius:8px;box-shadow:${cssValue(l.value)}"></div>`);
6368
+ // Blur and opacity need a saturated fill: a mid-grey at .38 is indistinguishable from the stage.
6369
+ tilesFor("blur", l => `<div class="rfp-obj rfp-accent" style="border-radius:8px;filter:blur(${cssValue(l.value)})"></div>`);
6370
+ tilesFor("opacity", l => `<div class="rfp-obj rfp-accent" style="border-radius:8px;opacity:${cssValue(l.value)}"></div>`);
6371
+ tilesFor("zIndex", l => `<span class="rfp-numeral">${esc(String(l.value))}</span>`);
6372
+ return out.join("");
6373
+ }
6374
+ // ── Motion ─────────────────────────────────────────────────────────────────
6375
+ function renderMotion(leaves, tokenName) {
6376
+ const rows = leaves
6377
+ .map(leaf => {
6378
+ const path = leafPath(leaf);
6379
+ const value = String(leaf.value);
6380
+ return rowOf(idButton(path, tokenName === null || tokenName === void 0 ? void 0 : tokenName(path)), `<div class="rfp-track"><i class="rfp-dot" style="transition:transform ${cssValue(value)}"></i></div>`, value, true);
6381
+ })
6382
+ .join("");
6383
+ return plate("effects.transitions", `${leaves.length} token(s)`, `<div class="rfp-rows">${rows}</div>`, `<button class="rfp-play" type="button" id="rfp-play">Play</button>`);
6384
+ }
6385
+ // ── Generic fallback (unknown groups keep their tokens visible) ─────────────
6386
+ function renderGeneric(group, leaves, tokenName) {
6387
+ const rows = leaves
6388
+ .map(leaf => rowOf(idButton(leafPath(leaf), tokenName === null || tokenName === void 0 ? void 0 : tokenName(leafPath(leaf))), `<div class="rfp-specimen">${esc(leafLabel(leaf))}</div>`, String(leaf.value)))
6389
+ .join("");
6390
+ return plate(group, `${leaves.length} token(s)`, `<div class="rfp-rows">${rows}</div>`);
6391
+ }
6392
+ // ── Shared bits ────────────────────────────────────────────────────────────
6393
+ const plate = (name, sub, body, action = "") => `<section class="rfp-plate"><div class="rfp-plate-head">` +
6394
+ `<span class="rfp-plate-name">${esc(name)}</span><span class="rfp-plate-sub">${sub}</span>${action}` +
6395
+ `</div>${body}</section>`;
6396
+ const tile = (stage, id, value) => `<div class="rfp-tile"><div class="rfp-stage">${stage}</div>` +
6397
+ `<div>${id}<span class="rfp-hex">${esc(value)}</span></div></div>`;
6156
6398
  // ---------------------------------------------------------------------------
6157
- // Recipe plates
6399
+ // Model-derived plates: modes, base elements
6158
6400
  // ---------------------------------------------------------------------------
6401
+ /** Distinct appearance modes across every property's `modes` list, in first-appearance order. */
6402
+ function collectModes(model) {
6403
+ var _a, _b;
6404
+ const modes = [];
6405
+ for (const subsystem of Object.values(model.subsystems)) {
6406
+ for (const property of Object.values((_a = subsystem.properties) !== null && _a !== void 0 ? _a : {})) {
6407
+ for (const override of (_b = property.modes) !== null && _b !== void 0 ? _b : []) {
6408
+ if (override.mode && !modes.includes(override.mode))
6409
+ modes.push(override.mode);
6410
+ }
6411
+ }
6412
+ }
6413
+ return modes;
6414
+ }
6415
+ /** A literal `Ref` reads straight off `value`; a derived/aliased one has to be skipped honestly. */
6416
+ const literalOf = (ref) => {
6417
+ if (!ref || ref.value === undefined || ref.value === null)
6418
+ return undefined;
6419
+ if (typeof ref.value === "object")
6420
+ return undefined;
6421
+ return String(ref.value);
6422
+ };
6159
6423
  /**
6160
- * Which element to render a recipe as, when the adapter didn't say. Presentation guesswork belongs
6161
- * here rather than in an adapter: it's about the specimen page, not about the output format.
6424
+ * Which tokens actually carry a mode override. The toggle shows the *result*; this shows the
6425
+ * *cause* and the tokens that DIDN'T override are usually the surprise worth catching.
6426
+ * Derived (non-literal) overrides are counted but not tabled, since there's no honest value to show.
6162
6427
  */
6428
+ function collectModeChanges(model) {
6429
+ var _a;
6430
+ const changes = [];
6431
+ let skipped = 0;
6432
+ const walk = (subsystem, name, property) => {
6433
+ var _a, _b, _c, _d, _e, _f, _g;
6434
+ for (const override of (_a = property.modes) !== null && _a !== void 0 ? _a : []) {
6435
+ if (!override.mode)
6436
+ continue;
6437
+ for (const [field, ref] of Object.entries((_b = override.overrides) !== null && _b !== void 0 ? _b : {})) {
6438
+ const to = literalOf(ref);
6439
+ const suffix = [override.target, field === "base" ? undefined : field].filter(Boolean).join(".");
6440
+ const path = `${subsystem}.${name}${suffix ? `.${suffix}` : ""}`;
6441
+ if (to === undefined) {
6442
+ skipped += 1;
6443
+ continue;
6444
+ }
6445
+ const from = field === "base" && !override.target
6446
+ ? literalOf(property.base)
6447
+ : (_d = literalOf((_c = property.extras) === null || _c === void 0 ? void 0 : _c[field])) !== null && _d !== void 0 ? _d : literalOf((_g = (_e = property.variants) === null || _e === void 0 ? void 0 : _e[(_f = override.target) !== null && _f !== void 0 ? _f : ""]) === null || _g === void 0 ? void 0 : _g.base);
6448
+ changes.push({ path, mode: override.mode, from, to });
6449
+ }
6450
+ }
6451
+ };
6452
+ for (const [subsystem, sub] of Object.entries(model.subsystems)) {
6453
+ for (const [name, property] of Object.entries((_a = sub.properties) !== null && _a !== void 0 ? _a : {}))
6454
+ walk(subsystem, name, property);
6455
+ }
6456
+ return { changes, skipped };
6457
+ }
6458
+ function renderModes(model, totalTokens, tokenName) {
6459
+ const modes = collectModes(model);
6460
+ if (!modes.length)
6461
+ return "";
6462
+ const { changes, skipped } = collectModeChanges(model);
6463
+ if (!changes.length && !skipped)
6464
+ return "";
6465
+ const rows = changes
6466
+ .map(c => `<tr><td>${idButton(c.path, tokenName === null || tokenName === void 0 ? void 0 : tokenName(c.path))}</td>` +
6467
+ `<td>${swatchCell(c.from)}</td><td class="rfp-arrow">&rarr;</td><td>${swatchCell(c.to)}</td></tr>`)
6468
+ .join("");
6469
+ const note = skipped
6470
+ ? `<p class="rfp-note-sm">${skipped} further override(s) resolve through a derivation, so they carry no single literal to show here.</p>`
6471
+ : "";
6472
+ return (`<section class="rfp-section" id="rfp-modes"><div class="rfp-section-head"><div>` +
6473
+ `<div class="rfp-eyebrow">Appearance</div><h2>What changes in ${esc(modes.join(" / "))}</h2></div>` +
6474
+ `<span class="rfp-tag">${modes.length} mode(s) declared</span></div>` +
6475
+ `<p class="rfp-note">Flipping the toggle shows you the result; this shows the cause. Only these tokens carry an override — everything else is inherited.</p>` +
6476
+ plate(`mode: ${modes.join(", ")}`, `${changes.length} of ${totalTokens} tokens overridden`, `<div class="rfp-scroll"><table class="rfp-diff"><thead><tr><th>Token</th><th>Base</th><th></th><th>Override</th></tr></thead>` +
6477
+ `<tbody>${rows}</tbody></table></div>${note}`) +
6478
+ `</section>`);
6479
+ }
6480
+ const swatchCell = (value) => {
6481
+ if (value === undefined)
6482
+ return `<span class="rfp-hex">—</span>`;
6483
+ const isColor = /^(#|rgb|hsl|oklch|color\()/i.test(value.trim());
6484
+ return (`<span class="rfp-swatch-cell">` +
6485
+ (isColor ? `<i class="rfp-chip" style="background:${cssValue(value)}"></i>` : "") +
6486
+ `<span class="rfp-hex">${esc(value)}</span></span>`);
6487
+ };
6488
+ /**
6489
+ * The `globals` subsystem themes bare elements — no class involved — so a prose specimen is the only
6490
+ * way to show them. The selectors come from the model; the emitted stylesheet does the styling.
6491
+ */
6492
+ function renderGlobals(model, live) {
6493
+ var _a, _b;
6494
+ const groups = (_a = model.subsystems.globals) === null || _a === void 0 ? void 0 : _a.ruleSets;
6495
+ if (!groups || !live)
6496
+ return "";
6497
+ const selectors = Object.keys((_b = groups.elements) !== null && _b !== void 0 ? _b : {});
6498
+ if (!selectors.length)
6499
+ return "";
6500
+ const has = (sel) => selectors.some(s => s === sel || s.startsWith(`${sel}.`) || s.startsWith(`${sel}:`));
6501
+ const parts = [];
6502
+ if (has("h1"))
6503
+ parts.push(`<h1>Shipping a theme</h1>`);
6504
+ parts.push(`<p>A theme compiles once and lowers to every format you target — the same source produces CSS custom ` +
6505
+ `properties, Sass partials, a JSON document, or styled-components modules` +
6506
+ (has("a") ? ` <a href="#rfp-recipes">without re-authoring a single value</a>` : "") +
6507
+ `.</p>`);
6508
+ if (has("h2"))
6509
+ parts.push(`<h2>Why bare elements matter</h2>`);
6510
+ parts.push(`<p>Content you don't control — a CMS body field, rendered markdown, a third-party embed — arrives ` +
6511
+ `without your class names. Theming the elements themselves is what keeps it consistent.</p>`);
6512
+ if (has("ul")) {
6513
+ parts.push(`<ul><li>Headings inherit the type scale and its derived leading</li>` +
6514
+ `<li>Links pick up the brand colour and its underline treatment</li>` +
6515
+ `<li>Variants emit only the delta from their base rule</li></ul>`);
6516
+ }
6517
+ if (has("blockquote")) {
6518
+ parts.push(`<blockquote>Structural devices should encode something true about the content, not decorate it.</blockquote>`);
6519
+ }
6520
+ if (has("hr"))
6521
+ parts.push(`<hr>`);
6522
+ if (has("h3"))
6523
+ parts.push(`<h3>Variants are structural deltas</h3>`);
6524
+ return (`<section class="rfp-section" id="rfp-globals"><div class="rfp-section-head"><div>` +
6525
+ `<div class="rfp-eyebrow">Base elements</div><h2>Unclassed markup</h2></div>` +
6526
+ `<span class="rfp-tag">globals.elements</span></div>` +
6527
+ `<p class="rfp-note">These style bare elements with no class involved, so plain HTML from a CMS or a markdown ` +
6528
+ `pipeline already looks right. Nothing else in this document renders without a class.</p>` +
6529
+ plate("globals.elements", `${selectors.length} selector(s) · ${esc(selectors.join(", "))}`, `<div class="rfp-prose">${parts.join("")}</div>`) +
6530
+ `</section>`);
6531
+ }
6532
+ // ---------------------------------------------------------------------------
6533
+ // Recipes
6534
+ // ---------------------------------------------------------------------------
6163
6535
  function inferTag(recipe) {
6164
6536
  const group = recipe.group.toLowerCase();
6165
6537
  if (group.includes("button") || group.includes("btn"))
6166
6538
  return "button";
6539
+ if (group.includes("input") || group.includes("field"))
6540
+ return "input";
6167
6541
  if (group.includes("link") || group.includes("anchor"))
6168
6542
  return "a";
6169
- if (group.includes("badge") || group.includes("chip") || group.includes("tag") || group.includes("label")) {
6543
+ if (group.includes("badge") || group.includes("chip") || group.includes("tag") || group.includes("label"))
6170
6544
  return "span";
6171
- }
6172
6545
  return "div";
6173
6546
  }
6174
- /** `<tag attr="…">Label</tag>` for one recipe, or `undefined` when the adapter offers no markup. */
6175
- function renderRecipeSpecimen(recipe, descriptor) {
6176
- var _a, _b;
6547
+ /** One rendered specimen. `pin` adds the adapter's state-pinning class so a state can be shown at rest. */
6548
+ function specimen(recipe, descriptor, pin) {
6549
+ var _a, _b, _c;
6177
6550
  const markup = (_a = descriptor === null || descriptor === void 0 ? void 0 : descriptor.markup) === null || _a === void 0 ? void 0 : _a.call(descriptor, recipe);
6178
6551
  if (!markup)
6179
6552
  return undefined;
6180
6553
  const tag = (_b = markup.tag) !== null && _b !== void 0 ? _b : inferTag(recipe);
6181
- const attrs = Object.entries(markup.attrs)
6182
- .map(([key, value]) => ` ${escapeHtml(key)}="${escapeHtml(value)}"`)
6554
+ const attrs = { ...markup.attrs };
6555
+ if (pin)
6556
+ attrs.class = `${(_c = attrs.class) !== null && _c !== void 0 ? _c : ""} ${pin}`.trim();
6557
+ const rendered = Object.entries(attrs)
6558
+ .map(([k, v]) => ` ${esc(k)}="${esc(v)}"`)
6183
6559
  .join("");
6184
- return `<${tag}${attrs}>${escapeHtml(recipe.variant)}</${tag}>`;
6560
+ if (tag === "input")
6561
+ return `<input${rendered} value="${esc(recipe.variant)}" readonly aria-label="${esc(recipe.variant)}">`;
6562
+ return `<${tag}${rendered}>${esc(recipe.variant)}</${tag}>`;
6185
6563
  }
6186
- function renderRecipePlates(usage, descriptor, live) {
6564
+ function renderRecipes(usage, descriptor, live) {
6187
6565
  var _a, _b;
6188
6566
  if (usage.recipes.length === 0) {
6189
6567
  // The scaffolder writes tokens and no recipes, so this is the FIRST thing a new user sees here.
6190
- // Say what's missing and where to go, rather than showing an empty box.
6191
- return (`<p class="rfp-note"><strong>No recipes yet — this theme is tokens only.</strong> ` +
6192
- `Tokens are values; recipes are the rule-sets that turn them into styled components ` +
6193
- `(a button, a card). Add a <code>recipes</code> block to a subsystem in your raw theme and ` +
6194
- `rebuild — they'll render here.</p>`);
6568
+ return (`<div class="rfp-notice"><span class="rfp-notice-mark">Empty</span><div>` +
6569
+ `<p><strong>No recipes yet — this theme is tokens only.</strong> Tokens are values; recipes are the ` +
6570
+ `rule-sets that turn them into styled components. Add a <code>recipes</code> block to a subsystem in ` +
6571
+ `your raw theme and rebuild — they'll render here.</p></div></div>`);
6195
6572
  }
6573
+ // Group for layout: the adapter decides (subsystem, component file, …); default is by group.
6196
6574
  const groups = new Map();
6197
6575
  for (const recipe of usage.recipes) {
6198
- const key = (_b = (_a = descriptor === null || descriptor === void 0 ? void 0 : descriptor.groupBy) === null || _a === void 0 ? void 0 : _a.call(descriptor, recipe)) !== null && _b !== void 0 ? _b : "";
6576
+ const key = (_b = (_a = descriptor === null || descriptor === void 0 ? void 0 : descriptor.groupBy) === null || _a === void 0 ? void 0 : _a.call(descriptor, recipe)) !== null && _b !== void 0 ? _b : `${recipe.subsystem}.${recipe.group}`;
6199
6577
  const bucket = groups.get(key);
6200
6578
  if (bucket)
6201
6579
  bucket.push(recipe);
6202
6580
  else
6203
6581
  groups.set(key, [recipe]);
6204
6582
  }
6205
- const sections = [];
6583
+ const out = [];
6206
6584
  for (const [group, recipes] of groups) {
6207
- const items = recipes
6585
+ // A state matrix beats one specimen per card: states are the whole point of a component sheet,
6586
+ // and side by side is the only way to tell whether hover and active are distinguishable.
6587
+ const states = live
6588
+ ? [...new Set(recipes.flatMap(r => { var _a, _b; return (_b = (_a = descriptor === null || descriptor === void 0 ? void 0 : descriptor.states) === null || _a === void 0 ? void 0 : _a.call(descriptor, r)) !== null && _b !== void 0 ? _b : []; }))].filter(s => { var _a; return (_a = descriptor === null || descriptor === void 0 ? void 0 : descriptor.statePinClass) === null || _a === void 0 ? void 0 : _a.call(descriptor, s); })
6589
+ : [];
6590
+ if (states.length && (descriptor === null || descriptor === void 0 ? void 0 : descriptor.markup)) {
6591
+ const head = ["base", ...states].map(s => `<th>${esc(s)}</th>`).join("");
6592
+ const body = recipes
6593
+ .map(recipe => {
6594
+ var _a, _b;
6595
+ const own = (_b = (_a = descriptor.states) === null || _a === void 0 ? void 0 : _a.call(descriptor, recipe)) !== null && _b !== void 0 ? _b : [];
6596
+ const cells = ["base", ...states]
6597
+ .map(state => {
6598
+ var _a, _b;
6599
+ if (state !== "base" && !own.includes(state))
6600
+ return `<td class="rfp-none">—</td>`;
6601
+ const pin = state === "base" ? undefined : (_a = descriptor.statePinClass) === null || _a === void 0 ? void 0 : _a.call(descriptor, state);
6602
+ return `<td>${(_b = specimen(recipe, descriptor, pin)) !== null && _b !== void 0 ? _b : ""}</td>`;
6603
+ })
6604
+ .join("");
6605
+ return (`<tr><td><span class="rfp-addr">${esc(`${recipe.subsystem}.${recipe.group}.${recipe.variant}`)}</span>` +
6606
+ `<button class="rfp-id" type="button">${esc(recipe.name)}</button></td>${cells}</tr>`);
6607
+ })
6608
+ .join("");
6609
+ out.push(plate(group, `${recipes.length} variant(s) × ${states.length} state(s)`, `<div class="rfp-scroll"><table class="rfp-matrix"><thead><tr><th>Variant</th>${head}</tr></thead>` +
6610
+ `<tbody>${body}</tbody></table></div>`));
6611
+ out.push(renderComposition(recipes, descriptor));
6612
+ continue;
6613
+ }
6614
+ const cards = recipes
6208
6615
  .map(recipe => {
6209
- const specimen = live ? renderRecipeSpecimen(recipe, descriptor) : undefined;
6210
- const address = `${recipe.subsystem}.${recipe.group}.${recipe.variant}`;
6211
- return (`<li class="rfp-recipe">` +
6212
- (specimen ? `<div class="rfp-stage">${specimen}</div>` : "") +
6213
- `<div class="rfp-meta"><span class="rfp-name">${escapeHtml(address)}</span>` +
6214
- `<code class="rfp-val">${escapeHtml(recipe.name)}</code></div></li>`);
6616
+ const rendered = live ? specimen(recipe, descriptor) : undefined;
6617
+ return (`<div class="rfp-recipe">` +
6618
+ (rendered ? `<div class="rfp-recipe-stage">${rendered}</div>` : "") +
6619
+ `<div class="rfp-recipe-foot"><span class="rfp-addr">` +
6620
+ esc(`${recipe.subsystem}.${recipe.group}.${recipe.variant}`) +
6621
+ `</span><button class="rfp-id" type="button">${esc(recipe.name)}</button></div></div>`);
6215
6622
  })
6216
- .join("\n");
6217
- const title = group
6218
- ? `<h3 class="rfp-plate-title">${escapeHtml(group)} <span class="rfp-count">${recipes.length}</span></h3>\n`
6219
- : "";
6220
- sections.push(`<section class="rfp-plate">\n${title}<ul class="rfp-recipes">\n${items}\n</ul>\n</section>`);
6623
+ .join("");
6624
+ out.push(plate(group, `${recipes.length} variant(s)`, `<div class="rfp-recipes">${cards}</div>`));
6625
+ out.push(renderComposition(recipes, descriptor));
6221
6626
  }
6222
- return sections.join("\n");
6627
+ return out.join("");
6223
6628
  }
6224
- // ---------------------------------------------------------------------------
6225
- // Model-derived page controls
6226
- // ---------------------------------------------------------------------------
6227
- /** Distinct appearance modes across every property's `modes` list, in first-appearance order. */
6228
- function collectModes(model) {
6229
- var _a, _b;
6230
- const modes = [];
6231
- for (const subsystem of Object.values(model.subsystems)) {
6232
- for (const property of Object.values((_a = subsystem.properties) !== null && _a !== void 0 ? _a : {})) {
6233
- for (const override of (_b = property.modes) !== null && _b !== void 0 ? _b : []) {
6234
- if (override.mode && !modes.includes(override.mode))
6235
- modes.push(override.mode);
6236
- }
6237
- }
6629
+ /**
6630
+ * A composed recipe emits a class LIST, not one class — its referenced recipes plus its own delta.
6631
+ * Showing the string without saying why it's two classes is the kind of thing that reads as a bug.
6632
+ */
6633
+ function renderComposition(recipes, descriptor) {
6634
+ if (!(descriptor === null || descriptor === void 0 ? void 0 : descriptor.composition))
6635
+ return "";
6636
+ for (const recipe of recipes) {
6637
+ const parts = descriptor.composition(recipe);
6638
+ if (!parts || parts.length < 2)
6639
+ continue;
6640
+ const chips = parts
6641
+ .map((p, i) => (i ? `<span class="rfp-plus">+</span>` : "") +
6642
+ `<span class="rfp-cls"><b>${esc(p.className)}</b>` +
6643
+ `<span>${p.from ? ` · from ${esc(p.from)}` : " · own delta"}</span></span>`)
6644
+ .join("");
6645
+ return plate("Composition", `why <code>${esc(recipe.variant)}</code> carries ${parts.length} classes`, `<div class="rfp-compose">${chips}</div>` +
6646
+ `<p class="rfp-note-sm">Order matters — the delta lands last, so it wins on equal specificity.</p>`);
6238
6647
  }
6239
- return modes;
6648
+ return "";
6240
6649
  }
6241
6650
  // ---------------------------------------------------------------------------
6242
6651
  // Page chrome
6243
6652
  // ---------------------------------------------------------------------------
6244
6653
  /**
6245
- * The preview's own styling. Class-only selectors (`.rfp-*`), so the theme's `globals` element rules
6246
- * can never outrank it, and emitted LAST so an equal-specificity theme class still loses here. The
6247
- * document body is deliberately left to the theme — a themed page background is part of the specimen.
6654
+ * The preview's own styling. Two rules it exists to enforce:
6655
+ *
6656
+ * - **Class-only selectors**, all under `.rfp`, emitted AFTER the theme — so a themed `body`/`h1`
6657
+ * rule from the `globals` subsystem can never outrank the tool around it.
6658
+ * - **`--rfp-spec` for specimen geometry.** Filling a swatch with the page ground makes it vanish
6659
+ * once the ground and the stage converge, which is precisely what happens in dark mode; using
6660
+ * the ink instead makes light mode a wall of near-black blobs competing with the theme. A
6661
+ * dedicated mid-tone reads at the same weight on both grounds.
6248
6662
  */
6249
6663
  const CHROME_CSS = `
6250
- .rfp-root{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:#111;background:#fff;
6251
- max-width:1100px;margin:0 auto;padding:24px;box-sizing:border-box}
6252
- .rfp-head{display:flex;flex-wrap:wrap;gap:12px;align-items:baseline;justify-content:space-between;
6253
- border-bottom:1px solid #e5e5e5;padding-bottom:12px;margin-bottom:20px}
6254
- .rfp-title{font-size:20px;font-weight:600;margin:0}
6255
- .rfp-sub{color:#666;font-size:12px}
6256
- .rfp-controls{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}
6257
- .rfp-controls button{font:inherit;font-size:12px;padding:4px 10px;border:1px solid #ccc;border-radius:6px;
6258
- background:#fafafa;color:#111;cursor:pointer}
6259
- .rfp-controls button[aria-pressed="true"]{background:#111;border-color:#111;color:#fff}
6260
- .rfp-group-label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#888;align-self:center;
6261
- margin-right:4px}
6262
- .rfp-section-title{font-size:15px;font-weight:600;margin:28px 0 10px}
6263
- .rfp-plate{border:1px solid #e5e5e5;border-radius:8px;padding:14px 16px;margin-bottom:14px;background:#fff}
6264
- .rfp-plate-title{font-size:13px;font-weight:600;margin:0 0 10px;color:#333}
6265
- .rfp-count{color:#999;font-weight:400}
6266
- .rfp-rows,.rfp-recipes{list-style:none;margin:0;padding:0}
6267
- .rfp-row{display:grid;grid-template-columns:minmax(120px,220px) 1fr;gap:12px;align-items:center;
6268
- padding:5px 0;border-top:1px solid #f2f2f2}
6664
+ .rfp{--rfp-ground:#fff;--rfp-panel:#f7f8fa;--rfp-panel-2:#eef0f4;--rfp-ink:#14171c;--rfp-ink-2:#4d5563;
6665
+ --rfp-ink-3:#79818f;--rfp-rule:#e4e7ec;--rfp-rule-2:#d3d8e0;--rfp-focus:#3b4ea8;--rfp-live:#2f9e6d;
6666
+ --rfp-spec:#97a1b0;--rfp-spec-2:#c2c9d4;--rfp-hatch:#d7dce4;--rfp-stage:#f2f4f7;--rfp-raised:#fff;
6667
+ --rfp-accent:#6b7a99;
6668
+ --rfp-mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
6669
+ --rfp-ui:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
6670
+ font-family:var(--rfp-ui);font-size:14px;line-height:1.55;color:var(--rfp-ink);
6671
+ background:var(--rfp-ground);-webkit-font-smoothing:antialiased;
6672
+ display:grid;grid-template-columns:216px minmax(0,1fr);gap:56px;max-width:1260px;margin:0 auto;
6673
+ padding:0 32px 96px;align-items:start;box-sizing:border-box}
6674
+ @media (prefers-color-scheme:dark){.rfp{--rfp-ground:#0f1114;--rfp-panel:#1a1e24;--rfp-panel-2:#232830;
6675
+ --rfp-ink:#e9ebef;--rfp-ink-2:#a8b0bd;--rfp-ink-3:#7b8492;--rfp-rule:#2b313a;--rfp-rule-2:#3b434f;
6676
+ --rfp-focus:#8ea2ff;--rfp-live:#4ec48d;--rfp-spec:#8791a0;--rfp-spec-2:#4a5361;--rfp-hatch:#333b46;
6677
+ --rfp-stage:#14181d;--rfp-raised:#232830;--rfp-accent:#8e9dbb}}
6678
+ .rfp *{box-sizing:border-box}
6679
+ .rfp *:focus-visible{outline:2px solid var(--rfp-focus);outline-offset:2px;border-radius:3px}
6680
+ .rfp-rail{position:sticky;top:0;padding:32px 0;max-height:100vh;overflow-y:auto}
6681
+ .rfp-brand{font-family:var(--rfp-mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;
6682
+ color:var(--rfp-ink-3);padding-bottom:14px;margin-bottom:14px;border-bottom:1px solid var(--rfp-rule)}
6683
+ .rfp-nav{display:flex;flex-direction:column;gap:1px}
6684
+ .rfp-nav a{display:flex;justify-content:space-between;align-items:baseline;gap:10px;padding:5px 8px;
6685
+ border-radius:4px;color:var(--rfp-ink-2);text-decoration:none;font-size:13px}
6686
+ .rfp-nav a:hover{background:var(--rfp-panel);color:var(--rfp-ink)}
6687
+ .rfp-nav a[aria-current="true"]{background:var(--rfp-ink);color:var(--rfp-ground)}
6688
+ .rfp-nav .rfp-n{font-family:var(--rfp-mono);font-size:11px;font-variant-numeric:tabular-nums;color:var(--rfp-ink-3)}
6689
+ .rfp-nav a[aria-current="true"] .rfp-n{color:var(--rfp-ground);opacity:.65}
6690
+ .rfp-main{padding-top:32px;min-width:0}
6691
+ .rfp-masthead{padding-bottom:28px;border-bottom:1px solid var(--rfp-rule)}
6692
+ .rfp-eyebrow{font-family:var(--rfp-mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--rfp-ink-3)}
6693
+ .rfp-masthead h1{font-size:34px;line-height:1.1;letter-spacing:-.025em;font-weight:640;margin:10px 0 0;text-wrap:balance}
6694
+ .rfp-meta{display:flex;flex-wrap:wrap;gap:6px 10px;margin-top:16px;font-family:var(--rfp-mono);font-size:11.5px;color:var(--rfp-ink-2)}
6695
+ .rfp-meta span{display:inline-flex;align-items:center;gap:6px}
6696
+ .rfp-meta span+span::before{content:"";width:1px;height:11px;background:var(--rfp-rule-2)}
6697
+ .rfp-meta b{font-weight:600;color:var(--rfp-ink)}
6698
+ .rfp-dot{width:6px;height:6px;border-radius:50%;background:var(--rfp-live)}
6699
+ .rfp-controls{display:flex;flex-wrap:wrap;gap:24px;padding:18px 0;border-bottom:1px solid var(--rfp-rule);
6700
+ position:sticky;top:0;background:var(--rfp-ground);z-index:5}
6701
+ .rfp-ctl{display:flex;align-items:center;gap:8px}
6702
+ .rfp-ctl-label{font-family:var(--rfp-mono);font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--rfp-ink-3)}
6703
+ .rfp-seg{display:flex;border:1px solid var(--rfp-rule-2);border-radius:6px;overflow:hidden}
6704
+ .rfp-seg button{font:inherit;font-family:var(--rfp-mono);font-size:11.5px;padding:4px 10px;border:0;
6705
+ border-left:1px solid var(--rfp-rule-2);background:var(--rfp-ground);color:var(--rfp-ink-2);cursor:pointer}
6706
+ .rfp-seg button:first-child{border-left:0}
6707
+ .rfp-seg button:hover{background:var(--rfp-panel);color:var(--rfp-ink)}
6708
+ .rfp-seg button[aria-pressed="true"]{background:var(--rfp-ink);color:var(--rfp-ground)}
6709
+ .rfp-section{padding-top:52px;scroll-margin-top:72px}
6710
+ .rfp-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
6711
+ .rfp-section-head h2{font-size:21px;letter-spacing:-.018em;font-weight:620;margin:6px 0 0}
6712
+ .rfp-note{color:var(--rfp-ink-2);max-width:64ch;margin:8px 0 0;font-size:13.5px}
6713
+ .rfp-note-sm{color:var(--rfp-ink-3);font-size:12.5px;margin:10px 0 0;max-width:64ch}
6714
+ .rfp-tag{font-family:var(--rfp-mono);font-size:11px;color:var(--rfp-ink-3);border:1px solid var(--rfp-rule-2);border-radius:3px;padding:1px 6px}
6715
+ .rfp-plate{margin-top:22px;border-top:1px solid var(--rfp-rule);padding-top:18px}
6716
+ .rfp-plate-head{display:flex;align-items:baseline;gap:10px;margin-bottom:14px;flex-wrap:wrap}
6717
+ .rfp-plate-name{font-family:var(--rfp-mono);font-size:12.5px;font-weight:600;color:var(--rfp-ink)}
6718
+ .rfp-plate-sub{font-family:var(--rfp-mono);font-size:11px;color:var(--rfp-ink-3)}
6719
+ .rfp-id{font-family:var(--rfp-mono);font-size:11.5px;color:var(--rfp-ink-2);background:none;border:0;
6720
+ padding:1px 4px;margin-left:-4px;border-radius:3px;cursor:copy;text-align:left;display:inline-block}
6721
+ .rfp-id:hover{background:var(--rfp-panel-2);color:var(--rfp-ink)}
6722
+ .rfp-id.rfp-copied{background:var(--rfp-ink);color:var(--rfp-ground)}
6723
+ .rfp-var{font-family:var(--rfp-mono);font-size:10.5px;color:var(--rfp-ink-3);display:block;margin-top:1px}
6724
+ .rfp-hex{font-family:var(--rfp-mono);font-size:11px;color:var(--rfp-ink-3);font-variant-numeric:tabular-nums;display:block;margin-top:1px}
6725
+ .rfp-ladder{display:grid;grid-template-columns:150px minmax(0,1fr);gap:20px;align-items:start}
6726
+ .rfp-ladder+.rfp-ladder{margin-top:18px}
6727
+ .rfp-ladder-name{font-family:var(--rfp-mono);font-size:12.5px;font-weight:600}
6728
+ .rfp-ladder-base{font-family:var(--rfp-mono);font-size:11px;color:var(--rfp-ink-3);margin-top:2px}
6729
+ .rfp-rungs{display:flex;border-radius:6px;overflow:hidden;border:1px solid var(--rfp-rule)}
6730
+ .rfp-rung{flex:1 1 0;min-width:0}
6731
+ .rfp-rung-chip{height:52px}
6732
+ .rfp-rung[data-lands="true"] .rfp-rung-foot{color:var(--rfp-ink);font-weight:600}
6733
+ .rfp-rung[data-lands="true"] .rfp-rung-foot::before{content:"◆ "}
6734
+ .rfp-rung-foot{padding:5px 2px 6px;text-align:center;background:var(--rfp-panel);border-top:1px solid var(--rfp-rule);
6735
+ font-family:var(--rfp-mono);font-size:10px;font-variant-numeric:tabular-nums;color:var(--rfp-ink-2)}
6736
+ .rfp-pairs{display:grid;grid-template-columns:repeat(auto-fill,minmax(184px,1fr));gap:12px}
6737
+ .rfp-pair{border:1px solid var(--rfp-rule);border-radius:6px;overflow:hidden}
6738
+ .rfp-pair-swatch{padding:16px 14px 14px;display:flex;flex-direction:column;gap:10px;min-height:92px}
6739
+ .rfp-pair-sample{font-size:14px;font-weight:600}
6740
+ .rfp-pair-ratio{align-self:flex-start;font-family:var(--rfp-mono);font-size:10.5px;font-variant-numeric:tabular-nums;
6741
+ padding:1px 6px;border-radius:3px;border:1px solid currentColor}
6742
+ .rfp-pair-foot{padding:8px 12px;background:var(--rfp-panel);border-top:1px solid var(--rfp-rule)}
6743
+ .rfp-rows{display:flex;flex-direction:column}
6744
+ .rfp-row{display:grid;grid-template-columns:190px minmax(0,1fr) 108px;gap:20px;align-items:baseline;
6745
+ padding:11px 0;border-top:1px solid var(--rfp-rule)}
6269
6746
  .rfp-row:first-child{border-top:0}
6270
- .rfp-name{font-size:12px;color:#444;overflow-wrap:anywhere}
6271
- .rfp-swatch{display:flex;align-items:center;gap:10px;min-width:0;overflow:hidden}
6272
- .rfp-val{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#666;background:#f6f6f6;
6273
- padding:1px 5px;border-radius:4px;white-space:nowrap}
6274
- .rfp-chip{width:28px;height:28px;border-radius:5px;border:1px solid rgba(0,0,0,.15);flex:none}
6275
- .rfp-box{width:44px;height:28px;border-radius:5px;background:#fff;border:1px solid rgba(0,0,0,.08);flex:none}
6276
- .rfp-bar{height:10px;background:#111;border-radius:2px;flex:none;max-width:100%}
6277
- .rfp-specimen{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
6278
- .rfp-recipes{display:flex;flex-wrap:wrap;gap:14px}
6279
- .rfp-recipe{border:1px solid #eee;border-radius:8px;padding:12px;min-width:160px;background:#fff}
6280
- .rfp-stage{display:flex;align-items:center;justify-content:center;min-height:56px;margin-bottom:8px}
6281
- .rfp-meta{display:flex;flex-direction:column;gap:3px;align-items:flex-start}
6282
- .rfp-note{font-size:13px;color:#555;background:#f8f8f8;border:1px solid #ececec;border-left:3px solid #bbb;
6283
- border-radius:0 6px 6px 0;padding:10px 14px;margin:0 0 14px}
6284
- .rfp-frame{margin:0 auto;transition:max-width .15s ease}
6285
- .rfp-files{font-size:12px;color:#666;margin:0 0 16px}
6286
- .rfp-files code{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}
6287
- @media (prefers-color-scheme:dark){
6288
- .rfp-root{color:#eee;background:#151515}
6289
- .rfp-head{border-bottom-color:#2c2c2c}
6290
- .rfp-plate,.rfp-recipe{background:#1c1c1c;border-color:#2c2c2c}
6291
- .rfp-plate-title{color:#ddd}.rfp-name{color:#bbb}.rfp-sub,.rfp-files{color:#999}
6292
- .rfp-val{background:#262626;color:#aaa}
6293
- .rfp-controls button{background:#222;border-color:#3a3a3a;color:#eee}
6294
- .rfp-controls button[aria-pressed="true"]{background:#eee;border-color:#eee;color:#111}
6295
- .rfp-note{background:#1d1d1d;border-color:#2c2c2c;border-left-color:#555;color:#bbb}
6296
- .rfp-row{border-top-color:#242424}
6297
- .rfp-bar{background:#eee}
6747
+ .rfp-row.rfp-centred{align-items:center}
6748
+ .rfp-rowval{font-family:var(--rfp-mono);font-size:11px;color:var(--rfp-ink-3);font-variant-numeric:tabular-nums;text-align:right;overflow-wrap:anywhere}
6749
+ .rfp-specimen{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.015em}
6750
+ .rfp-leading{font-size:13px;color:var(--rfp-ink-2);max-width:52ch}
6751
+ .rfp-bar{height:12px;background:var(--rfp-spec);border-radius:2px;max-width:100%}
6752
+ .rfp-bar.rfp-ghost{background:var(--rfp-spec-2)}
6753
+ .rfp-gap{display:flex;align-items:stretch;border:1px dashed var(--rfp-rule-2);border-radius:6px;padding:10px}
6754
+ .rfp-gap>i{flex:1 1 0;height:30px;background:var(--rfp-spec-2);border-radius:3px}
6755
+ .rfp-inset{border:1px solid var(--rfp-rule);border-radius:6px;
6756
+ background:repeating-linear-gradient(-45deg,var(--rfp-hatch) 0 4px,transparent 4px 8px)}
6757
+ .rfp-inset-core{background:var(--rfp-spec);color:var(--rfp-ground);border-radius:3px;font-family:var(--rfp-mono);
6758
+ font-size:10.5px;font-weight:600;text-align:center;padding:8px 4px}
6759
+ .rfp-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}
6760
+ .rfp-tile{display:flex;flex-direction:column;gap:10px}
6761
+ .rfp-stage{height:82px;display:grid;place-items:center;background:var(--rfp-stage);border-radius:6px;
6762
+ border:1px solid var(--rfp-rule);color:var(--rfp-spec)}
6763
+ .rfp-obj{width:62px;height:62px;background:var(--rfp-spec)}
6764
+ .rfp-obj.rfp-outlined{background:transparent}
6765
+ .rfp-obj.rfp-raised{background:var(--rfp-raised)}
6766
+ .rfp-obj.rfp-accent{background:var(--rfp-accent)}
6767
+ .rfp-numeral{font-family:var(--rfp-mono);font-size:20px;font-variant-numeric:tabular-nums;color:var(--rfp-ink-2)}
6768
+ .rfp-track{height:34px;border-radius:6px;background:var(--rfp-stage);border:1px solid var(--rfp-rule);position:relative;overflow:hidden}
6769
+ .rfp-dot{position:absolute;top:6px;left:6px;width:22px;height:22px;border-radius:5px;background:var(--rfp-spec)}
6770
+ .rfp-play{font:inherit;font-family:var(--rfp-mono);font-size:11.5px;padding:3px 10px;border-radius:4px;
6771
+ border:1px solid var(--rfp-rule-2);background:var(--rfp-panel);color:var(--rfp-ink);cursor:pointer;margin-left:auto}
6772
+ .rfp-scroll{overflow-x:auto}
6773
+ .rfp-diff{width:100%;border-collapse:collapse;font-size:13px}
6774
+ .rfp-diff th{text-align:left;font-family:var(--rfp-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;
6775
+ color:var(--rfp-ink-3);font-weight:500;padding:0 12px 8px 0;white-space:nowrap}
6776
+ .rfp-diff td{padding:7px 12px 7px 0;border-top:1px solid var(--rfp-rule);vertical-align:middle}
6777
+ .rfp-swatch-cell{display:flex;align-items:center;gap:8px}
6778
+ .rfp-chip{width:18px;height:18px;border-radius:4px;border:1px solid var(--rfp-rule-2);flex:none}
6779
+ .rfp-arrow{color:var(--rfp-ink-3);font-family:var(--rfp-mono)}
6780
+ .rfp-prose{border:1px solid var(--rfp-rule);border-radius:6px;padding:26px 28px;overflow:hidden}
6781
+ .rfp-matrix{border-collapse:collapse;min-width:560px;width:100%}
6782
+ .rfp-matrix th{font-family:var(--rfp-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;
6783
+ color:var(--rfp-ink-3);font-weight:500;text-align:center;padding:0 8px 10px}
6784
+ .rfp-matrix th:first-child{text-align:left}
6785
+ .rfp-matrix td{padding:12px 8px;border-top:1px solid var(--rfp-rule);text-align:center}
6786
+ .rfp-matrix td:first-child{text-align:left;width:210px}
6787
+ .rfp-none{font-family:var(--rfp-mono);font-size:11px;color:var(--rfp-ink-3);opacity:.6}
6788
+ .rfp-recipes{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
6789
+ .rfp-recipe{border:1px solid var(--rfp-rule);border-radius:6px;overflow:hidden}
6790
+ .rfp-recipe-stage{min-height:96px;display:grid;place-items:center;padding:20px 16px;
6791
+ background:linear-gradient(45deg,var(--rfp-panel) 25%,transparent 25%,transparent 75%,var(--rfp-panel) 75%),
6792
+ linear-gradient(45deg,var(--rfp-panel) 25%,transparent 25%,transparent 75%,var(--rfp-panel) 75%);
6793
+ background-size:14px 14px;background-position:0 0,7px 7px}
6794
+ .rfp-recipe-foot{padding:9px 12px;border-top:1px solid var(--rfp-rule);background:var(--rfp-panel)}
6795
+ .rfp-addr{font-family:var(--rfp-mono);font-size:11px;color:var(--rfp-ink-3);display:block}
6796
+ .rfp-compose{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
6797
+ .rfp-cls{font-family:var(--rfp-mono);font-size:11.5px;padding:3px 8px;border-radius:4px;
6798
+ border:1px solid var(--rfp-rule-2);background:var(--rfp-panel)}
6799
+ .rfp-cls b{font-weight:600;color:var(--rfp-ink)}
6800
+ .rfp-cls span{color:var(--rfp-ink-3)}
6801
+ .rfp-plus{color:var(--rfp-ink-3);font-family:var(--rfp-mono)}
6802
+ .rfp-notice{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:start;padding:14px 16px;
6803
+ border:1px solid var(--rfp-rule-2);border-radius:6px;background:var(--rfp-panel);margin-top:18px}
6804
+ .rfp-notice-mark{font-family:var(--rfp-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;
6805
+ padding:2px 6px;border-radius:3px;background:var(--rfp-ink);color:var(--rfp-ground);white-space:nowrap}
6806
+ .rfp-notice p{margin:0;color:var(--rfp-ink-2);font-size:13px;max-width:68ch}
6807
+ .rfp-notice p+p{margin-top:6px}
6808
+ .rfp-notice strong{color:var(--rfp-ink);font-weight:600}
6809
+ .rfp-frame{margin:0 auto}
6810
+ @media (prefers-reduced-motion:reduce){.rfp *{transition:none!important;animation:none!important}}
6811
+ @media (max-width:900px){
6812
+ .rfp{grid-template-columns:minmax(0,1fr);gap:0;padding:0 20px 72px}
6813
+ .rfp-rail{position:static;max-height:none;padding:24px 0 0}
6814
+ .rfp-nav{flex-direction:row;overflow-x:auto;gap:4px;padding-bottom:4px}
6815
+ .rfp-nav a{white-space:nowrap}
6816
+ .rfp-ladder{grid-template-columns:minmax(0,1fr);gap:10px}
6817
+ .rfp-row{grid-template-columns:130px minmax(0,1fr)}
6818
+ .rfp-rowval{grid-column:1/-1;text-align:left}
6298
6819
  }
6299
6820
  `.trim();
6300
- /** Mode + breakpoint controls. Plain DOM, no dependencies — the page must work from `file://`. */
6301
6821
  const CHROME_JS = `
6302
6822
  (function(){
6303
- var root=document.documentElement;
6304
- function bind(sel,onPick){
6305
- var btns=[].slice.call(document.querySelectorAll(sel));
6306
- btns.forEach(function(b){
6307
- b.addEventListener("click",function(){
6308
- btns.forEach(function(o){o.setAttribute("aria-pressed",String(o===b));});
6309
- onPick(b.getAttribute("data-value"));
6310
- });
6311
- });
6312
- }
6313
- bind("[data-rfp-mode]",function(v){
6314
- var attr=root.getAttribute("data-rfp-mode-attr");
6315
- if(!attr)return;
6316
- if(v)root.setAttribute(attr,v);else root.removeAttribute(attr);
6317
- });
6318
- bind("[data-rfp-width]",function(v){
6319
- var frame=document.getElementById("rfp-frame");
6320
- if(frame)frame.style.maxWidth=v?v+"px":"";
6321
- });
6823
+ var root=document.documentElement;
6824
+ function srgb(c){c/=255;return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4);}
6825
+ function lum(hex){var m=/^#([0-9a-f]{6})$/i.exec(hex.trim());if(!m)return null;var n=parseInt(m[1],16);
6826
+ return 0.2126*srgb((n>>16)&255)+0.7152*srgb((n>>8)&255)+0.0722*srgb(n&255);}
6827
+ // Contrast is computed here rather than baked in, so it can never drift from the swatch beside it.
6828
+ document.querySelectorAll(".rfp-pair-swatch[data-fg]").forEach(function(el){
6829
+ var out=el.querySelector(".rfp-pair-ratio");if(!out)return;
6830
+ var a=lum(el.getAttribute("data-bg")||""),b=lum(el.getAttribute("data-fg")||"");
6831
+ if(a===null||b===null){out.remove();return;}
6832
+ var r=(Math.max(a,b)+0.05)/(Math.min(a,b)+0.05);
6833
+ out.textContent=r.toFixed(2)+":1 · "+(r>=7?"AAA":r>=4.5?"AA":r>=3?"AA large":"fail");
6834
+ });
6835
+ function bind(sel,onPick){
6836
+ var btns=[].slice.call(document.querySelectorAll(sel));
6837
+ btns.forEach(function(b){b.addEventListener("click",function(){
6838
+ btns.forEach(function(o){o.setAttribute("aria-pressed",String(o===b));});
6839
+ onPick(b.getAttribute("data-value"));});});
6840
+ }
6841
+ bind("[data-rfp-mode]",function(v){
6842
+ var attr=root.getAttribute("data-rfp-mode-attr");if(!attr)return;
6843
+ if(v)root.setAttribute(attr,v);else root.removeAttribute(attr);});
6844
+ bind("[data-rfp-width]",function(v){
6845
+ var frame=document.getElementById("rfp-frame");if(frame)frame.style.maxWidth=v?v+"px":"";});
6846
+ var play=document.getElementById("rfp-play"),running=false;
6847
+ if(play)play.addEventListener("click",function(){
6848
+ running=!running;play.textContent=running?"Reset":"Play";
6849
+ document.querySelectorAll(".rfp-dot").forEach(function(d){
6850
+ d.style.transform=running?"translateX("+(d.parentElement.clientWidth-34)+"px)":"translateX(0)";});});
6851
+ document.addEventListener("click",function(e){
6852
+ var btn=e.target.closest?e.target.closest(".rfp-id"):null;if(!btn)return;
6853
+ var text=btn.textContent.trim();
6854
+ var done=function(){var prev=btn.textContent;btn.classList.add("rfp-copied");btn.textContent="copied";
6855
+ setTimeout(function(){btn.classList.remove("rfp-copied");btn.textContent=prev;},900);};
6856
+ if(navigator.clipboard){navigator.clipboard.writeText(text).then(done,done);}else{done();}});
6857
+ var links=[].slice.call(document.querySelectorAll(".rfp-nav a"));
6858
+ if(window.IntersectionObserver){
6859
+ var io=new IntersectionObserver(function(es){es.forEach(function(en){if(!en.isIntersecting)return;
6860
+ links.forEach(function(a){a.setAttribute("aria-current",String(a.getAttribute("href")==="#"+en.target.id));});});},
6861
+ {rootMargin:"-10% 0px -80% 0px"});
6862
+ document.querySelectorAll(".rfp-section").forEach(function(s){io.observe(s);});
6863
+ }
6322
6864
  })();
6323
6865
  `.trim();
6324
- function renderControls(modes, modeAttribute, breakpoints) {
6325
- const parts = [];
6326
- // A mode toggle is only offered when the adapter told us HOW to switch — otherwise the buttons
6327
- // would be decorative. "Auto" clears the attribute and hands control back to the OS media query.
6328
- if (modeAttribute && modes.length > 0) {
6329
- const buttons = [`<button type="button" data-rfp-mode data-value="" aria-pressed="true">auto</button>`]
6330
- .concat(modes.map(mode => `<button type="button" data-rfp-mode data-value="${escapeHtml(mode)}" aria-pressed="false">` +
6331
- `${escapeHtml(mode)}</button>`))
6332
- .join("");
6333
- parts.push(`<div class="rfp-controls"><span class="rfp-group-label">mode</span>${buttons}</div>`);
6334
- }
6335
- const widths = Object.entries(breakpoints);
6336
- if (widths.length > 0) {
6337
- const buttons = [`<button type="button" data-rfp-width data-value="" aria-pressed="true">full</button>`]
6338
- .concat(widths.map(([name, px]) => `<button type="button" data-rfp-width data-value="${escapeHtml(String(px))}" aria-pressed="false">` +
6339
- `${escapeHtml(name)} · ${escapeHtml(String(px))}px</button>`))
6340
- .join("");
6341
- parts.push(`<div class="rfp-controls"><span class="rfp-group-label">width</span>${buttons}</div>`);
6342
- }
6343
- return parts.join("\n");
6344
- }
6345
6866
  /** `<style>`/`<link>` tags for the emitted artifacts, in the adapter's declared load order. */
6346
6867
  function renderThemeLinks(stylesheets, contents, inline) {
6347
6868
  return stylesheets
6348
6869
  .map(name => {
6349
6870
  const body = inline ? contents === null || contents === void 0 ? void 0 : contents[name] : undefined;
6350
6871
  return body === undefined
6351
- ? `<link rel="stylesheet" href="./${escapeHtml(name)}">`
6352
- : `<style data-rfp-source="${escapeHtml(name)}">\n${escapeTextElement(body)}\n</style>`;
6872
+ ? `<link rel="stylesheet" href="./${esc(name)}">`
6873
+ : `<style data-rfp-source="${esc(name)}">\n${escapeTextElement(body)}\n</style>`;
6353
6874
  })
6354
6875
  .join("\n");
6355
6876
  }
6356
6877
  // ---------------------------------------------------------------------------
6357
6878
  // Entry point
6358
6879
  // ---------------------------------------------------------------------------
6359
- /**
6360
- * Build the preview artifact. Pure — returns `filename → contents` for the build layer to write.
6361
- *
6362
- * The adapter's declared `stylesheets` are intersected with `options.files` (what was actually
6363
- * written), so a descriptor that drifts from `emit()` degrades to tokens-only instead of producing a
6364
- * page that silently references a file that isn't there.
6365
- */
6366
6880
  function buildPreview(source, options) {
6367
- var _a, _b, _c, _d, _e, _f, _g;
6881
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
6368
6882
  const { usage, preview, tokens, model } = source;
6369
6883
  const fileName = (_a = options.file) !== null && _a !== void 0 ? _a : DEFAULT_FILE;
6370
6884
  const inline = (_b = options.inline) !== null && _b !== void 0 ? _b : true;
6371
6885
  const written = new Set(options.files);
6372
6886
  const stylesheets = ((_c = preview === null || preview === void 0 ? void 0 : preview.stylesheets) !== null && _c !== void 0 ? _c : []).filter(name => written.has(name));
6373
6887
  const live = stylesheets.length > 0;
6374
- const modes = collectModes(model);
6375
- const breakpoints = (_d = model.breakpoints) !== null && _d !== void 0 ? _d : {};
6376
- const title = (_e = options.title) !== null && _e !== void 0 ? _e : `${usage.format} theme preview`;
6888
+ const tokenName = preview === null || preview === void 0 ? void 0 : preview.tokenName;
6889
+ const leaves = [];
6890
+ collectLeaves(tokens, [], undefined, leaves);
6891
+ const groups = groupLeaves(leaves);
6892
+ // Build each section from the groups that map to it; a section with no content is omitted
6893
+ // entirely, so a theme without shadows shows no elevation plate rather than an empty box.
6894
+ const bodies = new Map();
6895
+ const counts = new Map();
6896
+ for (const [group, items] of groups) {
6897
+ const section = (_d = SECTION_OF[group]) !== null && _d !== void 0 ? _d : "other";
6898
+ counts.set(section, ((_e = counts.get(section)) !== null && _e !== void 0 ? _e : 0) + items.length);
6899
+ }
6900
+ for (const section of SECTIONS) {
6901
+ const own = [...groups.entries()].filter(([g]) => { var _a; return ((_a = SECTION_OF[g]) !== null && _a !== void 0 ? _a : "other") === section.id; });
6902
+ if (!own.length)
6903
+ continue;
6904
+ let body = "";
6905
+ if (section.id === "palette")
6906
+ body = renderPalette(own.flatMap(([, l]) => l), tokenName);
6907
+ else if (section.id === "type")
6908
+ body = renderTypography(own.flatMap(([, l]) => l), tokenName);
6909
+ else if (section.id === "space")
6910
+ body = renderSpace(new Map(own), tokenName);
6911
+ else if (section.id === "shape")
6912
+ body = renderShape(new Map(own), tokenName);
6913
+ else if (section.id === "motion")
6914
+ body = renderMotion(own.flatMap(([, l]) => l), tokenName);
6915
+ else
6916
+ body = own.map(([g, l]) => renderGeneric(g, l, tokenName)).join("");
6917
+ if (body)
6918
+ bodies.set(section.id, body);
6919
+ }
6920
+ const modesHtml = renderModes(model, leaves.length, tokenName);
6921
+ const globalsHtml = renderGlobals(model, live);
6377
6922
  const notes = [];
6378
6923
  if (!live) {
6379
6924
  notes.push((_f = preview === null || preview === void 0 ? void 0 : preview.unavailable) !== null && _f !== void 0 ? _f : `This theme was built with the ${usage.format} adapter, whose output a browser can't load ` +
@@ -6381,42 +6926,85 @@ function buildPreview(source, options) {
6381
6926
  }
6382
6927
  for (const note of (_g = preview === null || preview === void 0 ? void 0 : preview.notes) !== null && _g !== void 0 ? _g : [])
6383
6928
  notes.push(note);
6929
+ // ── Rail ────────────────────────────────────────────────────────────────
6930
+ const navItems = [];
6931
+ for (const section of SECTIONS) {
6932
+ if (!bodies.has(section.id))
6933
+ continue;
6934
+ navItems.push(`<a href="#rfp-${section.id}">${section.eyebrow}<span class="rfp-n">${(_h = counts.get(section.id)) !== null && _h !== void 0 ? _h : 0}</span></a>`);
6935
+ }
6936
+ if (modesHtml)
6937
+ navItems.push(`<a href="#rfp-modes">Appearance</a>`);
6938
+ if (globalsHtml)
6939
+ navItems.push(`<a href="#rfp-globals">Base elements</a>`);
6940
+ navItems.push(`<a href="#rfp-recipes">Components<span class="rfp-n">${usage.recipes.length}</span></a>`);
6941
+ // ── Masthead ────────────────────────────────────────────────────────────
6942
+ const title = (_j = options.title) !== null && _j !== void 0 ? _j : `${usage.format} theme`;
6943
+ const totalBytes = options.files.reduce((sum, f) => { var _a, _b, _c; return sum + ((_c = (_b = (_a = options.contents) === null || _a === void 0 ? void 0 : _a[f]) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0); }, 0);
6944
+ const metaBits = [
6945
+ `<span><i class="rfp-dot"></i> ${esc(usage.format)} · ${live ? "live" : "tokens only"}</span>`,
6946
+ `<span>emit <b>${esc(options.plan.type)}</b></span>`,
6947
+ `<span>${leaves.length} tokens</span>`,
6948
+ `<span>${usage.recipes.length} recipes</span>`,
6949
+ totalBytes ? `<span>${options.files.length} file(s) · ${bytes(totalBytes)}</span>` : "",
6950
+ ].filter(Boolean);
6951
+ const modeAttribute = preview === null || preview === void 0 ? void 0 : preview.modeAttribute;
6952
+ const modes = collectModes(model);
6953
+ const breakpoints = (_k = model.breakpoints) !== null && _k !== void 0 ? _k : {};
6954
+ const controls = [];
6955
+ if (modeAttribute && modes.length) {
6956
+ const buttons = [`<button type="button" data-rfp-mode data-value="" aria-pressed="true">auto</button>`]
6957
+ .concat(modes.map(m => `<button type="button" data-rfp-mode data-value="${esc(m)}" aria-pressed="false">${esc(m)}</button>`))
6958
+ .join("");
6959
+ controls.push(`<div class="rfp-ctl"><span class="rfp-ctl-label">Appearance</span><div class="rfp-seg" role="group" aria-label="Appearance mode">${buttons}</div></div>`);
6960
+ }
6961
+ const widths = Object.entries(breakpoints);
6962
+ if (widths.length) {
6963
+ const buttons = [`<button type="button" data-rfp-width data-value="" aria-pressed="true">full</button>`]
6964
+ .concat(widths.map(([n, px]) => `<button type="button" data-rfp-width data-value="${esc(String(px))}" aria-pressed="false">${esc(n)}</button>`))
6965
+ .join("");
6966
+ controls.push(`<div class="rfp-ctl"><span class="rfp-ctl-label">Width</span><div class="rfp-seg" role="group" aria-label="Viewport width">${buttons}</div></div>`);
6967
+ }
6968
+ // ── Sections ────────────────────────────────────────────────────────────
6969
+ const sectionHtml = SECTIONS.filter(s => bodies.has(s.id))
6970
+ .map(s => `<section class="rfp-section" id="rfp-${s.id}"><div class="rfp-section-head"><div>` +
6971
+ `<div class="rfp-eyebrow">${s.eyebrow}</div><h2>${s.title}</h2></div></div>` +
6972
+ (s.note ? `<p class="rfp-note">${s.note}</p>` : "") +
6973
+ bodies.get(s.id) +
6974
+ `</section>`)
6975
+ .join("");
6384
6976
  const head = [
6385
6977
  `<meta charset="utf-8">`,
6386
6978
  `<meta name="viewport" content="width=device-width,initial-scale=1">`,
6387
- `<title>${escapeHtml(title)}</title>`,
6388
- // Theme first, chrome last: equal-specificity ties then resolve in the chrome's favor.
6979
+ `<title>${esc(title)}</title>`,
6980
+ // Theme FIRST, chrome last: equal-specificity ties then resolve in the chrome's favour, and a
6981
+ // themed element rule (`globals`) can never outrank a class-only chrome selector.
6389
6982
  renderThemeLinks(stylesheets, options.contents, inline),
6983
+ (preview === null || preview === void 0 ? void 0 : preview.statePinCss) && live ? `<style data-rfp-state-pins>\n${escapeTextElement(preview.statePinCss)}\n</style>` : "",
6390
6984
  `<style>\n${CHROME_CSS}\n</style>`,
6391
6985
  ]
6392
6986
  .filter(Boolean)
6393
6987
  .join("\n");
6394
- const modeAttribute = preview === null || preview === void 0 ? void 0 : preview.modeAttribute;
6395
- const fileList = options.files.length
6396
- ? `<p class="rfp-files">Built files: ${options.files.map(f => `<code>${escapeHtml(f)}</code>`).join(" · ")}</p>`
6397
- : "";
6398
- const body = [
6399
- `<div class="rfp-root">`,
6400
- `<header class="rfp-head">`,
6401
- `<h1 class="rfp-title">${escapeHtml(title)}</h1>`,
6402
- `<span class="rfp-sub">format <strong>${escapeHtml(usage.format)}</strong> · emit ` +
6403
- `<strong>${escapeHtml(options.plan.type)}</strong> · ${usage.recipes.length} recipe(s)</span>`,
6404
- `</header>`,
6405
- fileList,
6406
- notes.map(n => `<p class="rfp-note">${escapeHtml(n)}</p>`).join("\n"),
6407
- renderControls(modes, modeAttribute, breakpoints),
6408
- `<div class="rfp-frame" id="rfp-frame">`,
6409
- `<h2 class="rfp-section-title">Tokens</h2>`,
6410
- renderTokenPlates(tokens),
6411
- `<h2 class="rfp-section-title">Recipes</h2>`,
6412
- renderRecipePlates(usage, preview, live),
6413
- `</div>`,
6414
- `</div>`,
6415
- `<script>\n${CHROME_JS}\n</script>`,
6416
- ]
6417
- .filter(Boolean)
6418
- .join("\n");
6419
- const html = `<!doctype html>\n<html lang="en"${modeAttribute ? ` data-rfp-mode-attr="${escapeHtml(modeAttribute)}"` : ""}>\n` +
6988
+ const body = `<div class="rfp">` +
6989
+ `<aside class="rfp-rail"><div class="rfp-brand">refract preview</div>` +
6990
+ `<nav class="rfp-nav">${navItems.join("")}</nav></aside>` +
6991
+ `<main class="rfp-main">` +
6992
+ `<header class="rfp-masthead"><div class="rfp-eyebrow">Theme specimen</div>` +
6993
+ `<h1>${esc(title)}</h1><div class="rfp-meta">${metaBits.join("")}</div></header>` +
6994
+ (controls.length ? `<div class="rfp-controls">${controls.join("")}</div>` : "") +
6995
+ notes.map(n => `<p class="rfp-note">${esc(n)}</p>`).join("") +
6996
+ `<div class="rfp-frame" id="rfp-frame">` +
6997
+ sectionHtml +
6998
+ modesHtml +
6999
+ globalsHtml +
7000
+ `<section class="rfp-section" id="rfp-recipes"><div class="rfp-section-head"><div>` +
7001
+ `<div class="rfp-eyebrow">Components</div><h2>Recipes${live ? " and their states" : ""}</h2></div>` +
7002
+ `<span class="rfp-tag">${live ? "rendered live" : "names only"}</span></div>` +
7003
+ renderRecipes(usage, preview, live) +
7004
+ `</section>` +
7005
+ `</div></main></div>` +
7006
+ `<script>\n${CHROME_JS}\n</script>`;
7007
+ const html = `<!doctype html>\n<html lang="en"${modeAttribute ? ` data-rfp-mode-attr="${esc(modeAttribute)}"` : ""}>\n` +
6420
7008
  `<head>\n${head}\n</head>\n<body>\n${body}\n</body>\n</html>\n`;
6421
7009
  return { files: { [fileName]: html } };
6422
7010
  }