@odla-ai/brand 0.3.0 → 0.5.0

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/index.cjs CHANGED
@@ -47,7 +47,16 @@ __export(src_exports, {
47
47
  DARK_FLIP_L_MIN: () => DARK_FLIP_L_MIN,
48
48
  DEFAULT_ACCENT_SEED: () => DEFAULT_ACCENT_SEED,
49
49
  DEFAULT_BRAND_SYSTEM: () => DEFAULT_BRAND_SYSTEM,
50
+ DESIGN_ASSET_KIND: () => DESIGN_ASSET_KIND,
51
+ DESIGN_CONTENT_TYPES: () => DESIGN_CONTENT_TYPES,
52
+ MAX_COLORS: () => MAX_COLORS,
53
+ MAX_EXTERNALS: () => MAX_EXTERNALS,
54
+ MAX_FONTS: () => MAX_FONTS,
55
+ MAX_HEADING_CHARS: () => MAX_HEADING_CHARS,
56
+ MAX_OUTLINE_ENTRIES: () => MAX_OUTLINE_ENTRIES,
57
+ MAX_PROPS: () => MAX_PROPS,
50
58
  MAX_SWATCHES: () => MAX_SWATCHES,
59
+ MAX_THUMBNAIL_CHARS: () => MAX_THUMBNAIL_CHARS,
51
60
  MAX_VIEW_BYTES: () => MAX_VIEW_BYTES,
52
61
  PALETTE_SOURCES: () => PALETTE_SOURCES,
53
62
  PALETTE_STATUSES: () => PALETTE_STATUSES,
@@ -56,6 +65,7 @@ __export(src_exports, {
56
65
  PROPOSAL_STATUSES: () => PROPOSAL_STATUSES,
57
66
  RAMP_L_MAX: () => RAMP_L_MAX,
58
67
  RAMP_L_MIN: () => RAMP_L_MIN,
68
+ ROLE_TOKEN: () => ROLE_TOKEN,
59
69
  SECTION_KINDS: () => SECTION_KINDS,
60
70
  SECTION_STATUSES: () => SECTION_STATUSES,
61
71
  SWATCH_ROLES: () => SWATCH_ROLES,
@@ -70,6 +80,7 @@ __export(src_exports, {
70
80
  assetTools: () => assetTools,
71
81
  attachedBrandAssetIds: () => attachedBrandAssetIds,
72
82
  audienceFanoutOps: () => audienceFanoutOps,
83
+ base64ByteLength: () => base64ByteLength,
73
84
  base64FromBytes: () => base64FromBytes,
74
85
  beginAssetDeleteOps: () => beginAssetDeleteOps,
75
86
  bookForChannel: () => bookForChannel,
@@ -88,27 +99,43 @@ __export(src_exports, {
88
99
  capStringArray: () => capStringArray,
89
100
  clamp01: () => clamp01,
90
101
  clampToGamut: () => clampToGamut,
102
+ collapseWhitespace: () => collapseWhitespace,
91
103
  compileBrandTokens: () => compileBrandTokens,
92
104
  complementary: () => complementary,
93
105
  contrastRatio: () => contrastRatio,
106
+ contrastReport: () => contrastReport,
94
107
  createAssetOps: () => createAssetOps,
95
108
  createBookOps: () => createBookOps,
96
109
  createBrandIntegration: () => createBrandIntegration,
97
110
  createBrandPersona: () => createBrandPersona,
98
111
  createBrandRoutes: () => createBrandRoutes,
112
+ cssColorToHex: () => cssColorToHex,
113
+ decodeEntities: () => decodeEntities,
99
114
  deltaEOK: () => deltaEOK,
100
115
  deltaEOKLab: () => deltaEOKLab,
101
116
  deriveChartColors: () => deriveChartColors,
102
117
  deriveDarkTokens: () => deriveDarkTokens,
103
118
  derivePalette: () => derivePalette,
119
+ digestDesignBundle: () => digestDesignBundle,
120
+ digestDesignHtml: () => digestDesignHtml,
104
121
  dispatchBrandTurn: () => dispatchBrandTurn,
122
+ extractColors: () => extractColors,
123
+ extractDesignTokens: () => extractDesignTokens,
124
+ extractFonts: () => extractFonts,
125
+ extractOutline: () => extractOutline,
126
+ extractProps: () => extractProps,
127
+ extractThumbnailSvg: () => extractThumbnailSvg,
128
+ extractTitle: () => extractTitle,
105
129
  finishAssetDeleteOps: () => finishAssetDeleteOps,
106
130
  formatBrandDiscussionReference: () => formatBrandDiscussionReference,
131
+ groupAssets: () => groupAssets,
107
132
  hexToOklch: () => hexToOklch,
108
133
  hslToRgb: () => hslToRgb,
134
+ htmlToText: () => htmlToText,
109
135
  inSrgbGamut: () => inSrgbGamut,
110
136
  isBoundedBrandJson: () => isBoundedBrandJson,
111
137
  isBrandHumanAuthorityConsumption: () => isBrandHumanAuthorityConsumption,
138
+ isDesignBundle: () => isDesignBundle,
112
139
  linearToSrgb: () => linearToSrgb,
113
140
  mapPaletteToTokens: () => mapPaletteToTokens,
114
141
  meetsAA: () => meetsAA,
@@ -123,25 +150,32 @@ __export(src_exports, {
123
150
  paletteTools: () => paletteTools,
124
151
  parseBrandDiscussionReference: () => parseBrandDiscussionReference,
125
152
  parseBrandDispatch: () => parseBrandDispatch,
153
+ parseDesignBundle: () => parseDesignBundle,
126
154
  parseHex: () => parseHex,
127
155
  pickTextOn: () => pickTextOn,
128
156
  proposalReviewSnapshot: () => proposalReviewSnapshot,
129
157
  proposePaletteOps: () => proposePaletteOps,
130
158
  proposeSectionOps: () => proposeSectionOps,
159
+ readDesignManifest: () => readDesignManifest,
131
160
  readTools: () => readTools,
132
161
  recordAnalysisOps: () => recordAnalysisOps,
133
162
  rejectProposalOps: () => rejectProposalOps,
134
163
  relativeLuminance: () => relativeLuminance,
135
164
  renderTokensCss: () => renderTokensCss,
136
165
  resolveDeps: () => resolveDeps,
166
+ resolveVarRefs: () => resolveVarRefs,
137
167
  rgbToHsl: () => rgbToHsl,
138
168
  rgbToOklab: () => rgbToOklab,
139
169
  rotateHue: () => rotateHue,
140
170
  safeFileName: () => safeFileName,
171
+ scanCustomProperties: () => scanCustomProperties,
141
172
  sectionKey: () => sectionKey,
142
173
  splitComplementary: () => splitComplementary,
143
174
  srgbToLinear: () => srgbToLinear,
175
+ stripCssComments: () => stripCssComments,
176
+ styleSheetText: () => styleSheetText,
144
177
  supportsBrandVision: () => supportsBrandVision,
178
+ swatchesFromDesignTokens: () => swatchesFromDesignTokens,
145
179
  tetradic: () => tetradic,
146
180
  tintShadeRamp: () => tintShadeRamp,
147
181
  toHex: () => toHex,
@@ -168,7 +202,15 @@ var PALETTE_STATUSES = ["active", "archived"];
168
202
  var PALETTE_SOURCES = ["extracted", "derived", "manual"];
169
203
  var PROPOSAL_KINDS = SECTION_KINDS;
170
204
  var PROPOSAL_STATUSES = ["open", "accepted", "rejected", "superseded"];
171
- var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "other"];
205
+ var ASSET_KINDS = [
206
+ "logo",
207
+ "wordmark",
208
+ "inspiration",
209
+ "document",
210
+ "design",
211
+ "other"
212
+ ];
213
+ var DESIGN_ASSET_KIND = "design";
172
214
  var SWATCH_ROLES = [
173
215
  "primary",
174
216
  "secondary",
@@ -392,7 +434,7 @@ var BRAND_SCHEMA = {
392
434
  id: uniq("string"),
393
435
  bookId: idx("string"),
394
436
  kind: idx("string"),
395
- // logo | wordmark | inspiration | document | other
437
+ // logo | wordmark | inspiration | document | design | other
396
438
  path: idx("string"),
397
439
  storageObjectId: idx("string"),
398
440
  contentDigest: idx("string"),
@@ -404,6 +446,11 @@ var BRAND_SCHEMA = {
404
446
  analysis: opt("json"),
405
447
  // { description, dominantColors: hex[], tags }
406
448
  analyzedAt: opt("date"),
449
+ // `design` kind only: the DesignDigest computed from the uploaded
450
+ // bundle at upload time. Machine-derived and deterministic — unlike
451
+ // `analysis`, which is what a model reports seeing — so it needs no
452
+ // review gate and is rewritten only by re-uploading.
453
+ design: opt("json"),
407
454
  analysisRevision: a("number"),
408
455
  analysisDigest: opt("string"),
409
456
  analyzedBy: opt("string"),
@@ -498,6 +545,7 @@ var ASSET_CONTENT_TYPES = /* @__PURE__ */ new Set([
498
545
  "image/webp",
499
546
  "application/pdf"
500
547
  ]);
548
+ var DESIGN_CONTENT_TYPES = /* @__PURE__ */ new Set(["text/html"]);
501
549
  var HEX_RGB = /^#[0-9a-f]{3}$/;
502
550
  var HEX_RRGGBB = /^#[0-9a-f]{6}$/;
503
551
  var HEX_ALPHA = /^#[0-9a-f]{4}$|^#[0-9a-f]{8}$/;
@@ -619,12 +667,13 @@ function safeFileName(name) {
619
667
  throw new BrandInputError("file name is empty or a dot segment after sanitizing");
620
668
  return cleaned.slice(0, 120);
621
669
  }
622
- function assertAssetContentType(value) {
670
+ function assertAssetContentType(value, kind) {
623
671
  if (typeof value !== "string") throw new BrandInputError("contentType must be a string");
624
672
  const ct = value.split(";")[0].trim().toLowerCase();
625
- if (!ASSET_CONTENT_TYPES.has(ct))
673
+ const allowed = kind === DESIGN_ASSET_KIND ? DESIGN_CONTENT_TYPES : ASSET_CONTENT_TYPES;
674
+ if (!allowed.has(ct))
626
675
  throw new BrandInputError(
627
- `unsupported content type ${ct || "(empty)"}; allowed: ${[...ASSET_CONTENT_TYPES].join(", ")}`
676
+ `unsupported content type ${ct || "(empty)"} for kind ${kind ?? "other"}; allowed: ${[...allowed].join(", ")}`
628
677
  );
629
678
  return ct;
630
679
  }
@@ -1171,7 +1220,11 @@ function rejectProposalOps(input) {
1171
1220
  function createAssetOps(input) {
1172
1221
  if (!ASSET_KINDS.includes(input.kind))
1173
1222
  throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
1174
- const contentType = assertAssetContentType(input.contentType);
1223
+ const contentType = assertAssetContentType(input.contentType, input.kind);
1224
+ if (input.kind === DESIGN_ASSET_KIND && input.design === void 0)
1225
+ throw new BrandInputError("a design asset must carry its parsed digest");
1226
+ if (input.kind !== DESIGN_ASSET_KIND && input.design !== void 0)
1227
+ throw new BrandInputError(`only ${DESIGN_ASSET_KIND} assets may carry a design digest`);
1175
1228
  if (typeof input.size !== "number" || !Number.isFinite(input.size) || input.size <= 0)
1176
1229
  throw new BrandInputError("size must be a positive byte count");
1177
1230
  if (!/^sha256:[0-9a-f]{64}$/.test(input.contentDigest))
@@ -1201,7 +1254,8 @@ function createAssetOps(input) {
1201
1254
  200
1202
1255
  ),
1203
1256
  createdAt: input.now,
1204
- ...title ? { title } : {}
1257
+ ...title ? { title } : {},
1258
+ ...input.design ? { design: input.design } : {}
1205
1259
  }
1206
1260
  },
1207
1261
  { t: "link", ns: BRAND_NS.asset, id: input.id, label: "book", target: input.bookId }
@@ -1253,6 +1307,180 @@ async function recordAnalysisOps(assetId, analysis, priorRevision, analyzedBy, a
1253
1307
  ];
1254
1308
  }
1255
1309
 
1310
+ // src/design/bundle.ts
1311
+ var MAX_THUMBNAIL_CHARS = 16384;
1312
+ function island(html, name) {
1313
+ const open = `<script type="__bundler/${name}">`;
1314
+ const start = html.indexOf(open);
1315
+ if (start < 0) return null;
1316
+ const from = start + open.length;
1317
+ const end = html.indexOf("</script>", from);
1318
+ return end < 0 ? null : html.slice(from, end);
1319
+ }
1320
+ function isDesignBundle(html) {
1321
+ return html.includes('<script type="__bundler/manifest">') && html.includes('<script type="__bundler/template">');
1322
+ }
1323
+ function base64ByteLength(data) {
1324
+ const len = data.length;
1325
+ if (len === 0) return 0;
1326
+ const pad = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
1327
+ return Math.max(0, Math.floor(len * 3 / 4) - pad);
1328
+ }
1329
+ function parseIslandJson(text, name) {
1330
+ try {
1331
+ return JSON.parse(text);
1332
+ } catch {
1333
+ throw new BrandInputError(`design bundle's ${name} island is not valid JSON`);
1334
+ }
1335
+ }
1336
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1337
+ function readAssets(raw) {
1338
+ if (!isRecord2(raw)) throw new BrandInputError("design bundle's manifest island must be an object");
1339
+ const assets = [];
1340
+ for (const [uuid, value] of Object.entries(raw)) {
1341
+ if (!isRecord2(value)) continue;
1342
+ const data = value.data;
1343
+ const mime = value.mime;
1344
+ if (typeof data !== "string" || typeof mime !== "string") continue;
1345
+ assets.push({
1346
+ uuid,
1347
+ mime: mime.slice(0, 120),
1348
+ bytes: base64ByteLength(data),
1349
+ compressed: value.compressed === true
1350
+ });
1351
+ }
1352
+ return assets;
1353
+ }
1354
+ function readExternals(raw) {
1355
+ if (raw === null || raw === void 0) return [];
1356
+ if (!Array.isArray(raw))
1357
+ throw new BrandInputError("design bundle's ext_resources island must be an array");
1358
+ const out = [];
1359
+ for (const entry of raw) {
1360
+ if (isRecord2(entry) && typeof entry.id === "string") out.push(entry.id.slice(0, 512));
1361
+ }
1362
+ return out;
1363
+ }
1364
+ function readPageOrder(raw) {
1365
+ if (raw === null || raw === void 0) return [];
1366
+ if (!Array.isArray(raw))
1367
+ throw new BrandInputError("design bundle's page_order island must be an array");
1368
+ return raw.filter((v) => typeof v === "string");
1369
+ }
1370
+ function extractThumbnailSvg(html) {
1371
+ const anchor = html.indexOf("__bundler_thumbnail");
1372
+ if (anchor < 0) return void 0;
1373
+ const open = html.indexOf("<svg", anchor);
1374
+ if (open < 0) return void 0;
1375
+ const close = html.indexOf("</svg>", open);
1376
+ if (close < 0) return void 0;
1377
+ const svg = html.slice(open, close + "</svg>".length);
1378
+ return svg.length > MAX_THUMBNAIL_CHARS ? void 0 : svg;
1379
+ }
1380
+ function readDesignManifest(html) {
1381
+ const raw = island(html, "manifest");
1382
+ if (raw === null) throw new BrandInputError("design bundle has no manifest island");
1383
+ const parsed = parseIslandJson(raw, "manifest");
1384
+ if (!isRecord2(parsed)) throw new BrandInputError("design bundle's manifest island must be an object");
1385
+ const out = {};
1386
+ for (const [uuid, value] of Object.entries(parsed)) {
1387
+ if (!isRecord2(value)) continue;
1388
+ const { data, mime } = value;
1389
+ if (typeof data !== "string" || typeof mime !== "string") continue;
1390
+ out[uuid] = { mime, compressed: value.compressed === true, data };
1391
+ }
1392
+ return out;
1393
+ }
1394
+ function parseDesignBundle(html) {
1395
+ if (!isDesignBundle(html))
1396
+ throw new BrandInputError(
1397
+ "not a Claude Design bundle: no __bundler/manifest and __bundler/template script islands. Export the design as standalone HTML and upload that file."
1398
+ );
1399
+ const templateRaw = island(html, "template");
1400
+ if (templateRaw === null)
1401
+ throw new BrandInputError("design bundle's template island is unterminated");
1402
+ const template = parseIslandJson(templateRaw, "template");
1403
+ if (typeof template !== "string")
1404
+ throw new BrandInputError("design bundle's template island must be a JSON string");
1405
+ const manifestRaw = island(html, "manifest");
1406
+ if (manifestRaw === null)
1407
+ throw new BrandInputError("design bundle's manifest island is unterminated");
1408
+ const extRaw = island(html, "ext_resources");
1409
+ const pageRaw = island(html, "page_order");
1410
+ const thumbnailSvg = extractThumbnailSvg(html);
1411
+ return {
1412
+ template,
1413
+ assets: readAssets(parseIslandJson(manifestRaw, "manifest")),
1414
+ externals: readExternals(extRaw === null ? null : parseIslandJson(extRaw, "ext_resources")),
1415
+ pageOrder: readPageOrder(pageRaw === null ? null : parseIslandJson(pageRaw, "page_order")),
1416
+ ...thumbnailSvg ? { thumbnailSvg } : {}
1417
+ };
1418
+ }
1419
+
1420
+ // src/design/css-scan.ts
1421
+ function stripCssComments(css) {
1422
+ let out = "";
1423
+ let i = 0;
1424
+ for (; ; ) {
1425
+ const start = css.indexOf("/*", i);
1426
+ if (start < 0) return out + css.slice(i);
1427
+ out += css.slice(i, start);
1428
+ const end = css.indexOf("*/", start + 2);
1429
+ if (end < 0) return out;
1430
+ i = end + 2;
1431
+ }
1432
+ }
1433
+ function styleSheetText(html) {
1434
+ const parts = [];
1435
+ let i = 0;
1436
+ for (; ; ) {
1437
+ const open = html.indexOf("<style", i);
1438
+ if (open < 0) break;
1439
+ const gt = html.indexOf(">", open);
1440
+ if (gt < 0) break;
1441
+ const close = html.indexOf("</style>", gt);
1442
+ if (close < 0) break;
1443
+ parts.push(html.slice(gt + 1, close));
1444
+ i = close + "</style>".length;
1445
+ }
1446
+ return parts.join("\n");
1447
+ }
1448
+ function scanCustomProperties(css) {
1449
+ const text = stripCssComments(css);
1450
+ const found = [];
1451
+ const stack = [];
1452
+ let paren = 0;
1453
+ let start = 0;
1454
+ const flush = (end) => {
1455
+ if (stack.length === 0) return;
1456
+ const chunk = text.slice(start, end).trim();
1457
+ if (!chunk.startsWith("--")) return;
1458
+ const colon = chunk.indexOf(":");
1459
+ if (colon < 0) return;
1460
+ const name = chunk.slice(0, colon).trim();
1461
+ if (name.length < 3) return;
1462
+ found.push({ selectors: [...stack], name, value: chunk.slice(colon + 1).trim() });
1463
+ };
1464
+ for (let i = 0; i < text.length; i++) {
1465
+ const ch = text[i];
1466
+ if (ch === "(") paren++;
1467
+ else if (ch === ")") paren = Math.max(0, paren - 1);
1468
+ if (paren !== 0) continue;
1469
+ if (ch === "{") {
1470
+ stack.push(text.slice(start, i).trim());
1471
+ start = i + 1;
1472
+ } else if (ch === "}") {
1473
+ flush(i);
1474
+ stack.pop();
1475
+ start = i + 1;
1476
+ } else if (ch === ";") {
1477
+ flush(i);
1478
+ start = i + 1;
1479
+ }
1480
+ }
1481
+ return found;
1482
+ }
1483
+
1256
1484
  // src/color/hex.ts
1257
1485
  function clamp01(x) {
1258
1486
  return Number.isNaN(x) ? 0 : x < 0 ? 0 : x > 1 ? 1 : x;
@@ -1763,69 +1991,6 @@ function derivePalette(seedHex, opts = {}) {
1763
1991
  return withNames ? swatches.map((s) => ({ ...s, name: nearestNamedColor(s.hex).name })) : swatches;
1764
1992
  }
1765
1993
 
1766
- // src/tokens/roles.ts
1767
- var BRAND_REQUIRED_TOKENS = [
1768
- "--ui-bg",
1769
- "--ui-surface",
1770
- "--ui-surface-2",
1771
- "--ui-text",
1772
- "--ui-text-muted",
1773
- "--ui-text-faint",
1774
- "--ui-border",
1775
- "--ui-border-strong",
1776
- "--ui-accent",
1777
- "--ui-accent-strong",
1778
- "--ui-accent-soft",
1779
- "--ui-on-accent",
1780
- "--ui-good",
1781
- "--ui-good-soft",
1782
- "--ui-warn",
1783
- "--ui-warn-soft",
1784
- "--ui-danger",
1785
- "--ui-danger-soft",
1786
- "--ui-code-bg",
1787
- "--ui-code-text",
1788
- "--ui-shadow",
1789
- "--ui-font-sans",
1790
- "--ui-font-serif",
1791
- "--ui-font-mono",
1792
- "--ui-font-display"
1793
- ];
1794
- var BRAND_DERIVED_TOKENS = [
1795
- "--ui-accent-glow",
1796
- "--ui-accent-2",
1797
- "--ui-accent-2-soft",
1798
- "--ui-highlight",
1799
- "--ui-focus",
1800
- "--ui-shadow-strong"
1801
- ];
1802
- var BRAND_CHART_TOKENS = [
1803
- "--ui-chart-1",
1804
- "--ui-chart-2",
1805
- "--ui-chart-3",
1806
- "--ui-chart-4",
1807
- "--ui-chart-5",
1808
- "--ui-chart-6",
1809
- "--ui-chart-band",
1810
- "--ui-chart-band-strong",
1811
- "--ui-chart-flow",
1812
- "--ui-chart-glow"
1813
- ];
1814
- var BRAND_CHAT_TOKENS = [
1815
- "--ui-chat-user-bg",
1816
- "--ui-chat-user-text",
1817
- "--ui-chat-assistant-bg",
1818
- "--ui-chat-thinking-bg",
1819
- "--ui-chat-thinking-text",
1820
- "--ui-chat-tool-accent"
1821
- ];
1822
- var BRAND_EMITTED_TOKENS = [
1823
- ...BRAND_REQUIRED_TOKENS,
1824
- ...BRAND_DERIVED_TOKENS,
1825
- ...BRAND_CHART_TOKENS,
1826
- ...BRAND_CHAT_TOKENS
1827
- ];
1828
-
1829
1994
  // src/tokens/map.ts
1830
1995
  var DEFAULT_ACCENT_SEED = "#3b5e8c";
1831
1996
  var CHROMATIC_C = 0.02;
@@ -2025,6 +2190,452 @@ function mapPaletteToTokens(input) {
2025
2190
  return { tokens, warnings };
2026
2191
  }
2027
2192
 
2193
+ // src/design/decompile.ts
2194
+ var CHART_TOKENS = [
2195
+ "--ui-chart-1",
2196
+ "--ui-chart-2",
2197
+ "--ui-chart-3",
2198
+ "--ui-chart-4",
2199
+ "--ui-chart-5",
2200
+ "--ui-chart-6"
2201
+ ];
2202
+ function cssColorToHex(value) {
2203
+ const text = value.trim();
2204
+ if (text.startsWith("#")) {
2205
+ try {
2206
+ return assertHex(text);
2207
+ } catch {
2208
+ return null;
2209
+ }
2210
+ }
2211
+ const fn = /^rgba?\(([^)]*)\)$/i.exec(text);
2212
+ if (!fn) return null;
2213
+ const parts = (fn[1] ?? "").split(/[,/\s]+/).filter((p) => p !== "");
2214
+ if (parts.length < 3 || parts.length > 4) return null;
2215
+ if (parts.length === 4) {
2216
+ const alpha = parts[3].endsWith("%") ? Number.parseFloat(parts[3]) / 100 : Number.parseFloat(parts[3]);
2217
+ if (!Number.isFinite(alpha) || alpha < 1) return null;
2218
+ }
2219
+ const channels = parts.slice(0, 3).map((part) => {
2220
+ const n = Number.parseFloat(part);
2221
+ if (!Number.isFinite(n)) return Number.NaN;
2222
+ return Math.round(part.endsWith("%") ? n / 100 * 255 : n);
2223
+ });
2224
+ if (channels.some((c) => !Number.isFinite(c) || c < 0 || c > 255)) return null;
2225
+ return `#${channels.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
2226
+ }
2227
+ var skipReason = (value) => value.includes("var(") ? "unresolved var() reference" : value.includes("color-mix(") ? "composed with color-mix()" : /rgba?\(/i.test(value) ? "not fully opaque" : "not a literal color";
2228
+ function swatchesFromDesignTokens(tokens) {
2229
+ const light = tokens.light;
2230
+ const swatches = [];
2231
+ const skipped = [];
2232
+ const missing2 = [];
2233
+ for (const [role, token] of Object.entries(ROLE_TOKEN)) {
2234
+ if (role === "chart") continue;
2235
+ const value = light[token];
2236
+ if (value === void 0) {
2237
+ missing2.push(role);
2238
+ continue;
2239
+ }
2240
+ const hex = cssColorToHex(value);
2241
+ if (hex === null) {
2242
+ skipped.push({ token, value, reason: skipReason(value) });
2243
+ continue;
2244
+ }
2245
+ swatches.push({ role, hex, rationale: `declared by the design as ${token}` });
2246
+ }
2247
+ let anyChart = false;
2248
+ for (const token of CHART_TOKENS) {
2249
+ const value = light[token];
2250
+ if (value === void 0) continue;
2251
+ const hex = cssColorToHex(value);
2252
+ if (hex === null) {
2253
+ skipped.push({ token, value, reason: skipReason(value) });
2254
+ continue;
2255
+ }
2256
+ anyChart = true;
2257
+ swatches.push({ role: "chart", hex, rationale: `declared by the design as ${token}` });
2258
+ }
2259
+ if (!anyChart) missing2.push("chart");
2260
+ return { swatches, skipped, missing: missing2 };
2261
+ }
2262
+
2263
+ // src/design/html-text.ts
2264
+ var NAMED_ENTITIES = {
2265
+ amp: "&",
2266
+ lt: "<",
2267
+ gt: ">",
2268
+ quot: '"',
2269
+ apos: "'",
2270
+ nbsp: "\xA0",
2271
+ mdash: "\u2014",
2272
+ ndash: "\u2013",
2273
+ hellip: "\u2026",
2274
+ rsquo: "\u2019",
2275
+ lsquo: "\u2018",
2276
+ ldquo: "\u201C",
2277
+ rdquo: "\u201D"
2278
+ };
2279
+ function decodeEntities(text) {
2280
+ return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body) => {
2281
+ if (body.startsWith("#x") || body.startsWith("#X")) {
2282
+ const code = Number.parseInt(body.slice(2), 16);
2283
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
2284
+ }
2285
+ if (body.startsWith("#")) {
2286
+ const code = Number.parseInt(body.slice(1), 10);
2287
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
2288
+ }
2289
+ return NAMED_ENTITIES[body.toLowerCase()] ?? whole;
2290
+ });
2291
+ }
2292
+ var collapseWhitespace = (text) => text.replace(/\s+/g, " ").trim();
2293
+ function htmlToText(fragment) {
2294
+ const withoutCode = fragment.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, " ");
2295
+ return collapseWhitespace(decodeEntities(withoutCode.replace(/<[^>]*>/g, " ")));
2296
+ }
2297
+
2298
+ // src/design/outline.ts
2299
+ var MAX_OUTLINE_ENTRIES = 120;
2300
+ var MAX_HEADING_CHARS = 200;
2301
+ function extractTitle(html) {
2302
+ const match = /<title\b[^>]*>([\s\S]{0,2000}?)<\/title\s*>/i.exec(html);
2303
+ if (!match) return void 0;
2304
+ const text = htmlToText(match[1] ?? "");
2305
+ return text === "" ? void 0 : text.slice(0, MAX_HEADING_CHARS);
2306
+ }
2307
+ function extractOutline(html) {
2308
+ const pattern = /<h([1-6])\b[^>]*>([\s\S]{0,4000}?)<\/h\1\s*>/gi;
2309
+ const entries = [];
2310
+ for (; ; ) {
2311
+ const match = pattern.exec(html);
2312
+ if (match === null) break;
2313
+ const text = htmlToText(match[2] ?? "");
2314
+ if (text === "") continue;
2315
+ if (entries.length >= MAX_OUTLINE_ENTRIES) return { entries, truncated: true };
2316
+ entries.push({ level: Number(match[1]), text: text.slice(0, MAX_HEADING_CHARS) });
2317
+ }
2318
+ return { entries, truncated: false };
2319
+ }
2320
+
2321
+ // src/design/props.ts
2322
+ var MAX_PROPS = 60;
2323
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2324
+ function attributeValue(html, attr) {
2325
+ const at = html.indexOf(`${attr}="`);
2326
+ if (at >= 0) {
2327
+ const from2 = at + attr.length + 2;
2328
+ const end2 = html.indexOf('"', from2);
2329
+ return end2 < 0 ? null : html.slice(from2, end2);
2330
+ }
2331
+ const single = html.indexOf(`${attr}='`);
2332
+ if (single < 0) return null;
2333
+ const from = single + attr.length + 2;
2334
+ const end = html.indexOf("'", from);
2335
+ return end < 0 ? null : html.slice(from, end);
2336
+ }
2337
+ function defaultText(value) {
2338
+ if (value === void 0 || value === null) return void 0;
2339
+ if (typeof value === "string") return value.slice(0, 400);
2340
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
2341
+ try {
2342
+ return JSON.stringify(value).slice(0, 400);
2343
+ } catch {
2344
+ return void 0;
2345
+ }
2346
+ }
2347
+ function toProp(name, spec) {
2348
+ const options = Array.isArray(spec.options) ? spec.options.filter((v) => typeof v === "string").slice(0, 24) : void 0;
2349
+ const declared = defaultText(spec.default);
2350
+ return {
2351
+ name: name.slice(0, 80),
2352
+ editor: typeof spec.editor === "string" ? spec.editor.slice(0, 40) : "unknown",
2353
+ ...options && options.length > 0 ? { options } : {},
2354
+ ...declared !== void 0 ? { default: declared } : {},
2355
+ ...typeof spec.section === "string" ? { section: spec.section.slice(0, 80) } : {},
2356
+ ...typeof spec.tsType === "string" ? { tsType: spec.tsType.slice(0, 200) } : {}
2357
+ };
2358
+ }
2359
+ function extractProps(html) {
2360
+ const at = html.indexOf("data-props=");
2361
+ if (at < 0) return { props: [], truncated: false };
2362
+ const raw = attributeValue(html.slice(at), "data-props");
2363
+ if (raw === null) return { props: [], truncated: false };
2364
+ let parsed;
2365
+ try {
2366
+ parsed = JSON.parse(decodeEntities(raw));
2367
+ } catch {
2368
+ return { props: [], truncated: false };
2369
+ }
2370
+ if (!isRecord3(parsed)) return { props: [], truncated: false };
2371
+ const entries = Object.entries(parsed).filter(
2372
+ (entry) => isRecord3(entry[1])
2373
+ );
2374
+ return {
2375
+ props: entries.slice(0, MAX_PROPS).map(([name, spec]) => toProp(name, spec)),
2376
+ truncated: entries.length > MAX_PROPS
2377
+ };
2378
+ }
2379
+
2380
+ // src/design/styles.ts
2381
+ var MAX_FONTS = 16;
2382
+ var MAX_COLORS = 24;
2383
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
2384
+ "serif",
2385
+ "sans-serif",
2386
+ "monospace",
2387
+ "cursive",
2388
+ "fantasy",
2389
+ "system-ui",
2390
+ "ui-serif",
2391
+ "ui-sans-serif",
2392
+ "ui-monospace",
2393
+ "ui-rounded",
2394
+ "math",
2395
+ "emoji",
2396
+ "inherit",
2397
+ "initial",
2398
+ "revert",
2399
+ "unset",
2400
+ "currentcolor"
2401
+ ]);
2402
+ var familyName = (raw) => raw.trim().replace(/^["']|["']$/g, "").trim();
2403
+ function fontFaceFamilies(css) {
2404
+ const seen = /* @__PURE__ */ new Set();
2405
+ const blocks = /@font-face\s*\{([^}]{0,4000})\}/gi;
2406
+ for (; ; ) {
2407
+ const block = blocks.exec(css);
2408
+ if (block === null) break;
2409
+ const declared = /font-family\s*:\s*([^;]{1,200})/i.exec(block[1] ?? "");
2410
+ if (!declared) continue;
2411
+ const name = familyName(declared[1] ?? "");
2412
+ if (name !== "" && !GENERIC_FAMILIES.has(name.toLowerCase())) seen.add(name);
2413
+ }
2414
+ return [...seen];
2415
+ }
2416
+ function stackFamilies(css) {
2417
+ const counts = /* @__PURE__ */ new Map();
2418
+ const stacks = /font-family\s*:\s*([^;{}]{1,400})/gi;
2419
+ for (; ; ) {
2420
+ const stack = stacks.exec(css);
2421
+ if (stack === null) break;
2422
+ for (const part of (stack[1] ?? "").split(",")) {
2423
+ if (!/["']/.test(part)) continue;
2424
+ const name = familyName(part);
2425
+ if (name === "" || GENERIC_FAMILIES.has(name.toLowerCase()) || name.includes("var(")) continue;
2426
+ counts.set(name, (counts.get(name) ?? 0) + 1);
2427
+ }
2428
+ }
2429
+ return [...counts.entries()].sort((a2, b) => b[1] - a2[1] || (a2[0] < b[0] ? -1 : 1)).map(([name]) => name);
2430
+ }
2431
+ function extractFonts(templateHtml) {
2432
+ const css = stripCssComments(styleSheetText(templateHtml));
2433
+ const embedded = fontFaceFamilies(css);
2434
+ return (embedded.length > 0 ? embedded : stackFamilies(css)).slice(0, MAX_FONTS);
2435
+ }
2436
+ function extractColors(templateHtml) {
2437
+ const css = stripCssComments(styleSheetText(templateHtml));
2438
+ const counts = /* @__PURE__ */ new Map();
2439
+ const hexes = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
2440
+ for (; ; ) {
2441
+ const found = hexes.exec(css);
2442
+ if (found === null) break;
2443
+ const raw = (found[1] ?? "").toLowerCase();
2444
+ const hex = raw.length === 3 ? `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}` : `#${raw}`;
2445
+ counts.set(hex, (counts.get(hex) ?? 0) + 1);
2446
+ }
2447
+ return [...counts.entries()].sort((a2, b) => b[1] - a2[1] || (a2[0] < b[0] ? -1 : 1)).slice(0, MAX_COLORS).map(([hex, count]) => ({ hex, count }));
2448
+ }
2449
+
2450
+ // src/design/tokens.ts
2451
+ var MAX_VAR_DEPTH = 8;
2452
+ var DOC_SELECTOR = /(^|,)\s*(:root|html)\b|\[data-theme\s*=|\.ui-invert\b/;
2453
+ var DARK_SELECTOR = /data-theme\s*=\s*["']?dark|prefers-color-scheme\s*:\s*dark|\.ui-invert\b/;
2454
+ function splitVarArgs(inner) {
2455
+ let paren = 0;
2456
+ for (let i = 0; i < inner.length; i++) {
2457
+ const ch = inner[i];
2458
+ if (ch === "(") paren++;
2459
+ else if (ch === ")") paren--;
2460
+ else if (ch === "," && paren === 0)
2461
+ return { name: inner.slice(0, i).trim(), fallback: inner.slice(i + 1).trim() };
2462
+ }
2463
+ return { name: inner.trim() };
2464
+ }
2465
+ function resolveVarRefs(value, table, depth = 0) {
2466
+ if (depth >= MAX_VAR_DEPTH || !value.includes("var(")) return value;
2467
+ let out = "";
2468
+ let i = 0;
2469
+ for (; ; ) {
2470
+ const at = value.indexOf("var(", i);
2471
+ if (at < 0) return out + value.slice(i);
2472
+ out += value.slice(i, at);
2473
+ let paren = 1;
2474
+ let j = at + "var(".length;
2475
+ for (; j < value.length && paren > 0; j++) {
2476
+ if (value[j] === "(") paren++;
2477
+ else if (value[j] === ")") paren--;
2478
+ }
2479
+ if (paren > 0) return out + value.slice(at);
2480
+ const { name, fallback } = splitVarArgs(value.slice(at + "var(".length, j - 1));
2481
+ const referenced = table.get(name);
2482
+ const replacement = referenced !== void 0 ? resolveVarRefs(referenced, table, depth + 1) : fallback !== void 0 ? resolveVarRefs(fallback, table, depth + 1) : `var(${name})`;
2483
+ out += replacement;
2484
+ i = j;
2485
+ }
2486
+ }
2487
+ function foldDeclarations(html) {
2488
+ const light = /* @__PURE__ */ new Map();
2489
+ const darkOverrides = [];
2490
+ for (const decl of scanCustomProperties(styleSheetText(html))) {
2491
+ const innermost = decl.selectors[decl.selectors.length - 1] ?? "";
2492
+ if (!DOC_SELECTOR.test(innermost)) continue;
2493
+ if (decl.selectors.some((sel) => DARK_SELECTOR.test(sel))) darkOverrides.push([decl.name, decl.value]);
2494
+ else light.set(decl.name, decl.value);
2495
+ }
2496
+ const dark = new Map(light);
2497
+ for (const [name, value] of darkOverrides) dark.set(name, value);
2498
+ return { light, dark };
2499
+ }
2500
+ function resolveUiTokens(table) {
2501
+ const out = {};
2502
+ for (const [name, value] of table) {
2503
+ if (!name.startsWith("--ui-")) continue;
2504
+ out[name] = resolveVarRefs(value, table);
2505
+ }
2506
+ return out;
2507
+ }
2508
+ function extractDesignTokens(templateHtml) {
2509
+ const { light, dark } = foldDeclarations(templateHtml);
2510
+ return { light: resolveUiTokens(light), dark: resolveUiTokens(dark) };
2511
+ }
2512
+
2513
+ // src/design/digest.ts
2514
+ var MAX_EXTERNALS = 24;
2515
+ function groupAssets(bundle) {
2516
+ const groups = /* @__PURE__ */ new Map();
2517
+ for (const asset of bundle.assets) {
2518
+ const group = groups.get(asset.mime) ?? { mime: asset.mime, count: 0, bytes: 0 };
2519
+ group.count += 1;
2520
+ group.bytes += asset.bytes;
2521
+ groups.set(asset.mime, group);
2522
+ }
2523
+ return [...groups.values()].sort((a2, b) => b.bytes - a2.bytes || (a2.mime < b.mime ? -1 : 1));
2524
+ }
2525
+ function digestDesignBundle(bundle) {
2526
+ const template = bundle.template;
2527
+ const outline = extractOutline(template);
2528
+ const props = extractProps(template);
2529
+ const assetGroups = groupAssets(bundle);
2530
+ const title = extractTitle(template);
2531
+ const truncated = [];
2532
+ if (outline.truncated) truncated.push("outline");
2533
+ if (props.truncated) truncated.push("props");
2534
+ if (bundle.externals.length > MAX_EXTERNALS) truncated.push("externals");
2535
+ return {
2536
+ format: "claude-design-bundle/1",
2537
+ ...title ? { title } : {},
2538
+ templateBytes: new TextEncoder().encode(template).byteLength,
2539
+ assetBytes: bundle.assets.reduce((total, asset) => total + asset.bytes, 0),
2540
+ assetCount: bundle.assets.length,
2541
+ assetGroups,
2542
+ externals: bundle.externals.slice(0, MAX_EXTERNALS),
2543
+ pageCount: bundle.pageOrder.length,
2544
+ tokens: extractDesignTokens(template),
2545
+ fonts: extractFonts(template),
2546
+ colors: extractColors(template),
2547
+ props: props.props,
2548
+ outline: outline.entries,
2549
+ ...bundle.thumbnailSvg ? { thumbnailSvg: bundle.thumbnailSvg } : {},
2550
+ truncated
2551
+ };
2552
+ }
2553
+ function digestDesignHtml(html) {
2554
+ return digestDesignBundle(parseDesignBundle(html));
2555
+ }
2556
+
2557
+ // src/palette-report.ts
2558
+ var round2 = (n) => Math.round(n * 100) / 100;
2559
+ function contrastReport(swatches) {
2560
+ const byRole = (role) => swatches.find((s) => s.role === role)?.hex;
2561
+ const bg = byRole("bg") ?? "#ffffff";
2562
+ const report = {};
2563
+ const put = (label, fg) => {
2564
+ if (fg) report[label] = round2(contrastRatio(fg, bg));
2565
+ };
2566
+ put("text-on-bg", byRole("text"));
2567
+ put("primary-on-bg", byRole("primary"));
2568
+ put("good-on-bg", byRole("good"));
2569
+ put("warn-on-bg", byRole("warn"));
2570
+ put("danger-on-bg", byRole("danger"));
2571
+ const primary = byRole("primary");
2572
+ if (primary) report["text-on-primary"] = round2(contrastRatio(pickTextOn(primary), primary));
2573
+ return report;
2574
+ }
2575
+
2576
+ // src/tokens/roles.ts
2577
+ var BRAND_REQUIRED_TOKENS = [
2578
+ "--ui-bg",
2579
+ "--ui-surface",
2580
+ "--ui-surface-2",
2581
+ "--ui-text",
2582
+ "--ui-text-muted",
2583
+ "--ui-text-faint",
2584
+ "--ui-border",
2585
+ "--ui-border-strong",
2586
+ "--ui-accent",
2587
+ "--ui-accent-strong",
2588
+ "--ui-accent-soft",
2589
+ "--ui-on-accent",
2590
+ "--ui-good",
2591
+ "--ui-good-soft",
2592
+ "--ui-warn",
2593
+ "--ui-warn-soft",
2594
+ "--ui-danger",
2595
+ "--ui-danger-soft",
2596
+ "--ui-code-bg",
2597
+ "--ui-code-text",
2598
+ "--ui-shadow",
2599
+ "--ui-font-sans",
2600
+ "--ui-font-serif",
2601
+ "--ui-font-mono",
2602
+ "--ui-font-display"
2603
+ ];
2604
+ var BRAND_DERIVED_TOKENS = [
2605
+ "--ui-accent-glow",
2606
+ "--ui-accent-2",
2607
+ "--ui-accent-2-soft",
2608
+ "--ui-highlight",
2609
+ "--ui-focus",
2610
+ "--ui-shadow-strong"
2611
+ ];
2612
+ var BRAND_CHART_TOKENS = [
2613
+ "--ui-chart-1",
2614
+ "--ui-chart-2",
2615
+ "--ui-chart-3",
2616
+ "--ui-chart-4",
2617
+ "--ui-chart-5",
2618
+ "--ui-chart-6",
2619
+ "--ui-chart-band",
2620
+ "--ui-chart-band-strong",
2621
+ "--ui-chart-flow",
2622
+ "--ui-chart-glow"
2623
+ ];
2624
+ var BRAND_CHAT_TOKENS = [
2625
+ "--ui-chat-user-bg",
2626
+ "--ui-chat-user-text",
2627
+ "--ui-chat-assistant-bg",
2628
+ "--ui-chat-thinking-bg",
2629
+ "--ui-chat-thinking-text",
2630
+ "--ui-chat-tool-accent"
2631
+ ];
2632
+ var BRAND_EMITTED_TOKENS = [
2633
+ ...BRAND_REQUIRED_TOKENS,
2634
+ ...BRAND_DERIVED_TOKENS,
2635
+ ...BRAND_CHART_TOKENS,
2636
+ ...BRAND_CHAT_TOKENS
2637
+ ];
2638
+
2028
2639
  // src/tokens/dark.ts
2029
2640
  var DARK_FLIP_L_MIN = 0.2;
2030
2641
  var DARK_FLIP_L_MAX = 0.95;
@@ -2182,10 +2793,10 @@ ${renderBlock(mediaSelector, dark, diffNames, " ")}
2182
2793
  }
2183
2794
 
2184
2795
  // src/tokens/compile.ts
2185
- var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2796
+ var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2186
2797
  function applyOverrides(light, overrides, warnings) {
2187
2798
  if (overrides === void 0) return;
2188
- if (!isRecord2(overrides)) {
2799
+ if (!isRecord4(overrides)) {
2189
2800
  warnings.push({ token: "--ui-accent", message: "overrides ignored: not an object" });
2190
2801
  return;
2191
2802
  }
@@ -2419,22 +3030,6 @@ function applyHarmony(swatches, seedHex, harmony) {
2419
3030
  return { ...s, hex, name: nearestNamedColor(hex).name, rationale: `${harmony} companion of the seed` };
2420
3031
  });
2421
3032
  }
2422
- function contrastReport(swatches) {
2423
- const byRole = (role) => swatches.find((s) => s.role === role)?.hex;
2424
- const bg = byRole("bg") ?? "#ffffff";
2425
- const report = {};
2426
- const put = (label, fg) => {
2427
- if (fg) report[label] = Math.round(contrastRatio(fg, bg) * 100) / 100;
2428
- };
2429
- put("text-on-bg", byRole("text"));
2430
- put("primary-on-bg", byRole("primary"));
2431
- put("good-on-bg", byRole("good"));
2432
- put("warn-on-bg", byRole("warn"));
2433
- put("danger-on-bg", byRole("danger"));
2434
- const primary = byRole("primary");
2435
- if (primary) report["text-on-primary"] = Math.round(contrastRatio(pickTextOn(primary), primary) * 100) / 100;
2436
- return report;
2437
- }
2438
3033
  var INCLUDE_SECTIONS = ["harmony", "ramp"];
2439
3034
  function analyzeLines(hex, include) {
2440
3035
  const lch = hexToOklch(hex);
@@ -2591,6 +3186,160 @@ function paletteTools(ctx) {
2591
3186
  return [analyzeColor, evaluateContrast, proposePalette];
2592
3187
  }
2593
3188
 
3189
+ // src/skill/design-tools.ts
3190
+ var MAX_SOURCE_WINDOW = 12e3;
3191
+ function describeDigest(asset, digest2) {
3192
+ const lines = [
3193
+ `Design ${asset.id}${digest2.title ? ` \u2014 "${digest2.title}"` : ""}${asset.title ? ` (uploaded as "${asset.title}")` : ""}`,
3194
+ `Template ${digest2.templateBytes} bytes; ${digest2.assetCount} embedded assets (${digest2.assetBytes} bytes); ${digest2.pageCount} nested pages.`
3195
+ ];
3196
+ if (digest2.assetGroups.length > 0)
3197
+ lines.push(
3198
+ `Assets: ${digest2.assetGroups.map((g) => `${g.count}\xD7 ${g.mime} (${g.bytes} B)`).join(", ")}`
3199
+ );
3200
+ if (digest2.externals.length > 0) lines.push(`Built against: ${digest2.externals.join(", ")}`);
3201
+ if (digest2.fonts.length > 0) lines.push(`Typefaces: ${digest2.fonts.join(", ")}`);
3202
+ const tokenNames = Object.keys(digest2.tokens.light);
3203
+ lines.push(
3204
+ tokenNames.length > 0 ? `Declares ${tokenNames.length} --ui-* tokens (light) and ${Object.keys(digest2.tokens.dark).length} (dark). Key values: ${["--ui-bg", "--ui-text", "--ui-accent", "--ui-accent-strong", "--ui-surface"].filter((n) => digest2.tokens.light[n]).map((n) => `${n}=${digest2.tokens.light[n]}`).join(", ")}` : "Declares no --ui-* tokens; it was not authored on the @odla-ai/ui contract."
3205
+ );
3206
+ if (digest2.colors.length > 0)
3207
+ lines.push(`Literal colors: ${digest2.colors.map((c) => `${c.hex}\xD7${c.count}`).join(", ")}`);
3208
+ if (digest2.props.length > 0)
3209
+ lines.push(
3210
+ "Configurable props:\n" + digest2.props.map(
3211
+ (p) => ` ${p.name} (${p.editor}${p.options ? `: ${p.options.join("|")}` : ""})${p.default === void 0 ? "" : ` default ${p.default}`}${p.section ? ` [${p.section}]` : ""}`
3212
+ ).join("\n")
3213
+ );
3214
+ if (digest2.outline.length > 0)
3215
+ lines.push(
3216
+ "Outline:\n" + digest2.outline.map((h) => `${" ".repeat(h.level - 1)}h${h.level} ${h.text}`).join("\n")
3217
+ );
3218
+ if (digest2.truncated.length > 0)
3219
+ lines.push(`NOTE: truncated to fit digest caps: ${digest2.truncated.join(", ")}.`);
3220
+ return lines.join("\n");
3221
+ }
3222
+ function sourceWindow(template, input) {
3223
+ const length = Math.min(
3224
+ MAX_SOURCE_WINDOW,
3225
+ Math.max(1, typeof input.length === "number" ? input.length : MAX_SOURCE_WINDOW)
3226
+ );
3227
+ let start;
3228
+ if (input.find !== void 0 && input.find !== "") {
3229
+ const at = template.indexOf(input.find);
3230
+ if (at < 0) throw new BrandNotFoundError(`"${input.find}" in the design source`);
3231
+ start = Math.max(0, at - 400);
3232
+ } else {
3233
+ start = Math.max(0, Math.min(template.length, Math.trunc(input.offset ?? 0)));
3234
+ }
3235
+ const end = Math.min(template.length, start + length);
3236
+ return { text: template.slice(start, end), start, end };
3237
+ }
3238
+ function designTools(ctx) {
3239
+ const loadDesign = async (assetId) => {
3240
+ await ctx.authority("brand.read");
3241
+ const res = await ctx.db.query({
3242
+ [BRAND_NS.asset]: { $: { where: { id: assetId, bookId: ctx.bookId, status: "live" } } }
3243
+ });
3244
+ const row = (res[BRAND_NS.asset] ?? [])[0];
3245
+ if (!row || row.kind !== DESIGN_ASSET_KIND || !row.design)
3246
+ throw new BrandNotFoundError(`design asset ${assetId}`);
3247
+ return row;
3248
+ };
3249
+ const readDesign = {
3250
+ name: "read_design",
3251
+ description: "Read an uploaded Claude Design: its design tokens, typefaces, colors, configurable props, and heading outline. Start here before building anything from a design \u2014 it is the whole design at a size you can reason about.",
3252
+ inputSchema: {
3253
+ type: "object",
3254
+ required: ["assetId"],
3255
+ properties: {
3256
+ assetId: { type: "string", description: "A design asset id from list_assets." }
3257
+ }
3258
+ },
3259
+ handler: ctx.guard(async (input) => {
3260
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
3261
+ return { content: describeDigest(asset, asset.design) };
3262
+ })
3263
+ };
3264
+ const readDesignSource = {
3265
+ name: "read_design_source",
3266
+ description: "Read a window of a design's actual HTML source, to port exact markup or styles. Pass `find` to jump to the first occurrence of a string (a heading, a class name), or `offset` to page through. Returns at most 12000 characters.",
3267
+ // The template is author-supplied content, not instructions.
3268
+ outputTaint: ["tool_untrusted:read_design_source"],
3269
+ inputSchema: {
3270
+ type: "object",
3271
+ required: ["assetId"],
3272
+ properties: {
3273
+ assetId: { type: "string" },
3274
+ find: { type: "string", description: "Jump to the first occurrence of this string." },
3275
+ offset: { type: "number", description: "Character offset to read from (ignored with find)." },
3276
+ length: { type: "number", description: `Characters to return (max ${MAX_SOURCE_WINDOW}).` }
3277
+ }
3278
+ },
3279
+ handler: ctx.guard(async (input) => {
3280
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
3281
+ const template = await ctx.readDesignTemplate(asset.id);
3282
+ const found = sourceWindow(template, {
3283
+ ...typeof input.find === "string" ? { find: input.find } : {},
3284
+ ...typeof input.offset === "number" ? { offset: input.offset } : {},
3285
+ ...typeof input.length === "number" ? { length: input.length } : {}
3286
+ });
3287
+ return {
3288
+ content: `Design ${asset.id} source, characters ${found.start}\u2013${found.end} of ${template.length}:
3289
+ ` + found.text
3290
+ };
3291
+ })
3292
+ };
3293
+ const proposeFromDesign = {
3294
+ name: "propose_palette_from_design",
3295
+ description: "Read a design's own --ui-* token declarations back into a brand palette and park it as a proposal for human review. Use when a design already carries the brand's colors and they should become the brand book's palette. Does NOT change the brand.",
3296
+ acceptsTaint: ["tool_untrusted:read_design_source"],
3297
+ inputSchema: {
3298
+ type: "object",
3299
+ required: ["assetId", "rationale"],
3300
+ properties: {
3301
+ assetId: { type: "string" },
3302
+ name: { type: "string", description: "Palette name; defaults to the design's title." },
3303
+ rationale: { type: "string", description: "Why this design's palette should become the brand's." }
3304
+ }
3305
+ },
3306
+ handler: ctx.guard(async (input) => {
3307
+ const book = await ctx.loadBook();
3308
+ await ctx.authority("brand.edit");
3309
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
3310
+ const digest2 = asset.design;
3311
+ const rationale = capString(input.rationale, "rationale", 2e3);
3312
+ const name = capString(
3313
+ input.name ?? digest2.title ?? asset.title ?? "Design palette",
3314
+ "name",
3315
+ 120
3316
+ );
3317
+ const { swatches, skipped, missing: missing2 } = swatchesFromDesignTokens(digest2.tokens);
3318
+ if (swatches.length === 0)
3319
+ throw new BrandInputError(
3320
+ `design ${asset.id} declares no --ui-* tokens that resolve to opaque colors; propose a palette explicitly instead.`
3321
+ );
3322
+ const report = contrastReport(swatches);
3323
+ const proposal = await ctx.createProposal({
3324
+ mutationId: ctx.newId(),
3325
+ kind: "palette",
3326
+ payload: { name, swatches, contrastReport: report, source: "design", designAssetId: asset.id },
3327
+ rationale,
3328
+ sourceAssetId: asset.id
3329
+ });
3330
+ if (proposal.bookId !== book.id || proposal.createdBy !== ctx.self.selfId || proposal.kind !== "palette" || proposal.status !== "open") throw new BrandInputError("brand proposal bridge returned an invalid proposal");
3331
+ const notes = [
3332
+ skipped.length > 0 ? `Not imported (not literal opaque colors): ${skipped.map((s) => `${s.token} \u2014 ${s.reason}`).join("; ")}.` : "",
3333
+ missing2.length > 0 ? `Roles the design declares no token for: ${missing2.join(", ")}.` : ""
3334
+ ].filter((n) => n !== "");
3335
+ return {
3336
+ content: `Parked palette proposal ${proposal.id} ("${name}") from design ${asset.id}: ${swatches.length} swatches read back from its --ui-* declarations. Contrast: ${Object.entries(report).map(([k, v]) => `${k} ${v.toFixed(2)}:1`).join(", ")}. ` + `${notes.join(" ")} Awaiting a human decision in the brand approval surface.`.trim()
3337
+ };
3338
+ })
3339
+ };
3340
+ return [readDesign, readDesignSource, proposeFromDesign];
3341
+ }
3342
+
2594
3343
  // src/skill/read-tools.ts
2595
3344
  var iso = (ms) => new Date(ms).toISOString();
2596
3345
  var shortId = (id) => id.length <= 12 ? id : `${id.slice(0, 6)}\u2026${id.slice(-4)}`;
@@ -2706,10 +3455,11 @@ function readTools(ctx) {
2706
3455
  }
2707
3456
 
2708
3457
  // src/skill/skill.ts
2709
- var BRAND_INSTRUCTIONS = "You help build and maintain ONE brand book. Workflow, in order:\n1. Understand the brand first: read_brand_book and list_assets before proposing anything.\n2. Look at the real material: view_asset on logos and inspiration, then record what you saw with record_asset_analysis (description, dominant colors as hex, tags).\n3. Explore with the math tools: analyze_color and evaluate_contrast. Never invent a hex without a rationale \u2014 ground every color in an asset's dominant color, a harmony companion, or a contrast fix, and say which.\n4. propose_palette parks a proposal for review. It does NOT change the brand.\n5. You cannot approve or resolve a proposal. Direct the human to the brand approval surface, which records a guarded receipt for their decision.\n6. update_section parks a proposal; it never overwrites an approved facet. Bind sourceAssetId when a real Brand asset grounds typography, voice, logo, or imagery. Human acceptance applies the exact reviewed change and automatically recompiles dependent tokens.\n7. Re-read the book after a decision and explain any compiler warnings conversationally.";
3458
+ var BRAND_INSTRUCTIONS = "You help build and maintain ONE brand book. Workflow, in order:\n1. Understand the brand first: read_brand_book and list_assets before proposing anything.\n2. Look at the real material: view_asset on logos and inspiration, then record what you saw with record_asset_analysis (description, dominant colors as hex, tags).\n3. Explore with the math tools: analyze_color and evaluate_contrast. Never invent a hex without a rationale \u2014 ground every color in an asset's dominant color, a harmony companion, or a contrast fix, and say which.\n4. When a Claude Design has been uploaded, read_design is the fastest way to learn the brand: it reports the design tokens, typefaces, props, and page outline. read_design_source reads exact markup when you need it. If the design already carries the brand colors, propose_palette_from_design reads its --ui-* declarations back into a palette proposal.\n5. propose_palette parks a proposal for review. It does NOT change the brand.\n6. You cannot approve or resolve a proposal. Direct the human to the brand approval surface, which records a guarded receipt for their decision.\n7. update_section parks a proposal; it never overwrites an approved facet. Bind sourceAssetId when a real Brand asset grounds typography, voice, logo, or imagery. Human acceptance applies the exact reviewed change and automatically recompiles dependent tokens.\n8. Re-read the book after a decision and explain any compiler warnings conversationally.";
2710
3459
  function brandSkill(opts) {
2711
3460
  if (opts.agentDbBinding.principalId !== opts.self.selfId || !opts.agentDbBinding.credentialRef) throw new BrandForbiddenError("brand db is not bound to the acting agent");
2712
3461
  const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId });
3462
+ const designTemplates = /* @__PURE__ */ new Map();
2713
3463
  const authority = (capability) => Promise.resolve(opts.authorizeCapability({
2714
3464
  agentId: opts.self.selfId,
2715
3465
  bookId: opts.bookId,
@@ -2742,6 +3492,16 @@ function brandSkill(opts) {
2742
3492
  bookId: opts.bookId,
2743
3493
  assetId
2744
3494
  }),
3495
+ readDesignTemplate: (assetId) => {
3496
+ const cached = designTemplates.get(assetId);
3497
+ if (cached) return cached;
3498
+ const pending = opts.agentBridge.readAssetContent({ jobId: opts.agentJobId, bookId: opts.bookId, assetId }).then(
3499
+ (fetched) => parseDesignBundle(new TextDecoder().decode(fetched.bytes)).template
3500
+ );
3501
+ pending.catch(() => designTemplates.delete(assetId));
3502
+ designTemplates.set(assetId, pending);
3503
+ return pending;
3504
+ },
2745
3505
  resolvePrincipals: (principalIds) => {
2746
3506
  const ids = [...new Set(principalIds)].slice(0, 100);
2747
3507
  return opts.resolvePrincipals({
@@ -2773,7 +3533,13 @@ function brandSkill(opts) {
2773
3533
  return {
2774
3534
  name: "brand",
2775
3535
  instructions: BRAND_INSTRUCTIONS,
2776
- tools: [...readTools(ctx), ...assetTools(ctx), ...paletteTools(ctx), ...bookTools(ctx)]
3536
+ tools: [
3537
+ ...readTools(ctx),
3538
+ ...assetTools(ctx),
3539
+ ...designTools(ctx),
3540
+ ...paletteTools(ctx),
3541
+ ...bookTools(ctx)
3542
+ ]
2777
3543
  };
2778
3544
  }
2779
3545
 
@@ -2904,9 +3670,13 @@ async function uploadAsset(ctx, req, bookId) {
2904
3670
  const file = form.get("file");
2905
3671
  if (!(file instanceof File))
2906
3672
  throw new BrandInputError('"file" must be an uploaded file field');
3673
+ const kindRaw = form.get("kind");
3674
+ const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
3675
+ if (!ASSET_KINDS.includes(kind))
3676
+ throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
2907
3677
  let contentType;
2908
3678
  try {
2909
- contentType = assertAssetContentType(file.type);
3679
+ contentType = assertAssetContentType(file.type, kind);
2910
3680
  } catch (error) {
2911
3681
  if (error instanceof BrandInputError)
2912
3682
  return json({ error: error.message }, 415);
@@ -2914,10 +3684,7 @@ async function uploadAsset(ctx, req, bookId) {
2914
3684
  }
2915
3685
  if (file.size > ctx.maxUploadBytes)
2916
3686
  return json({ error: `file exceeds ${ctx.maxUploadBytes} bytes` }, 413);
2917
- const kindRaw = form.get("kind");
2918
- const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
2919
- if (!ASSET_KINDS.includes(kind))
2920
- throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
3687
+ const design = kind === DESIGN_ASSET_KIND ? digestDesignHtml(await file.text()) : void 0;
2921
3688
  const titleRaw = form.get("title");
2922
3689
  const title = typeof titleRaw === "string" && titleRaw ? capString(titleRaw, "title", 160) : void 0;
2923
3690
  const fileName = safeFileName(file.name);
@@ -2965,6 +3732,7 @@ async function uploadAsset(ctx, req, bookId) {
2965
3732
  uploadedAuthorityRef: authority.authorityRef,
2966
3733
  audience: book.memberIds,
2967
3734
  title,
3735
+ ...design ? { design } : {},
2968
3736
  now: ctx.now()
2969
3737
  }), {
2970
3738
  mutationId: `brand:asset-upload:v1:${id}`,
@@ -3206,6 +3974,51 @@ async function handleBookItem(ctx, req, bookId) {
3206
3974
  return json(await loadMemberBook(ctx.db, bookId, ctx.actor.id));
3207
3975
  }
3208
3976
 
3977
+ // src/routes/design-preview.ts
3978
+ var SIGNED_READ_TTL_SECONDS = 60;
3979
+ function designPreviewHeaders(frameAncestors) {
3980
+ return {
3981
+ "content-type": "text/html; charset=utf-8",
3982
+ "content-security-policy": [
3983
+ "sandbox allow-scripts",
3984
+ `frame-ancestors ${frameAncestors.join(" ")}`
3985
+ ].join("; "),
3986
+ "x-content-type-options": "nosniff",
3987
+ "referrer-policy": "no-referrer",
3988
+ "cache-control": "private, no-store"
3989
+ };
3990
+ }
3991
+ async function handleDesignPreview(ctx, req, bookId, assetId) {
3992
+ if (req.method !== "GET") return methodNotAllowed();
3993
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
3994
+ const asset = await linkedAsset(ctx, book, assetId);
3995
+ if (asset.kind !== DESIGN_ASSET_KIND) throw new BrandNotFoundError(`design ${assetId}`);
3996
+ const signed = await ctx.db.storage.sign(asset.path, SIGNED_READ_TTL_SECONDS);
3997
+ const fetched = await ctx.fetchPrivateAsset(signed, {
3998
+ headers: { accept: "text/html" },
3999
+ redirect: "manual",
4000
+ signal: req.signal
4001
+ });
4002
+ if (!fetched.ok || fetched.type === "opaqueredirect")
4003
+ throw new BrandNotFoundError(`design ${assetId}`);
4004
+ return new Response(new Uint8Array(await fetched.arrayBuffer()), {
4005
+ status: 200,
4006
+ headers: designPreviewHeaders(ctx.previewFrameAncestors)
4007
+ });
4008
+ }
4009
+ async function handleDesignDigest(ctx, req, bookId, assetId) {
4010
+ if (req.method !== "GET") return methodNotAllowed();
4011
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
4012
+ const asset = await linkedAsset(ctx, book, assetId);
4013
+ if (asset.kind !== DESIGN_ASSET_KIND || !asset.design)
4014
+ throw new BrandNotFoundError(`design ${assetId}`);
4015
+ return json(
4016
+ { assetId: asset.id, title: asset.title, digest: asset.design },
4017
+ 200,
4018
+ { "cache-control": "private, no-store" }
4019
+ );
4020
+ }
4021
+
3209
4022
  // src/routes/proposal-input.ts
3210
4023
  var SNAPSHOT_KEYS = [
3211
4024
  "audience",
@@ -3426,13 +4239,13 @@ async function proposalDecisionOps(input) {
3426
4239
 
3427
4240
  // src/routes/proposal-resolution-receipts.ts
3428
4241
  async function proposalResolutionMutationKey(bookId, proposalId, mutationId) {
3429
- const digest = await brandJsonDigest({
4242
+ const digest2 = await brandJsonDigest({
3430
4243
  version: 3,
3431
4244
  bookId,
3432
4245
  proposalId,
3433
4246
  mutationId
3434
4247
  });
3435
- return `brand:proposal-resolution:v3:${digest.slice("sha256:".length)}`;
4248
+ return `brand:proposal-resolution:v3:${digest2.slice("sha256:".length)}`;
3436
4249
  }
3437
4250
  async function proposalReceiptByMutation(ctx, mutationKey) {
3438
4251
  const result = await ctx.db.query({
@@ -3982,6 +4795,130 @@ async function handleTokens(db, req, bookId, which, actor) {
3982
4795
  });
3983
4796
  }
3984
4797
 
4798
+ // src/routes/discussion-asset.ts
4799
+ var MAX_DISCUSSION_ASSET_BYTES = 4718592;
4800
+ var SIGN_TTL_SECONDS = 30;
4801
+ var TYPES = /* @__PURE__ */ new Set([
4802
+ "image/png",
4803
+ "image/jpeg",
4804
+ "image/gif",
4805
+ "image/webp",
4806
+ "application/pdf"
4807
+ ]);
4808
+ var bare = (value) => (value?.split(";", 1)[0] ?? "").trim().toLowerCase();
4809
+ var starts = (bytes, expected) => expected.every((value, index) => bytes[index] === value);
4810
+ function magicMatches(contentType, bytes) {
4811
+ if (contentType === "image/png") {
4812
+ return starts(bytes, [137, 80, 78, 71, 13, 10, 26, 10]);
4813
+ }
4814
+ if (contentType === "image/jpeg") {
4815
+ return starts(bytes, [255, 216, 255]);
4816
+ }
4817
+ const prefix = new TextDecoder().decode(bytes.subarray(0, 16));
4818
+ if (contentType === "image/gif") {
4819
+ return prefix.startsWith("GIF87a") || prefix.startsWith("GIF89a");
4820
+ }
4821
+ if (contentType === "image/webp") {
4822
+ return prefix.startsWith("RIFF") && prefix.slice(8, 12) === "WEBP";
4823
+ }
4824
+ return contentType === "application/pdf" && prefix.startsWith("%PDF-");
4825
+ }
4826
+ async function boundedBytes(response, max) {
4827
+ const length = Number(response.headers.get("content-length"));
4828
+ if (Number.isFinite(length) && length > max) return null;
4829
+ if (!response.body) return new Uint8Array();
4830
+ const reader = response.body.getReader();
4831
+ const chunks = [];
4832
+ let total = 0;
4833
+ while (true) {
4834
+ const next = await reader.read();
4835
+ if (next.done) break;
4836
+ total += next.value.byteLength;
4837
+ if (total > max) {
4838
+ await reader.cancel();
4839
+ return null;
4840
+ }
4841
+ chunks.push(next.value);
4842
+ }
4843
+ const out = new Uint8Array(total);
4844
+ let offset = 0;
4845
+ for (const chunk of chunks) {
4846
+ out.set(chunk, offset);
4847
+ offset += chunk.byteLength;
4848
+ }
4849
+ return out;
4850
+ }
4851
+ async function digest(bytes) {
4852
+ const value = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
4853
+ return `sha256:${[...new Uint8Array(value)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
4854
+ }
4855
+ function exactAsset(beforeBook, before, afterBook, after) {
4856
+ return afterBook.id === beforeBook.id && after.id === before.id && after.bookId === before.bookId && after.status === "live" && after.path === before.path && after.storageObjectId === before.storageObjectId && after.contentDigest === before.contentDigest && after.contentType === before.contentType && after.size === before.size;
4857
+ }
4858
+ function safeSignedUrl(value) {
4859
+ try {
4860
+ const url = new URL(value);
4861
+ return value.length <= 4096 && url.protocol === "https:" && !url.username && !url.password ? url : null;
4862
+ } catch {
4863
+ return null;
4864
+ }
4865
+ }
4866
+ async function handleBrandDiscussionAsset(ctx, req, target) {
4867
+ const beforeBook = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id);
4868
+ const before = await linkedAsset(ctx, beforeBook, target.resourceId);
4869
+ const contentType = bare(before.contentType);
4870
+ if (!TYPES.has(contentType)) {
4871
+ return json({ error: "asset content type is not viewable" }, 415);
4872
+ }
4873
+ if (before.size < 1 || before.size > MAX_DISCUSSION_ASSET_BYTES) {
4874
+ return json({ error: "asset exceeds the discussion media limit" }, 413);
4875
+ }
4876
+ const signed = safeSignedUrl(
4877
+ await ctx.db.storage.sign(before.path, SIGN_TTL_SECONDS)
4878
+ );
4879
+ if (!signed) throw new BrandInputError("private asset signing failed");
4880
+ const response = await ctx.fetchPrivateAsset(signed, {
4881
+ headers: { accept: contentType },
4882
+ redirect: "manual",
4883
+ signal: req.signal
4884
+ });
4885
+ if (!response.ok || response.type === "opaqueredirect") {
4886
+ throw new BrandNotFoundError(`asset ${before.id}`);
4887
+ }
4888
+ const served = bare(response.headers.get("content-type"));
4889
+ if (served === "image/svg+xml" || served && served !== "application/octet-stream" && served !== contentType) {
4890
+ return json({ error: "asset content type is inconsistent" }, 415);
4891
+ }
4892
+ const bytes = await boundedBytes(response, MAX_DISCUSSION_ASSET_BYTES);
4893
+ if (!bytes || bytes.byteLength !== before.size || !magicMatches(contentType, bytes) || await digest(bytes) !== before.contentDigest) {
4894
+ return json({ error: "asset bytes failed validation" }, 422);
4895
+ }
4896
+ const afterBook = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id);
4897
+ const after = await linkedAsset(ctx, afterBook, target.resourceId);
4898
+ if (!exactAsset(beforeBook, before, afterBook, after)) {
4899
+ return json({ error: "asset changed during inspection" }, 409);
4900
+ }
4901
+ return json({
4902
+ asset: {
4903
+ reference: { kind: "brand:asset", id: `${before.bookId}/${before.id}` },
4904
+ contentType,
4905
+ byteLength: bytes.byteLength,
4906
+ data: base64(bytes),
4907
+ taint: "untrusted_project_material"
4908
+ }
4909
+ }, 200, {
4910
+ "cache-control": "private, no-store",
4911
+ "x-content-type-options": "nosniff"
4912
+ });
4913
+ }
4914
+ function base64(bytes) {
4915
+ let binary = "";
4916
+ for (let index = 0; index < bytes.length; index += 8192) {
4917
+ binary += String.fromCharCode(...bytes.subarray(index, index + 8192));
4918
+ }
4919
+ return btoa(binary);
4920
+ }
4921
+
3985
4922
  // src/routes/discussion-references.ts
3986
4923
  var capLimit = (raw) => {
3987
4924
  const parsed = Number(raw ?? 20);
@@ -4013,6 +4950,7 @@ var projection = (req, ctx, book, target, row) => {
4013
4950
  let summary = `${book.name} brand book`;
4014
4951
  let status = book.status;
4015
4952
  let destination = "Brand Studio \xB7 Brand book";
4953
+ let swatches;
4016
4954
  if (target.kind === "brand:asset") {
4017
4955
  const asset = row;
4018
4956
  label = asset.title?.trim() || `${asset.kind} asset`;
@@ -4022,6 +4960,11 @@ var projection = (req, ctx, book, target, row) => {
4022
4960
  destination = "Brand Studio \xB7 Conversation";
4023
4961
  } else if (target.kind === "brand:palette") {
4024
4962
  const palette = row;
4963
+ swatches = assertSwatches(palette.swatches).map((swatch) => ({
4964
+ role: swatch.role,
4965
+ hex: swatch.hex,
4966
+ ...swatch.name ? { name: swatch.name } : {}
4967
+ }));
4025
4968
  label = palette.name;
4026
4969
  hint = `${book.name} \xB7 ${palette.status} palette`;
4027
4970
  summary = `${palette.swatches.length}-color palette in ${book.name}`;
@@ -4054,7 +4997,8 @@ var projection = (req, ctx, book, target, row) => {
4054
4997
  new URL(req.url),
4055
4998
  target,
4056
4999
  ctx.discussionBasePath
4057
- )
5000
+ ),
5001
+ ...swatches ? { swatches } : {}
4058
5002
  };
4059
5003
  };
4060
5004
  async function exact(ctx, req, target) {
@@ -4108,11 +5052,23 @@ async function search(ctx, req, url) {
4108
5052
  }
4109
5053
  async function handleBrandDiscussionReferences(ctx, req, url) {
4110
5054
  if (req.method !== "GET") return methodNotAllowed();
5055
+ const inspect = url.searchParams.get("inspect");
4111
5056
  const kind = url.searchParams.get("kind");
4112
5057
  const id = url.searchParams.get("id");
4113
5058
  if (kind && !id || !kind && id) return json({ error: "kind and id must be paired" }, 400);
4114
5059
  const target = targetFromQuery(url);
4115
5060
  if ((kind || id) && !target) return json({ items: [] });
5061
+ if (inspect === "asset-content") {
5062
+ const allowed = /* @__PURE__ */ new Set(["inspect", "kind", "id"]);
5063
+ if ([...url.searchParams.keys()].some((key) => !allowed.has(key)) || target?.kind !== "brand:asset" || !target.resourceId) return json({ error: "exact brand asset kind and id required" }, 400);
5064
+ return handleBrandDiscussionAsset(ctx, req, {
5065
+ bookId: target.bookId,
5066
+ resourceId: target.resourceId
5067
+ });
5068
+ }
5069
+ if (inspect !== null && inspect !== "1") {
5070
+ return json({ error: "invalid inspection mode" }, 400);
5071
+ }
4116
5072
  return json({
4117
5073
  items: target ? await exact(ctx, req, target) : await search(ctx, req, url)
4118
5074
  });
@@ -4133,6 +5089,10 @@ async function route(ctx, req, url, seg) {
4133
5089
  if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
4134
5090
  if (seg.length === 5 && action === "content")
4135
5091
  return handleAssetContent(ctx, req, id, subId);
5092
+ if (seg.length === 5 && action === "preview")
5093
+ return handleDesignPreview(ctx, req, id, subId);
5094
+ if (seg.length === 5 && action === "design")
5095
+ return handleDesignDigest(ctx, req, id, subId);
4136
5096
  return null;
4137
5097
  }
4138
5098
  if (sub === "proposals" && seg.length === 5 && action === "resolve")
@@ -4153,7 +5113,9 @@ function createBrandRoutes(options) {
4153
5113
  now: options.now ?? Date.now,
4154
5114
  newId: options.newId ?? (() => crypto.randomUUID()),
4155
5115
  maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
4156
- discussionBasePath: options.discussionBasePath ?? "/"
5116
+ discussionBasePath: options.discussionBasePath ?? "/",
5117
+ fetchPrivateAsset: options.fetchPrivateAsset ?? fetch,
5118
+ previewFrameAncestors: options.previewFrameAncestors ?? ["'self'"]
4157
5119
  };
4158
5120
  return async (req) => {
4159
5121
  const url = new URL(req.url);