@odla-ai/brand 0.4.0 → 0.6.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,35 @@ __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,
156
+ proposalAsWritten: () => proposalAsWritten,
128
157
  proposalReviewSnapshot: () => proposalReviewSnapshot,
129
158
  proposePaletteOps: () => proposePaletteOps,
130
159
  proposeSectionOps: () => proposeSectionOps,
160
+ readDesignManifest: () => readDesignManifest,
131
161
  readTools: () => readTools,
162
+ receiptAsWritten: () => receiptAsWritten,
132
163
  recordAnalysisOps: () => recordAnalysisOps,
133
164
  rejectProposalOps: () => rejectProposalOps,
134
165
  relativeLuminance: () => relativeLuminance,
135
166
  renderTokensCss: () => renderTokensCss,
136
167
  resolveDeps: () => resolveDeps,
168
+ resolveVarRefs: () => resolveVarRefs,
137
169
  rgbToHsl: () => rgbToHsl,
138
170
  rgbToOklab: () => rgbToOklab,
139
171
  rotateHue: () => rotateHue,
172
+ rowAsWritten: () => rowAsWritten,
140
173
  safeFileName: () => safeFileName,
174
+ scanCustomProperties: () => scanCustomProperties,
141
175
  sectionKey: () => sectionKey,
142
176
  splitComplementary: () => splitComplementary,
143
177
  srgbToLinear: () => srgbToLinear,
178
+ stripCssComments: () => stripCssComments,
179
+ styleSheetText: () => styleSheetText,
144
180
  supportsBrandVision: () => supportsBrandVision,
181
+ swatchesFromDesignTokens: () => swatchesFromDesignTokens,
145
182
  tetradic: () => tetradic,
146
183
  tintShadeRamp: () => tintShadeRamp,
147
184
  toHex: () => toHex,
@@ -168,7 +205,15 @@ var PALETTE_STATUSES = ["active", "archived"];
168
205
  var PALETTE_SOURCES = ["extracted", "derived", "manual"];
169
206
  var PROPOSAL_KINDS = SECTION_KINDS;
170
207
  var PROPOSAL_STATUSES = ["open", "accepted", "rejected", "superseded"];
171
- var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "other"];
208
+ var ASSET_KINDS = [
209
+ "logo",
210
+ "wordmark",
211
+ "inspiration",
212
+ "document",
213
+ "design",
214
+ "other"
215
+ ];
216
+ var DESIGN_ASSET_KIND = "design";
172
217
  var SWATCH_ROLES = [
173
218
  "primary",
174
219
  "secondary",
@@ -392,7 +437,7 @@ var BRAND_SCHEMA = {
392
437
  id: uniq("string"),
393
438
  bookId: idx("string"),
394
439
  kind: idx("string"),
395
- // logo | wordmark | inspiration | document | other
440
+ // logo | wordmark | inspiration | document | design | other
396
441
  path: idx("string"),
397
442
  storageObjectId: idx("string"),
398
443
  contentDigest: idx("string"),
@@ -404,6 +449,11 @@ var BRAND_SCHEMA = {
404
449
  analysis: opt("json"),
405
450
  // { description, dominantColors: hex[], tags }
406
451
  analyzedAt: opt("date"),
452
+ // `design` kind only: the DesignDigest computed from the uploaded
453
+ // bundle at upload time. Machine-derived and deterministic — unlike
454
+ // `analysis`, which is what a model reports seeing — so it needs no
455
+ // review gate and is rewritten only by re-uploading.
456
+ design: opt("json"),
407
457
  analysisRevision: a("number"),
408
458
  analysisDigest: opt("string"),
409
459
  analyzedBy: opt("string"),
@@ -498,6 +548,7 @@ var ASSET_CONTENT_TYPES = /* @__PURE__ */ new Set([
498
548
  "image/webp",
499
549
  "application/pdf"
500
550
  ]);
551
+ var DESIGN_CONTENT_TYPES = /* @__PURE__ */ new Set(["text/html"]);
501
552
  var HEX_RGB = /^#[0-9a-f]{3}$/;
502
553
  var HEX_RRGGBB = /^#[0-9a-f]{6}$/;
503
554
  var HEX_ALPHA = /^#[0-9a-f]{4}$|^#[0-9a-f]{8}$/;
@@ -619,12 +670,13 @@ function safeFileName(name) {
619
670
  throw new BrandInputError("file name is empty or a dot segment after sanitizing");
620
671
  return cleaned.slice(0, 120);
621
672
  }
622
- function assertAssetContentType(value) {
673
+ function assertAssetContentType(value, kind) {
623
674
  if (typeof value !== "string") throw new BrandInputError("contentType must be a string");
624
675
  const ct = value.split(";")[0].trim().toLowerCase();
625
- if (!ASSET_CONTENT_TYPES.has(ct))
676
+ const allowed = kind === DESIGN_ASSET_KIND ? DESIGN_CONTENT_TYPES : ASSET_CONTENT_TYPES;
677
+ if (!allowed.has(ct))
626
678
  throw new BrandInputError(
627
- `unsupported content type ${ct || "(empty)"}; allowed: ${[...ASSET_CONTENT_TYPES].join(", ")}`
679
+ `unsupported content type ${ct || "(empty)"} for kind ${kind ?? "other"}; allowed: ${[...allowed].join(", ")}`
628
680
  );
629
681
  return ct;
630
682
  }
@@ -641,8 +693,40 @@ function assertAnalysis(value) {
641
693
  };
642
694
  }
643
695
 
696
+ // src/read-shape.ts
697
+ var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
698
+ var DATE_FIELDS = /* @__PURE__ */ new Map();
699
+ for (const [ns, entity] of Object.entries(BRAND_SCHEMA.entities))
700
+ DATE_FIELDS.set(
701
+ ns,
702
+ new Set(
703
+ Object.entries(entity.attrs).filter(([, attr]) => attr.type === "date").map(([label]) => label)
704
+ )
705
+ );
706
+ function rowAsWritten(ns, row) {
707
+ const dates = DATE_FIELDS.get(ns);
708
+ if (!dates?.size || !record(row)) return row;
709
+ let changed = false;
710
+ const out = { ...row };
711
+ for (const field of dates) {
712
+ const value = out[field];
713
+ if (typeof value !== "string") continue;
714
+ const parsed = Date.parse(value);
715
+ if (!Number.isSafeInteger(parsed) || parsed < 0) continue;
716
+ out[field] = parsed;
717
+ changed = true;
718
+ }
719
+ return changed ? out : row;
720
+ }
721
+ function receiptAsWritten(row) {
722
+ return rowAsWritten(BRAND_NS.approvalReceipt, row);
723
+ }
724
+ function proposalAsWritten(row) {
725
+ return rowAsWritten(BRAND_NS.proposal, row);
726
+ }
727
+
644
728
  // src/review-json.ts
645
- var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
729
+ var record2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
646
730
  function isBoundedBrandJson(root, limits = {}) {
647
731
  const maxDepth = limits.maxDepth ?? 12;
648
732
  const maxNodes = limits.maxNodes ?? 2048;
@@ -666,7 +750,7 @@ function isBoundedBrandJson(root, limits = {}) {
666
750
  if (Array.isArray(value)) {
667
751
  for (const child of value)
668
752
  stack.push({ value: child, depth: depth + 1 });
669
- } else if (record(value)) {
753
+ } else if (record2(value)) {
670
754
  for (const child of Object.values(value))
671
755
  stack.push({ value: child, depth: depth + 1 });
672
756
  } else {
@@ -703,7 +787,7 @@ async function brandJsonDigest(value) {
703
787
 
704
788
  // src/review.ts
705
789
  var DIGEST = /^sha256:[0-9a-f]{64}$/;
706
- var record2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
790
+ var record3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
707
791
  var exactKeys = (value, required, optional = []) => {
708
792
  const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
709
793
  return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => allowed.has(key));
@@ -728,15 +812,15 @@ var SNAPSHOT_REQUIRED = [
728
812
  "createdAt"
729
813
  ];
730
814
  function validSnapshotShape(value) {
731
- if (!record2(value) || !exactKeys(value, SNAPSHOT_REQUIRED)) return false;
815
+ if (!record3(value) || !exactKeys(value, SNAPSHOT_REQUIRED)) return false;
732
816
  const provenance = value.provenance;
733
- return boundedString(value.id) && boundedString(value.bookId) && typeof value.kind === "string" && PROPOSAL_KINDS.includes(value.kind) && value.status === "open" && record2(value.payload) && boundedString(value.rationale, 2e3) && record2(provenance) && exactKeys(provenance, [
817
+ return boundedString(value.id) && boundedString(value.bookId) && typeof value.kind === "string" && PROPOSAL_KINDS.includes(value.kind) && value.status === "open" && record3(value.payload) && boundedString(value.rationale, 2e3) && record3(provenance) && exactKeys(provenance, [
734
818
  "sourceAssetId",
735
819
  "sourceAsset",
736
820
  "messageId",
737
821
  "turnId",
738
822
  "taintLabels"
739
- ]) && (provenance.sourceAssetId === null || boundedString(provenance.sourceAssetId)) && (provenance.sourceAsset === null || record2(provenance.sourceAsset) && exactKeys(provenance.sourceAsset, [
823
+ ]) && (provenance.sourceAssetId === null || boundedString(provenance.sourceAssetId)) && (provenance.sourceAsset === null || record3(provenance.sourceAsset) && exactKeys(provenance.sourceAsset, [
740
824
  "assetId",
741
825
  "contentDigest",
742
826
  "objectEtag",
@@ -769,8 +853,8 @@ var CONSUMPTION_REQUIRED = [
769
853
  "consumedAt"
770
854
  ];
771
855
  function isBrandHumanAuthorityConsumption(value) {
772
- if (!record2(value) || !exactKeys(value, CONSUMPTION_REQUIRED)) return false;
773
- return boundedString(value.id) && boundedString(value.grantId) && Number.isSafeInteger(value.grantVersion) && value.grantVersion > 0 && Number.isSafeInteger(value.useNumber) && value.useNumber > 0 && boundedString(value.actorPrincipalId) && value.actorKind === "human" && boundedString(value.credentialId) && value.credentialKind === "clerk" && boundedString(value.appId) && typeof value.appIncarnation === "string" && /^[a-f0-9]{32}$/.test(value.appIncarnation) && value.capability === "brand.proposal.resolve" && value.projectCapability === "brand.approve" && value.effect === "internal" && typeof value.actionDigest === "string" && DIGEST.test(value.actionDigest) && typeof value.resourceDigest === "string" && DIGEST.test(value.resourceDigest) && record2(value.constraintEvidence) && boundedString(value.consumptionIdempotencyKey) && typeof value.requestDigest === "string" && DIGEST.test(value.requestDigest) && safeTime(value.consumedAt);
856
+ if (!record3(value) || !exactKeys(value, CONSUMPTION_REQUIRED)) return false;
857
+ return boundedString(value.id) && boundedString(value.grantId) && Number.isSafeInteger(value.grantVersion) && value.grantVersion > 0 && Number.isSafeInteger(value.useNumber) && value.useNumber > 0 && boundedString(value.actorPrincipalId) && value.actorKind === "human" && boundedString(value.credentialId) && value.credentialKind === "clerk" && boundedString(value.appId) && typeof value.appIncarnation === "string" && /^[a-f0-9]{32}$/.test(value.appIncarnation) && value.capability === "brand.proposal.resolve" && value.projectCapability === "brand.approve" && value.effect === "internal" && typeof value.actionDigest === "string" && DIGEST.test(value.actionDigest) && typeof value.resourceDigest === "string" && DIGEST.test(value.resourceDigest) && record3(value.constraintEvidence) && boundedString(value.consumptionIdempotencyKey) && typeof value.requestDigest === "string" && DIGEST.test(value.requestDigest) && safeTime(value.consumedAt);
774
858
  }
775
859
  var RECEIPT_REQUIRED = [
776
860
  "version",
@@ -796,10 +880,10 @@ async function verifyBrandApprovalReceipt(receipt2) {
796
880
  try {
797
881
  if (!isBoundedBrandJson(receipt2, { maxDepth: 14, maxNodes: 2500, maxBytes: 48 * 1024 }))
798
882
  return false;
799
- if (!record2(receipt2) || !exactKeys(receipt2, RECEIPT_REQUIRED, ["paletteId", "resolutionNote"]))
883
+ if (!record3(receipt2) || !exactKeys(receipt2, RECEIPT_REQUIRED, ["paletteId", "resolutionNote"]))
800
884
  return false;
801
885
  const binding = receipt2.decisionBinding;
802
- if (receipt2.version !== 1 || !boundedString(receipt2.id) || !boundedString(receipt2.mutationKey) || !boundedString(receipt2.bookId) || !boundedString(receipt2.proposalId) || receipt2.resolution !== "accepted" && receipt2.resolution !== "rejected" || !validSnapshotShape(receipt2.reviewedProposal) || typeof receipt2.actionDigest !== "string" || !DIGEST.test(receipt2.actionDigest) || !record2(binding) || !exactKeys(binding, [
886
+ if (receipt2.version !== 1 || !boundedString(receipt2.id) || !boundedString(receipt2.mutationKey) || !boundedString(receipt2.bookId) || !boundedString(receipt2.proposalId) || receipt2.resolution !== "accepted" && receipt2.resolution !== "rejected" || !validSnapshotShape(receipt2.reviewedProposal) || typeof receipt2.actionDigest !== "string" || !DIGEST.test(receipt2.actionDigest) || !record3(binding) || !exactKeys(binding, [
803
887
  "version",
804
888
  "bookVersion",
805
889
  "activationRevision",
@@ -1171,7 +1255,11 @@ function rejectProposalOps(input) {
1171
1255
  function createAssetOps(input) {
1172
1256
  if (!ASSET_KINDS.includes(input.kind))
1173
1257
  throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
1174
- const contentType = assertAssetContentType(input.contentType);
1258
+ const contentType = assertAssetContentType(input.contentType, input.kind);
1259
+ if (input.kind === DESIGN_ASSET_KIND && input.design === void 0)
1260
+ throw new BrandInputError("a design asset must carry its parsed digest");
1261
+ if (input.kind !== DESIGN_ASSET_KIND && input.design !== void 0)
1262
+ throw new BrandInputError(`only ${DESIGN_ASSET_KIND} assets may carry a design digest`);
1175
1263
  if (typeof input.size !== "number" || !Number.isFinite(input.size) || input.size <= 0)
1176
1264
  throw new BrandInputError("size must be a positive byte count");
1177
1265
  if (!/^sha256:[0-9a-f]{64}$/.test(input.contentDigest))
@@ -1201,7 +1289,8 @@ function createAssetOps(input) {
1201
1289
  200
1202
1290
  ),
1203
1291
  createdAt: input.now,
1204
- ...title ? { title } : {}
1292
+ ...title ? { title } : {},
1293
+ ...input.design ? { design: input.design } : {}
1205
1294
  }
1206
1295
  },
1207
1296
  { t: "link", ns: BRAND_NS.asset, id: input.id, label: "book", target: input.bookId }
@@ -1253,6 +1342,180 @@ async function recordAnalysisOps(assetId, analysis, priorRevision, analyzedBy, a
1253
1342
  ];
1254
1343
  }
1255
1344
 
1345
+ // src/design/bundle.ts
1346
+ var MAX_THUMBNAIL_CHARS = 16384;
1347
+ function island(html, name) {
1348
+ const open = `<script type="__bundler/${name}">`;
1349
+ const start = html.indexOf(open);
1350
+ if (start < 0) return null;
1351
+ const from = start + open.length;
1352
+ const end = html.indexOf("</script>", from);
1353
+ return end < 0 ? null : html.slice(from, end);
1354
+ }
1355
+ function isDesignBundle(html) {
1356
+ return html.includes('<script type="__bundler/manifest">') && html.includes('<script type="__bundler/template">');
1357
+ }
1358
+ function base64ByteLength(data) {
1359
+ const len = data.length;
1360
+ if (len === 0) return 0;
1361
+ const pad = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
1362
+ return Math.max(0, Math.floor(len * 3 / 4) - pad);
1363
+ }
1364
+ function parseIslandJson(text, name) {
1365
+ try {
1366
+ return JSON.parse(text);
1367
+ } catch {
1368
+ throw new BrandInputError(`design bundle's ${name} island is not valid JSON`);
1369
+ }
1370
+ }
1371
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1372
+ function readAssets(raw) {
1373
+ if (!isRecord2(raw)) throw new BrandInputError("design bundle's manifest island must be an object");
1374
+ const assets = [];
1375
+ for (const [uuid, value] of Object.entries(raw)) {
1376
+ if (!isRecord2(value)) continue;
1377
+ const data = value.data;
1378
+ const mime = value.mime;
1379
+ if (typeof data !== "string" || typeof mime !== "string") continue;
1380
+ assets.push({
1381
+ uuid,
1382
+ mime: mime.slice(0, 120),
1383
+ bytes: base64ByteLength(data),
1384
+ compressed: value.compressed === true
1385
+ });
1386
+ }
1387
+ return assets;
1388
+ }
1389
+ function readExternals(raw) {
1390
+ if (raw === null || raw === void 0) return [];
1391
+ if (!Array.isArray(raw))
1392
+ throw new BrandInputError("design bundle's ext_resources island must be an array");
1393
+ const out = [];
1394
+ for (const entry of raw) {
1395
+ if (isRecord2(entry) && typeof entry.id === "string") out.push(entry.id.slice(0, 512));
1396
+ }
1397
+ return out;
1398
+ }
1399
+ function readPageOrder(raw) {
1400
+ if (raw === null || raw === void 0) return [];
1401
+ if (!Array.isArray(raw))
1402
+ throw new BrandInputError("design bundle's page_order island must be an array");
1403
+ return raw.filter((v) => typeof v === "string");
1404
+ }
1405
+ function extractThumbnailSvg(html) {
1406
+ const anchor = html.indexOf("__bundler_thumbnail");
1407
+ if (anchor < 0) return void 0;
1408
+ const open = html.indexOf("<svg", anchor);
1409
+ if (open < 0) return void 0;
1410
+ const close = html.indexOf("</svg>", open);
1411
+ if (close < 0) return void 0;
1412
+ const svg = html.slice(open, close + "</svg>".length);
1413
+ return svg.length > MAX_THUMBNAIL_CHARS ? void 0 : svg;
1414
+ }
1415
+ function readDesignManifest(html) {
1416
+ const raw = island(html, "manifest");
1417
+ if (raw === null) throw new BrandInputError("design bundle has no manifest island");
1418
+ const parsed = parseIslandJson(raw, "manifest");
1419
+ if (!isRecord2(parsed)) throw new BrandInputError("design bundle's manifest island must be an object");
1420
+ const out = {};
1421
+ for (const [uuid, value] of Object.entries(parsed)) {
1422
+ if (!isRecord2(value)) continue;
1423
+ const { data, mime } = value;
1424
+ if (typeof data !== "string" || typeof mime !== "string") continue;
1425
+ out[uuid] = { mime, compressed: value.compressed === true, data };
1426
+ }
1427
+ return out;
1428
+ }
1429
+ function parseDesignBundle(html) {
1430
+ if (!isDesignBundle(html))
1431
+ throw new BrandInputError(
1432
+ "not a Claude Design bundle: no __bundler/manifest and __bundler/template script islands. Export the design as standalone HTML and upload that file."
1433
+ );
1434
+ const templateRaw = island(html, "template");
1435
+ if (templateRaw === null)
1436
+ throw new BrandInputError("design bundle's template island is unterminated");
1437
+ const template = parseIslandJson(templateRaw, "template");
1438
+ if (typeof template !== "string")
1439
+ throw new BrandInputError("design bundle's template island must be a JSON string");
1440
+ const manifestRaw = island(html, "manifest");
1441
+ if (manifestRaw === null)
1442
+ throw new BrandInputError("design bundle's manifest island is unterminated");
1443
+ const extRaw = island(html, "ext_resources");
1444
+ const pageRaw = island(html, "page_order");
1445
+ const thumbnailSvg = extractThumbnailSvg(html);
1446
+ return {
1447
+ template,
1448
+ assets: readAssets(parseIslandJson(manifestRaw, "manifest")),
1449
+ externals: readExternals(extRaw === null ? null : parseIslandJson(extRaw, "ext_resources")),
1450
+ pageOrder: readPageOrder(pageRaw === null ? null : parseIslandJson(pageRaw, "page_order")),
1451
+ ...thumbnailSvg ? { thumbnailSvg } : {}
1452
+ };
1453
+ }
1454
+
1455
+ // src/design/css-scan.ts
1456
+ function stripCssComments(css) {
1457
+ let out = "";
1458
+ let i = 0;
1459
+ for (; ; ) {
1460
+ const start = css.indexOf("/*", i);
1461
+ if (start < 0) return out + css.slice(i);
1462
+ out += css.slice(i, start);
1463
+ const end = css.indexOf("*/", start + 2);
1464
+ if (end < 0) return out;
1465
+ i = end + 2;
1466
+ }
1467
+ }
1468
+ function styleSheetText(html) {
1469
+ const parts = [];
1470
+ let i = 0;
1471
+ for (; ; ) {
1472
+ const open = html.indexOf("<style", i);
1473
+ if (open < 0) break;
1474
+ const gt = html.indexOf(">", open);
1475
+ if (gt < 0) break;
1476
+ const close = html.indexOf("</style>", gt);
1477
+ if (close < 0) break;
1478
+ parts.push(html.slice(gt + 1, close));
1479
+ i = close + "</style>".length;
1480
+ }
1481
+ return parts.join("\n");
1482
+ }
1483
+ function scanCustomProperties(css) {
1484
+ const text = stripCssComments(css);
1485
+ const found = [];
1486
+ const stack = [];
1487
+ let paren = 0;
1488
+ let start = 0;
1489
+ const flush = (end) => {
1490
+ if (stack.length === 0) return;
1491
+ const chunk = text.slice(start, end).trim();
1492
+ if (!chunk.startsWith("--")) return;
1493
+ const colon = chunk.indexOf(":");
1494
+ if (colon < 0) return;
1495
+ const name = chunk.slice(0, colon).trim();
1496
+ if (name.length < 3) return;
1497
+ found.push({ selectors: [...stack], name, value: chunk.slice(colon + 1).trim() });
1498
+ };
1499
+ for (let i = 0; i < text.length; i++) {
1500
+ const ch = text[i];
1501
+ if (ch === "(") paren++;
1502
+ else if (ch === ")") paren = Math.max(0, paren - 1);
1503
+ if (paren !== 0) continue;
1504
+ if (ch === "{") {
1505
+ stack.push(text.slice(start, i).trim());
1506
+ start = i + 1;
1507
+ } else if (ch === "}") {
1508
+ flush(i);
1509
+ stack.pop();
1510
+ start = i + 1;
1511
+ } else if (ch === ";") {
1512
+ flush(i);
1513
+ start = i + 1;
1514
+ }
1515
+ }
1516
+ return found;
1517
+ }
1518
+
1256
1519
  // src/color/hex.ts
1257
1520
  function clamp01(x) {
1258
1521
  return Number.isNaN(x) ? 0 : x < 0 ? 0 : x > 1 ? 1 : x;
@@ -1763,69 +2026,6 @@ function derivePalette(seedHex, opts = {}) {
1763
2026
  return withNames ? swatches.map((s) => ({ ...s, name: nearestNamedColor(s.hex).name })) : swatches;
1764
2027
  }
1765
2028
 
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
2029
  // src/tokens/map.ts
1830
2030
  var DEFAULT_ACCENT_SEED = "#3b5e8c";
1831
2031
  var CHROMATIC_C = 0.02;
@@ -2025,6 +2225,452 @@ function mapPaletteToTokens(input) {
2025
2225
  return { tokens, warnings };
2026
2226
  }
2027
2227
 
2228
+ // src/design/decompile.ts
2229
+ var CHART_TOKENS = [
2230
+ "--ui-chart-1",
2231
+ "--ui-chart-2",
2232
+ "--ui-chart-3",
2233
+ "--ui-chart-4",
2234
+ "--ui-chart-5",
2235
+ "--ui-chart-6"
2236
+ ];
2237
+ function cssColorToHex(value) {
2238
+ const text = value.trim();
2239
+ if (text.startsWith("#")) {
2240
+ try {
2241
+ return assertHex(text);
2242
+ } catch {
2243
+ return null;
2244
+ }
2245
+ }
2246
+ const fn = /^rgba?\(([^)]*)\)$/i.exec(text);
2247
+ if (!fn) return null;
2248
+ const parts = (fn[1] ?? "").split(/[,/\s]+/).filter((p) => p !== "");
2249
+ if (parts.length < 3 || parts.length > 4) return null;
2250
+ if (parts.length === 4) {
2251
+ const alpha = parts[3].endsWith("%") ? Number.parseFloat(parts[3]) / 100 : Number.parseFloat(parts[3]);
2252
+ if (!Number.isFinite(alpha) || alpha < 1) return null;
2253
+ }
2254
+ const channels = parts.slice(0, 3).map((part) => {
2255
+ const n = Number.parseFloat(part);
2256
+ if (!Number.isFinite(n)) return Number.NaN;
2257
+ return Math.round(part.endsWith("%") ? n / 100 * 255 : n);
2258
+ });
2259
+ if (channels.some((c) => !Number.isFinite(c) || c < 0 || c > 255)) return null;
2260
+ return `#${channels.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
2261
+ }
2262
+ 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";
2263
+ function swatchesFromDesignTokens(tokens) {
2264
+ const light = tokens.light;
2265
+ const swatches = [];
2266
+ const skipped = [];
2267
+ const missing2 = [];
2268
+ for (const [role, token] of Object.entries(ROLE_TOKEN)) {
2269
+ if (role === "chart") continue;
2270
+ const value = light[token];
2271
+ if (value === void 0) {
2272
+ missing2.push(role);
2273
+ continue;
2274
+ }
2275
+ const hex = cssColorToHex(value);
2276
+ if (hex === null) {
2277
+ skipped.push({ token, value, reason: skipReason(value) });
2278
+ continue;
2279
+ }
2280
+ swatches.push({ role, hex, rationale: `declared by the design as ${token}` });
2281
+ }
2282
+ let anyChart = false;
2283
+ for (const token of CHART_TOKENS) {
2284
+ const value = light[token];
2285
+ if (value === void 0) continue;
2286
+ const hex = cssColorToHex(value);
2287
+ if (hex === null) {
2288
+ skipped.push({ token, value, reason: skipReason(value) });
2289
+ continue;
2290
+ }
2291
+ anyChart = true;
2292
+ swatches.push({ role: "chart", hex, rationale: `declared by the design as ${token}` });
2293
+ }
2294
+ if (!anyChart) missing2.push("chart");
2295
+ return { swatches, skipped, missing: missing2 };
2296
+ }
2297
+
2298
+ // src/design/html-text.ts
2299
+ var NAMED_ENTITIES = {
2300
+ amp: "&",
2301
+ lt: "<",
2302
+ gt: ">",
2303
+ quot: '"',
2304
+ apos: "'",
2305
+ nbsp: "\xA0",
2306
+ mdash: "\u2014",
2307
+ ndash: "\u2013",
2308
+ hellip: "\u2026",
2309
+ rsquo: "\u2019",
2310
+ lsquo: "\u2018",
2311
+ ldquo: "\u201C",
2312
+ rdquo: "\u201D"
2313
+ };
2314
+ function decodeEntities(text) {
2315
+ return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body) => {
2316
+ if (body.startsWith("#x") || body.startsWith("#X")) {
2317
+ const code = Number.parseInt(body.slice(2), 16);
2318
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
2319
+ }
2320
+ if (body.startsWith("#")) {
2321
+ const code = Number.parseInt(body.slice(1), 10);
2322
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
2323
+ }
2324
+ return NAMED_ENTITIES[body.toLowerCase()] ?? whole;
2325
+ });
2326
+ }
2327
+ var collapseWhitespace = (text) => text.replace(/\s+/g, " ").trim();
2328
+ function htmlToText(fragment) {
2329
+ const withoutCode = fragment.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, " ");
2330
+ return collapseWhitespace(decodeEntities(withoutCode.replace(/<[^>]*>/g, " ")));
2331
+ }
2332
+
2333
+ // src/design/outline.ts
2334
+ var MAX_OUTLINE_ENTRIES = 120;
2335
+ var MAX_HEADING_CHARS = 200;
2336
+ function extractTitle(html) {
2337
+ const match = /<title\b[^>]*>([\s\S]{0,2000}?)<\/title\s*>/i.exec(html);
2338
+ if (!match) return void 0;
2339
+ const text = htmlToText(match[1] ?? "");
2340
+ return text === "" ? void 0 : text.slice(0, MAX_HEADING_CHARS);
2341
+ }
2342
+ function extractOutline(html) {
2343
+ const pattern = /<h([1-6])\b[^>]*>([\s\S]{0,4000}?)<\/h\1\s*>/gi;
2344
+ const entries = [];
2345
+ for (; ; ) {
2346
+ const match = pattern.exec(html);
2347
+ if (match === null) break;
2348
+ const text = htmlToText(match[2] ?? "");
2349
+ if (text === "") continue;
2350
+ if (entries.length >= MAX_OUTLINE_ENTRIES) return { entries, truncated: true };
2351
+ entries.push({ level: Number(match[1]), text: text.slice(0, MAX_HEADING_CHARS) });
2352
+ }
2353
+ return { entries, truncated: false };
2354
+ }
2355
+
2356
+ // src/design/props.ts
2357
+ var MAX_PROPS = 60;
2358
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2359
+ function attributeValue(html, attr) {
2360
+ const at = html.indexOf(`${attr}="`);
2361
+ if (at >= 0) {
2362
+ const from2 = at + attr.length + 2;
2363
+ const end2 = html.indexOf('"', from2);
2364
+ return end2 < 0 ? null : html.slice(from2, end2);
2365
+ }
2366
+ const single = html.indexOf(`${attr}='`);
2367
+ if (single < 0) return null;
2368
+ const from = single + attr.length + 2;
2369
+ const end = html.indexOf("'", from);
2370
+ return end < 0 ? null : html.slice(from, end);
2371
+ }
2372
+ function defaultText(value) {
2373
+ if (value === void 0 || value === null) return void 0;
2374
+ if (typeof value === "string") return value.slice(0, 400);
2375
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
2376
+ try {
2377
+ return JSON.stringify(value).slice(0, 400);
2378
+ } catch {
2379
+ return void 0;
2380
+ }
2381
+ }
2382
+ function toProp(name, spec) {
2383
+ const options = Array.isArray(spec.options) ? spec.options.filter((v) => typeof v === "string").slice(0, 24) : void 0;
2384
+ const declared = defaultText(spec.default);
2385
+ return {
2386
+ name: name.slice(0, 80),
2387
+ editor: typeof spec.editor === "string" ? spec.editor.slice(0, 40) : "unknown",
2388
+ ...options && options.length > 0 ? { options } : {},
2389
+ ...declared !== void 0 ? { default: declared } : {},
2390
+ ...typeof spec.section === "string" ? { section: spec.section.slice(0, 80) } : {},
2391
+ ...typeof spec.tsType === "string" ? { tsType: spec.tsType.slice(0, 200) } : {}
2392
+ };
2393
+ }
2394
+ function extractProps(html) {
2395
+ const at = html.indexOf("data-props=");
2396
+ if (at < 0) return { props: [], truncated: false };
2397
+ const raw = attributeValue(html.slice(at), "data-props");
2398
+ if (raw === null) return { props: [], truncated: false };
2399
+ let parsed;
2400
+ try {
2401
+ parsed = JSON.parse(decodeEntities(raw));
2402
+ } catch {
2403
+ return { props: [], truncated: false };
2404
+ }
2405
+ if (!isRecord3(parsed)) return { props: [], truncated: false };
2406
+ const entries = Object.entries(parsed).filter(
2407
+ (entry) => isRecord3(entry[1])
2408
+ );
2409
+ return {
2410
+ props: entries.slice(0, MAX_PROPS).map(([name, spec]) => toProp(name, spec)),
2411
+ truncated: entries.length > MAX_PROPS
2412
+ };
2413
+ }
2414
+
2415
+ // src/design/styles.ts
2416
+ var MAX_FONTS = 16;
2417
+ var MAX_COLORS = 24;
2418
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
2419
+ "serif",
2420
+ "sans-serif",
2421
+ "monospace",
2422
+ "cursive",
2423
+ "fantasy",
2424
+ "system-ui",
2425
+ "ui-serif",
2426
+ "ui-sans-serif",
2427
+ "ui-monospace",
2428
+ "ui-rounded",
2429
+ "math",
2430
+ "emoji",
2431
+ "inherit",
2432
+ "initial",
2433
+ "revert",
2434
+ "unset",
2435
+ "currentcolor"
2436
+ ]);
2437
+ var familyName = (raw) => raw.trim().replace(/^["']|["']$/g, "").trim();
2438
+ function fontFaceFamilies(css) {
2439
+ const seen = /* @__PURE__ */ new Set();
2440
+ const blocks = /@font-face\s*\{([^}]{0,4000})\}/gi;
2441
+ for (; ; ) {
2442
+ const block = blocks.exec(css);
2443
+ if (block === null) break;
2444
+ const declared = /font-family\s*:\s*([^;]{1,200})/i.exec(block[1] ?? "");
2445
+ if (!declared) continue;
2446
+ const name = familyName(declared[1] ?? "");
2447
+ if (name !== "" && !GENERIC_FAMILIES.has(name.toLowerCase())) seen.add(name);
2448
+ }
2449
+ return [...seen];
2450
+ }
2451
+ function stackFamilies(css) {
2452
+ const counts = /* @__PURE__ */ new Map();
2453
+ const stacks = /font-family\s*:\s*([^;{}]{1,400})/gi;
2454
+ for (; ; ) {
2455
+ const stack = stacks.exec(css);
2456
+ if (stack === null) break;
2457
+ for (const part of (stack[1] ?? "").split(",")) {
2458
+ if (!/["']/.test(part)) continue;
2459
+ const name = familyName(part);
2460
+ if (name === "" || GENERIC_FAMILIES.has(name.toLowerCase()) || name.includes("var(")) continue;
2461
+ counts.set(name, (counts.get(name) ?? 0) + 1);
2462
+ }
2463
+ }
2464
+ return [...counts.entries()].sort((a2, b) => b[1] - a2[1] || (a2[0] < b[0] ? -1 : 1)).map(([name]) => name);
2465
+ }
2466
+ function extractFonts(templateHtml) {
2467
+ const css = stripCssComments(styleSheetText(templateHtml));
2468
+ const embedded = fontFaceFamilies(css);
2469
+ return (embedded.length > 0 ? embedded : stackFamilies(css)).slice(0, MAX_FONTS);
2470
+ }
2471
+ function extractColors(templateHtml) {
2472
+ const css = stripCssComments(styleSheetText(templateHtml));
2473
+ const counts = /* @__PURE__ */ new Map();
2474
+ const hexes = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
2475
+ for (; ; ) {
2476
+ const found = hexes.exec(css);
2477
+ if (found === null) break;
2478
+ const raw = (found[1] ?? "").toLowerCase();
2479
+ const hex = raw.length === 3 ? `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}` : `#${raw}`;
2480
+ counts.set(hex, (counts.get(hex) ?? 0) + 1);
2481
+ }
2482
+ 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 }));
2483
+ }
2484
+
2485
+ // src/design/tokens.ts
2486
+ var MAX_VAR_DEPTH = 8;
2487
+ var DOC_SELECTOR = /(^|,)\s*(:root|html)\b|\[data-theme\s*=|\.ui-invert\b/;
2488
+ var DARK_SELECTOR = /data-theme\s*=\s*["']?dark|prefers-color-scheme\s*:\s*dark|\.ui-invert\b/;
2489
+ function splitVarArgs(inner) {
2490
+ let paren = 0;
2491
+ for (let i = 0; i < inner.length; i++) {
2492
+ const ch = inner[i];
2493
+ if (ch === "(") paren++;
2494
+ else if (ch === ")") paren--;
2495
+ else if (ch === "," && paren === 0)
2496
+ return { name: inner.slice(0, i).trim(), fallback: inner.slice(i + 1).trim() };
2497
+ }
2498
+ return { name: inner.trim() };
2499
+ }
2500
+ function resolveVarRefs(value, table, depth = 0) {
2501
+ if (depth >= MAX_VAR_DEPTH || !value.includes("var(")) return value;
2502
+ let out = "";
2503
+ let i = 0;
2504
+ for (; ; ) {
2505
+ const at = value.indexOf("var(", i);
2506
+ if (at < 0) return out + value.slice(i);
2507
+ out += value.slice(i, at);
2508
+ let paren = 1;
2509
+ let j = at + "var(".length;
2510
+ for (; j < value.length && paren > 0; j++) {
2511
+ if (value[j] === "(") paren++;
2512
+ else if (value[j] === ")") paren--;
2513
+ }
2514
+ if (paren > 0) return out + value.slice(at);
2515
+ const { name, fallback } = splitVarArgs(value.slice(at + "var(".length, j - 1));
2516
+ const referenced = table.get(name);
2517
+ const replacement = referenced !== void 0 ? resolveVarRefs(referenced, table, depth + 1) : fallback !== void 0 ? resolveVarRefs(fallback, table, depth + 1) : `var(${name})`;
2518
+ out += replacement;
2519
+ i = j;
2520
+ }
2521
+ }
2522
+ function foldDeclarations(html) {
2523
+ const light = /* @__PURE__ */ new Map();
2524
+ const darkOverrides = [];
2525
+ for (const decl of scanCustomProperties(styleSheetText(html))) {
2526
+ const innermost = decl.selectors[decl.selectors.length - 1] ?? "";
2527
+ if (!DOC_SELECTOR.test(innermost)) continue;
2528
+ if (decl.selectors.some((sel) => DARK_SELECTOR.test(sel))) darkOverrides.push([decl.name, decl.value]);
2529
+ else light.set(decl.name, decl.value);
2530
+ }
2531
+ const dark = new Map(light);
2532
+ for (const [name, value] of darkOverrides) dark.set(name, value);
2533
+ return { light, dark };
2534
+ }
2535
+ function resolveUiTokens(table) {
2536
+ const out = {};
2537
+ for (const [name, value] of table) {
2538
+ if (!name.startsWith("--ui-")) continue;
2539
+ out[name] = resolveVarRefs(value, table);
2540
+ }
2541
+ return out;
2542
+ }
2543
+ function extractDesignTokens(templateHtml) {
2544
+ const { light, dark } = foldDeclarations(templateHtml);
2545
+ return { light: resolveUiTokens(light), dark: resolveUiTokens(dark) };
2546
+ }
2547
+
2548
+ // src/design/digest.ts
2549
+ var MAX_EXTERNALS = 24;
2550
+ function groupAssets(bundle) {
2551
+ const groups = /* @__PURE__ */ new Map();
2552
+ for (const asset of bundle.assets) {
2553
+ const group = groups.get(asset.mime) ?? { mime: asset.mime, count: 0, bytes: 0 };
2554
+ group.count += 1;
2555
+ group.bytes += asset.bytes;
2556
+ groups.set(asset.mime, group);
2557
+ }
2558
+ return [...groups.values()].sort((a2, b) => b.bytes - a2.bytes || (a2.mime < b.mime ? -1 : 1));
2559
+ }
2560
+ function digestDesignBundle(bundle) {
2561
+ const template = bundle.template;
2562
+ const outline = extractOutline(template);
2563
+ const props = extractProps(template);
2564
+ const assetGroups = groupAssets(bundle);
2565
+ const title = extractTitle(template);
2566
+ const truncated = [];
2567
+ if (outline.truncated) truncated.push("outline");
2568
+ if (props.truncated) truncated.push("props");
2569
+ if (bundle.externals.length > MAX_EXTERNALS) truncated.push("externals");
2570
+ return {
2571
+ format: "claude-design-bundle/1",
2572
+ ...title ? { title } : {},
2573
+ templateBytes: new TextEncoder().encode(template).byteLength,
2574
+ assetBytes: bundle.assets.reduce((total, asset) => total + asset.bytes, 0),
2575
+ assetCount: bundle.assets.length,
2576
+ assetGroups,
2577
+ externals: bundle.externals.slice(0, MAX_EXTERNALS),
2578
+ pageCount: bundle.pageOrder.length,
2579
+ tokens: extractDesignTokens(template),
2580
+ fonts: extractFonts(template),
2581
+ colors: extractColors(template),
2582
+ props: props.props,
2583
+ outline: outline.entries,
2584
+ ...bundle.thumbnailSvg ? { thumbnailSvg: bundle.thumbnailSvg } : {},
2585
+ truncated
2586
+ };
2587
+ }
2588
+ function digestDesignHtml(html) {
2589
+ return digestDesignBundle(parseDesignBundle(html));
2590
+ }
2591
+
2592
+ // src/palette-report.ts
2593
+ var round2 = (n) => Math.round(n * 100) / 100;
2594
+ function contrastReport(swatches) {
2595
+ const byRole = (role) => swatches.find((s) => s.role === role)?.hex;
2596
+ const bg = byRole("bg") ?? "#ffffff";
2597
+ const report = {};
2598
+ const put = (label, fg) => {
2599
+ if (fg) report[label] = round2(contrastRatio(fg, bg));
2600
+ };
2601
+ put("text-on-bg", byRole("text"));
2602
+ put("primary-on-bg", byRole("primary"));
2603
+ put("good-on-bg", byRole("good"));
2604
+ put("warn-on-bg", byRole("warn"));
2605
+ put("danger-on-bg", byRole("danger"));
2606
+ const primary = byRole("primary");
2607
+ if (primary) report["text-on-primary"] = round2(contrastRatio(pickTextOn(primary), primary));
2608
+ return report;
2609
+ }
2610
+
2611
+ // src/tokens/roles.ts
2612
+ var BRAND_REQUIRED_TOKENS = [
2613
+ "--ui-bg",
2614
+ "--ui-surface",
2615
+ "--ui-surface-2",
2616
+ "--ui-text",
2617
+ "--ui-text-muted",
2618
+ "--ui-text-faint",
2619
+ "--ui-border",
2620
+ "--ui-border-strong",
2621
+ "--ui-accent",
2622
+ "--ui-accent-strong",
2623
+ "--ui-accent-soft",
2624
+ "--ui-on-accent",
2625
+ "--ui-good",
2626
+ "--ui-good-soft",
2627
+ "--ui-warn",
2628
+ "--ui-warn-soft",
2629
+ "--ui-danger",
2630
+ "--ui-danger-soft",
2631
+ "--ui-code-bg",
2632
+ "--ui-code-text",
2633
+ "--ui-shadow",
2634
+ "--ui-font-sans",
2635
+ "--ui-font-serif",
2636
+ "--ui-font-mono",
2637
+ "--ui-font-display"
2638
+ ];
2639
+ var BRAND_DERIVED_TOKENS = [
2640
+ "--ui-accent-glow",
2641
+ "--ui-accent-2",
2642
+ "--ui-accent-2-soft",
2643
+ "--ui-highlight",
2644
+ "--ui-focus",
2645
+ "--ui-shadow-strong"
2646
+ ];
2647
+ var BRAND_CHART_TOKENS = [
2648
+ "--ui-chart-1",
2649
+ "--ui-chart-2",
2650
+ "--ui-chart-3",
2651
+ "--ui-chart-4",
2652
+ "--ui-chart-5",
2653
+ "--ui-chart-6",
2654
+ "--ui-chart-band",
2655
+ "--ui-chart-band-strong",
2656
+ "--ui-chart-flow",
2657
+ "--ui-chart-glow"
2658
+ ];
2659
+ var BRAND_CHAT_TOKENS = [
2660
+ "--ui-chat-user-bg",
2661
+ "--ui-chat-user-text",
2662
+ "--ui-chat-assistant-bg",
2663
+ "--ui-chat-thinking-bg",
2664
+ "--ui-chat-thinking-text",
2665
+ "--ui-chat-tool-accent"
2666
+ ];
2667
+ var BRAND_EMITTED_TOKENS = [
2668
+ ...BRAND_REQUIRED_TOKENS,
2669
+ ...BRAND_DERIVED_TOKENS,
2670
+ ...BRAND_CHART_TOKENS,
2671
+ ...BRAND_CHAT_TOKENS
2672
+ ];
2673
+
2028
2674
  // src/tokens/dark.ts
2029
2675
  var DARK_FLIP_L_MIN = 0.2;
2030
2676
  var DARK_FLIP_L_MAX = 0.95;
@@ -2182,10 +2828,10 @@ ${renderBlock(mediaSelector, dark, diffNames, " ")}
2182
2828
  }
2183
2829
 
2184
2830
  // src/tokens/compile.ts
2185
- var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2831
+ var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2186
2832
  function applyOverrides(light, overrides, warnings) {
2187
2833
  if (overrides === void 0) return;
2188
- if (!isRecord2(overrides)) {
2834
+ if (!isRecord4(overrides)) {
2189
2835
  warnings.push({ token: "--ui-accent", message: "overrides ignored: not an object" });
2190
2836
  return;
2191
2837
  }
@@ -2419,22 +3065,6 @@ function applyHarmony(swatches, seedHex, harmony) {
2419
3065
  return { ...s, hex, name: nearestNamedColor(hex).name, rationale: `${harmony} companion of the seed` };
2420
3066
  });
2421
3067
  }
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
3068
  var INCLUDE_SECTIONS = ["harmony", "ramp"];
2439
3069
  function analyzeLines(hex, include) {
2440
3070
  const lch = hexToOklch(hex);
@@ -2591,6 +3221,160 @@ function paletteTools(ctx) {
2591
3221
  return [analyzeColor, evaluateContrast, proposePalette];
2592
3222
  }
2593
3223
 
3224
+ // src/skill/design-tools.ts
3225
+ var MAX_SOURCE_WINDOW = 12e3;
3226
+ function describeDigest(asset, digest2) {
3227
+ const lines = [
3228
+ `Design ${asset.id}${digest2.title ? ` \u2014 "${digest2.title}"` : ""}${asset.title ? ` (uploaded as "${asset.title}")` : ""}`,
3229
+ `Template ${digest2.templateBytes} bytes; ${digest2.assetCount} embedded assets (${digest2.assetBytes} bytes); ${digest2.pageCount} nested pages.`
3230
+ ];
3231
+ if (digest2.assetGroups.length > 0)
3232
+ lines.push(
3233
+ `Assets: ${digest2.assetGroups.map((g) => `${g.count}\xD7 ${g.mime} (${g.bytes} B)`).join(", ")}`
3234
+ );
3235
+ if (digest2.externals.length > 0) lines.push(`Built against: ${digest2.externals.join(", ")}`);
3236
+ if (digest2.fonts.length > 0) lines.push(`Typefaces: ${digest2.fonts.join(", ")}`);
3237
+ const tokenNames = Object.keys(digest2.tokens.light);
3238
+ lines.push(
3239
+ 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."
3240
+ );
3241
+ if (digest2.colors.length > 0)
3242
+ lines.push(`Literal colors: ${digest2.colors.map((c) => `${c.hex}\xD7${c.count}`).join(", ")}`);
3243
+ if (digest2.props.length > 0)
3244
+ lines.push(
3245
+ "Configurable props:\n" + digest2.props.map(
3246
+ (p) => ` ${p.name} (${p.editor}${p.options ? `: ${p.options.join("|")}` : ""})${p.default === void 0 ? "" : ` default ${p.default}`}${p.section ? ` [${p.section}]` : ""}`
3247
+ ).join("\n")
3248
+ );
3249
+ if (digest2.outline.length > 0)
3250
+ lines.push(
3251
+ "Outline:\n" + digest2.outline.map((h) => `${" ".repeat(h.level - 1)}h${h.level} ${h.text}`).join("\n")
3252
+ );
3253
+ if (digest2.truncated.length > 0)
3254
+ lines.push(`NOTE: truncated to fit digest caps: ${digest2.truncated.join(", ")}.`);
3255
+ return lines.join("\n");
3256
+ }
3257
+ function sourceWindow(template, input) {
3258
+ const length = Math.min(
3259
+ MAX_SOURCE_WINDOW,
3260
+ Math.max(1, typeof input.length === "number" ? input.length : MAX_SOURCE_WINDOW)
3261
+ );
3262
+ let start;
3263
+ if (input.find !== void 0 && input.find !== "") {
3264
+ const at = template.indexOf(input.find);
3265
+ if (at < 0) throw new BrandNotFoundError(`"${input.find}" in the design source`);
3266
+ start = Math.max(0, at - 400);
3267
+ } else {
3268
+ start = Math.max(0, Math.min(template.length, Math.trunc(input.offset ?? 0)));
3269
+ }
3270
+ const end = Math.min(template.length, start + length);
3271
+ return { text: template.slice(start, end), start, end };
3272
+ }
3273
+ function designTools(ctx) {
3274
+ const loadDesign = async (assetId) => {
3275
+ await ctx.authority("brand.read");
3276
+ const res = await ctx.db.query({
3277
+ [BRAND_NS.asset]: { $: { where: { id: assetId, bookId: ctx.bookId, status: "live" } } }
3278
+ });
3279
+ const row = (res[BRAND_NS.asset] ?? [])[0];
3280
+ if (!row || row.kind !== DESIGN_ASSET_KIND || !row.design)
3281
+ throw new BrandNotFoundError(`design asset ${assetId}`);
3282
+ return row;
3283
+ };
3284
+ const readDesign = {
3285
+ name: "read_design",
3286
+ 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.",
3287
+ inputSchema: {
3288
+ type: "object",
3289
+ required: ["assetId"],
3290
+ properties: {
3291
+ assetId: { type: "string", description: "A design asset id from list_assets." }
3292
+ }
3293
+ },
3294
+ handler: ctx.guard(async (input) => {
3295
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
3296
+ return { content: describeDigest(asset, asset.design) };
3297
+ })
3298
+ };
3299
+ const readDesignSource = {
3300
+ name: "read_design_source",
3301
+ 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.",
3302
+ // The template is author-supplied content, not instructions.
3303
+ outputTaint: ["tool_untrusted:read_design_source"],
3304
+ inputSchema: {
3305
+ type: "object",
3306
+ required: ["assetId"],
3307
+ properties: {
3308
+ assetId: { type: "string" },
3309
+ find: { type: "string", description: "Jump to the first occurrence of this string." },
3310
+ offset: { type: "number", description: "Character offset to read from (ignored with find)." },
3311
+ length: { type: "number", description: `Characters to return (max ${MAX_SOURCE_WINDOW}).` }
3312
+ }
3313
+ },
3314
+ handler: ctx.guard(async (input) => {
3315
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
3316
+ const template = await ctx.readDesignTemplate(asset.id);
3317
+ const found = sourceWindow(template, {
3318
+ ...typeof input.find === "string" ? { find: input.find } : {},
3319
+ ...typeof input.offset === "number" ? { offset: input.offset } : {},
3320
+ ...typeof input.length === "number" ? { length: input.length } : {}
3321
+ });
3322
+ return {
3323
+ content: `Design ${asset.id} source, characters ${found.start}\u2013${found.end} of ${template.length}:
3324
+ ` + found.text
3325
+ };
3326
+ })
3327
+ };
3328
+ const proposeFromDesign = {
3329
+ name: "propose_palette_from_design",
3330
+ 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.",
3331
+ acceptsTaint: ["tool_untrusted:read_design_source"],
3332
+ inputSchema: {
3333
+ type: "object",
3334
+ required: ["assetId", "rationale"],
3335
+ properties: {
3336
+ assetId: { type: "string" },
3337
+ name: { type: "string", description: "Palette name; defaults to the design's title." },
3338
+ rationale: { type: "string", description: "Why this design's palette should become the brand's." }
3339
+ }
3340
+ },
3341
+ handler: ctx.guard(async (input) => {
3342
+ const book = await ctx.loadBook();
3343
+ await ctx.authority("brand.edit");
3344
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
3345
+ const digest2 = asset.design;
3346
+ const rationale = capString(input.rationale, "rationale", 2e3);
3347
+ const name = capString(
3348
+ input.name ?? digest2.title ?? asset.title ?? "Design palette",
3349
+ "name",
3350
+ 120
3351
+ );
3352
+ const { swatches, skipped, missing: missing2 } = swatchesFromDesignTokens(digest2.tokens);
3353
+ if (swatches.length === 0)
3354
+ throw new BrandInputError(
3355
+ `design ${asset.id} declares no --ui-* tokens that resolve to opaque colors; propose a palette explicitly instead.`
3356
+ );
3357
+ const report = contrastReport(swatches);
3358
+ const proposal = await ctx.createProposal({
3359
+ mutationId: ctx.newId(),
3360
+ kind: "palette",
3361
+ payload: { name, swatches, contrastReport: report, source: "design", designAssetId: asset.id },
3362
+ rationale,
3363
+ sourceAssetId: asset.id
3364
+ });
3365
+ 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");
3366
+ const notes = [
3367
+ skipped.length > 0 ? `Not imported (not literal opaque colors): ${skipped.map((s) => `${s.token} \u2014 ${s.reason}`).join("; ")}.` : "",
3368
+ missing2.length > 0 ? `Roles the design declares no token for: ${missing2.join(", ")}.` : ""
3369
+ ].filter((n) => n !== "");
3370
+ return {
3371
+ 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()
3372
+ };
3373
+ })
3374
+ };
3375
+ return [readDesign, readDesignSource, proposeFromDesign];
3376
+ }
3377
+
2594
3378
  // src/skill/read-tools.ts
2595
3379
  var iso = (ms) => new Date(ms).toISOString();
2596
3380
  var shortId = (id) => id.length <= 12 ? id : `${id.slice(0, 6)}\u2026${id.slice(-4)}`;
@@ -2706,10 +3490,11 @@ function readTools(ctx) {
2706
3490
  }
2707
3491
 
2708
3492
  // 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.";
3493
+ 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
3494
  function brandSkill(opts) {
2711
3495
  if (opts.agentDbBinding.principalId !== opts.self.selfId || !opts.agentDbBinding.credentialRef) throw new BrandForbiddenError("brand db is not bound to the acting agent");
2712
3496
  const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId });
3497
+ const designTemplates = /* @__PURE__ */ new Map();
2713
3498
  const authority = (capability) => Promise.resolve(opts.authorizeCapability({
2714
3499
  agentId: opts.self.selfId,
2715
3500
  bookId: opts.bookId,
@@ -2742,6 +3527,16 @@ function brandSkill(opts) {
2742
3527
  bookId: opts.bookId,
2743
3528
  assetId
2744
3529
  }),
3530
+ readDesignTemplate: (assetId) => {
3531
+ const cached = designTemplates.get(assetId);
3532
+ if (cached) return cached;
3533
+ const pending = opts.agentBridge.readAssetContent({ jobId: opts.agentJobId, bookId: opts.bookId, assetId }).then(
3534
+ (fetched) => parseDesignBundle(new TextDecoder().decode(fetched.bytes)).template
3535
+ );
3536
+ pending.catch(() => designTemplates.delete(assetId));
3537
+ designTemplates.set(assetId, pending);
3538
+ return pending;
3539
+ },
2745
3540
  resolvePrincipals: (principalIds) => {
2746
3541
  const ids = [...new Set(principalIds)].slice(0, 100);
2747
3542
  return opts.resolvePrincipals({
@@ -2773,7 +3568,13 @@ function brandSkill(opts) {
2773
3568
  return {
2774
3569
  name: "brand",
2775
3570
  instructions: BRAND_INSTRUCTIONS,
2776
- tools: [...readTools(ctx), ...assetTools(ctx), ...paletteTools(ctx), ...bookTools(ctx)]
3571
+ tools: [
3572
+ ...readTools(ctx),
3573
+ ...assetTools(ctx),
3574
+ ...designTools(ctx),
3575
+ ...paletteTools(ctx),
3576
+ ...bookTools(ctx)
3577
+ ]
2777
3578
  };
2778
3579
  }
2779
3580
 
@@ -2802,7 +3603,7 @@ async function linkedAsset(ctx, book, assetId, allowDeleting = false) {
2802
3603
  book: {}
2803
3604
  }
2804
3605
  });
2805
- const row = (result[BRAND_NS.asset] ?? [])[0];
3606
+ const row = rowAsWritten(BRAND_NS.asset, (result[BRAND_NS.asset] ?? [])[0]);
2806
3607
  const links = row?.book;
2807
3608
  if (!row || row.bookId !== book.id || !Array.isArray(links) || links.length !== 1 || links[0]?.id !== book.id || row.status !== "live" && !(allowDeleting && row.status === "deleting")) throw new BrandNotFoundError(`asset ${assetId}`);
2808
3609
  return row;
@@ -2876,7 +3677,7 @@ async function loadBook(db, bookId) {
2876
3677
  const res = await db.query({ [BRAND_NS.book]: { $: { where: { id: bookId } } } });
2877
3678
  const row = (res[BRAND_NS.book] ?? [])[0];
2878
3679
  if (!row) throw new BrandNotFoundError(`brand book ${bookId}`);
2879
- return row;
3680
+ return rowAsWritten(BRAND_NS.book, row);
2880
3681
  }
2881
3682
  async function loadMemberBook(db, bookId, actorId) {
2882
3683
  const book = await loadBook(db, bookId);
@@ -2904,9 +3705,13 @@ async function uploadAsset(ctx, req, bookId) {
2904
3705
  const file = form.get("file");
2905
3706
  if (!(file instanceof File))
2906
3707
  throw new BrandInputError('"file" must be an uploaded file field');
3708
+ const kindRaw = form.get("kind");
3709
+ const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
3710
+ if (!ASSET_KINDS.includes(kind))
3711
+ throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
2907
3712
  let contentType;
2908
3713
  try {
2909
- contentType = assertAssetContentType(file.type);
3714
+ contentType = assertAssetContentType(file.type, kind);
2910
3715
  } catch (error) {
2911
3716
  if (error instanceof BrandInputError)
2912
3717
  return json({ error: error.message }, 415);
@@ -2914,10 +3719,7 @@ async function uploadAsset(ctx, req, bookId) {
2914
3719
  }
2915
3720
  if (file.size > ctx.maxUploadBytes)
2916
3721
  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(", ")}`);
3722
+ const design = kind === DESIGN_ASSET_KIND ? digestDesignHtml(await file.text()) : void 0;
2921
3723
  const titleRaw = form.get("title");
2922
3724
  const title = typeof titleRaw === "string" && titleRaw ? capString(titleRaw, "title", 160) : void 0;
2923
3725
  const fileName = safeFileName(file.name);
@@ -2930,14 +3732,14 @@ async function uploadAsset(ctx, req, bookId) {
2930
3732
  "internal",
2931
3733
  book.id
2932
3734
  );
2933
- const record6 = await ctx.db.storage.upload(
3735
+ const record7 = await ctx.db.storage.upload(
2934
3736
  path,
2935
3737
  file,
2936
3738
  contentType,
2937
3739
  { private: true }
2938
3740
  );
2939
- if (record6.path !== path) {
2940
- await ctx.db.storage.delete(record6.path);
3741
+ if (record7.path !== path) {
3742
+ await ctx.db.storage.delete(record7.path);
2941
3743
  throw new BrandConflictError(
2942
3744
  "private storage returned an unexpected asset path"
2943
3745
  );
@@ -2956,15 +3758,16 @@ async function uploadAsset(ctx, req, bookId) {
2956
3758
  id,
2957
3759
  bookId: book.id,
2958
3760
  kind,
2959
- path: record6.path,
2960
- storageObjectId: record6.id,
3761
+ path: record7.path,
3762
+ storageObjectId: record7.id,
2961
3763
  contentDigest: await contentDigest(file),
2962
3764
  contentType,
2963
- size: record6.size,
3765
+ size: record7.size,
2964
3766
  uploadedBy: ctx.actor.id,
2965
3767
  uploadedAuthorityRef: authority.authorityRef,
2966
3768
  audience: book.memberIds,
2967
3769
  title,
3770
+ ...design ? { design } : {},
2968
3771
  now: ctx.now()
2969
3772
  }), {
2970
3773
  mutationId: `brand:asset-upload:v1:${id}`,
@@ -2986,7 +3789,7 @@ async function uploadAsset(ctx, req, bookId) {
2986
3789
  asPrincipalKind: "human"
2987
3790
  });
2988
3791
  } catch (error) {
2989
- await ctx.db.storage.delete(record6.path);
3792
+ await ctx.db.storage.delete(record7.path);
2990
3793
  throw error;
2991
3794
  }
2992
3795
  return json(await linkedAsset(ctx, book, id), 201);
@@ -3101,7 +3904,7 @@ async function handleAssetItem(ctx, req, bookId, assetId) {
3101
3904
  async function handleBooksRoot(ctx, req) {
3102
3905
  if (req.method === "GET") {
3103
3906
  const res = await ctx.db.query({ [BRAND_NS.book]: { $: { order: { createdAt: "asc" } } } });
3104
- const books = (res[BRAND_NS.book] ?? []).filter(
3907
+ const books = (res[BRAND_NS.book] ?? []).map((b) => rowAsWritten(BRAND_NS.book, b)).filter(
3105
3908
  (b) => isMember(b, ctx.actor.id)
3106
3909
  );
3107
3910
  return json({ books });
@@ -3206,6 +4009,51 @@ async function handleBookItem(ctx, req, bookId) {
3206
4009
  return json(await loadMemberBook(ctx.db, bookId, ctx.actor.id));
3207
4010
  }
3208
4011
 
4012
+ // src/routes/design-preview.ts
4013
+ var SIGNED_READ_TTL_SECONDS = 60;
4014
+ function designPreviewHeaders(frameAncestors) {
4015
+ return {
4016
+ "content-type": "text/html; charset=utf-8",
4017
+ "content-security-policy": [
4018
+ "sandbox allow-scripts",
4019
+ `frame-ancestors ${frameAncestors.join(" ")}`
4020
+ ].join("; "),
4021
+ "x-content-type-options": "nosniff",
4022
+ "referrer-policy": "no-referrer",
4023
+ "cache-control": "private, no-store"
4024
+ };
4025
+ }
4026
+ async function handleDesignPreview(ctx, req, bookId, assetId) {
4027
+ if (req.method !== "GET") return methodNotAllowed();
4028
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
4029
+ const asset = await linkedAsset(ctx, book, assetId);
4030
+ if (asset.kind !== DESIGN_ASSET_KIND) throw new BrandNotFoundError(`design ${assetId}`);
4031
+ const signed = await ctx.db.storage.sign(asset.path, SIGNED_READ_TTL_SECONDS);
4032
+ const fetched = await ctx.fetchPrivateAsset(signed, {
4033
+ headers: { accept: "text/html" },
4034
+ redirect: "manual",
4035
+ signal: req.signal
4036
+ });
4037
+ if (!fetched.ok || fetched.type === "opaqueredirect")
4038
+ throw new BrandNotFoundError(`design ${assetId}`);
4039
+ return new Response(new Uint8Array(await fetched.arrayBuffer()), {
4040
+ status: 200,
4041
+ headers: designPreviewHeaders(ctx.previewFrameAncestors)
4042
+ });
4043
+ }
4044
+ async function handleDesignDigest(ctx, req, bookId, assetId) {
4045
+ if (req.method !== "GET") return methodNotAllowed();
4046
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
4047
+ const asset = await linkedAsset(ctx, book, assetId);
4048
+ if (asset.kind !== DESIGN_ASSET_KIND || !asset.design)
4049
+ throw new BrandNotFoundError(`design ${assetId}`);
4050
+ return json(
4051
+ { assetId: asset.id, title: asset.title, digest: asset.design },
4052
+ 200,
4053
+ { "cache-control": "private, no-store" }
4054
+ );
4055
+ }
4056
+
3209
4057
  // src/routes/proposal-input.ts
3210
4058
  var SNAPSHOT_KEYS = [
3211
4059
  "audience",
@@ -3221,7 +4069,7 @@ var SNAPSHOT_KEYS = [
3221
4069
  "reviewDigest",
3222
4070
  "status"
3223
4071
  ];
3224
- var record3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4072
+ var record4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3225
4073
  function parseProposalResolutionRequest(body, bookId, proposalId) {
3226
4074
  const allowed = /* @__PURE__ */ new Set([
3227
4075
  "mutationId",
@@ -3265,13 +4113,13 @@ function proposalReviewSnapshot(proposal) {
3265
4113
  function parseReviewedProposal(value, bookId, proposalId) {
3266
4114
  if (!isBoundedBrandJson(value, { maxDepth: 12, maxNodes: 1500, maxBytes: 32 * 1024 }))
3267
4115
  throw new BrandInputError('"reviewedProposal" is too deeply nested, complex, or large');
3268
- if (!record3(value)) throw new BrandInputError('"reviewedProposal" must be an object');
4116
+ if (!record4(value)) throw new BrandInputError('"reviewedProposal" must be an object');
3269
4117
  const keys = Object.keys(value).sort();
3270
4118
  if (keys.length !== SNAPSHOT_KEYS.length || !SNAPSHOT_KEYS.every((key, index) => key === keys[index])) throw new BrandInputError('"reviewedProposal" has an invalid shape');
3271
4119
  if (value.id !== proposalId || value.bookId !== bookId || value.status !== "open")
3272
4120
  throw new BrandInputError('"reviewedProposal" must identify this open proposal');
3273
- if (typeof value.kind !== "string" || !PROPOSAL_KINDS.includes(value.kind) || !record3(value.payload) || typeof value.rationale !== "string" || !Array.isArray(value.audience) || value.audience.length < 1 || value.audience.length > 100 || value.audience.some((id) => typeof id !== "string" || id.length < 1 || id.length > 200) || new Set(value.audience).size !== value.audience.length || typeof value.createdBy !== "string" || value.createdBy.length < 1 || value.createdBy.length > 200 || typeof value.createdAuthorityRef !== "string" || value.createdAuthorityRef.length < 1 || value.createdAuthorityRef.length > 200 || !Number.isSafeInteger(value.createdAt) || value.createdAt < 0 || typeof value.reviewDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.reviewDigest)) throw new BrandInputError('"reviewedProposal" has invalid fields');
3274
- if (!record3(value.provenance) || Object.keys(value.provenance).sort().join(",") !== "messageId,sourceAsset,sourceAssetId,taintLabels,turnId" || value.provenance.sourceAssetId !== null && (typeof value.provenance.sourceAssetId !== "string" || value.provenance.sourceAssetId.length < 1 || value.provenance.sourceAssetId.length > 200) || value.provenance.sourceAsset !== null && (!record3(value.provenance.sourceAsset) || Object.keys(value.provenance.sourceAsset).sort().join(",") !== "analysisDigest,analysisRevision,assetId,contentDigest,contentType,objectEtag,objectSize,pathDigest" || typeof value.provenance.sourceAsset.assetId !== "string" || value.provenance.sourceAsset.assetId.length < 1 || value.provenance.sourceAsset.assetId.length > 200 || typeof value.provenance.sourceAsset.objectEtag !== "string" || value.provenance.sourceAsset.objectEtag.length < 1 || value.provenance.sourceAsset.objectEtag.length > 200 || !Number.isSafeInteger(value.provenance.sourceAsset.objectSize) || value.provenance.sourceAsset.objectSize < 1 || typeof value.provenance.sourceAsset.pathDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.provenance.sourceAsset.pathDigest) || value.provenance.sourceAsset.contentType !== null && (typeof value.provenance.sourceAsset.contentType !== "string" || value.provenance.sourceAsset.contentType.length < 1 || value.provenance.sourceAsset.contentType.length > 160) || typeof value.provenance.sourceAsset.contentDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.provenance.sourceAsset.contentDigest) || value.provenance.sourceAsset.analysisRevision !== null && (!Number.isSafeInteger(value.provenance.sourceAsset.analysisRevision) || value.provenance.sourceAsset.analysisRevision < 0) || value.provenance.sourceAsset.analysisDigest !== null && (typeof value.provenance.sourceAsset.analysisDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(
4121
+ if (typeof value.kind !== "string" || !PROPOSAL_KINDS.includes(value.kind) || !record4(value.payload) || typeof value.rationale !== "string" || !Array.isArray(value.audience) || value.audience.length < 1 || value.audience.length > 100 || value.audience.some((id) => typeof id !== "string" || id.length < 1 || id.length > 200) || new Set(value.audience).size !== value.audience.length || typeof value.createdBy !== "string" || value.createdBy.length < 1 || value.createdBy.length > 200 || typeof value.createdAuthorityRef !== "string" || value.createdAuthorityRef.length < 1 || value.createdAuthorityRef.length > 200 || !Number.isSafeInteger(value.createdAt) || value.createdAt < 0 || typeof value.reviewDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.reviewDigest)) throw new BrandInputError('"reviewedProposal" has invalid fields');
4122
+ if (!record4(value.provenance) || Object.keys(value.provenance).sort().join(",") !== "messageId,sourceAsset,sourceAssetId,taintLabels,turnId" || value.provenance.sourceAssetId !== null && (typeof value.provenance.sourceAssetId !== "string" || value.provenance.sourceAssetId.length < 1 || value.provenance.sourceAssetId.length > 200) || value.provenance.sourceAsset !== null && (!record4(value.provenance.sourceAsset) || Object.keys(value.provenance.sourceAsset).sort().join(",") !== "analysisDigest,analysisRevision,assetId,contentDigest,contentType,objectEtag,objectSize,pathDigest" || typeof value.provenance.sourceAsset.assetId !== "string" || value.provenance.sourceAsset.assetId.length < 1 || value.provenance.sourceAsset.assetId.length > 200 || typeof value.provenance.sourceAsset.objectEtag !== "string" || value.provenance.sourceAsset.objectEtag.length < 1 || value.provenance.sourceAsset.objectEtag.length > 200 || !Number.isSafeInteger(value.provenance.sourceAsset.objectSize) || value.provenance.sourceAsset.objectSize < 1 || typeof value.provenance.sourceAsset.pathDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.provenance.sourceAsset.pathDigest) || value.provenance.sourceAsset.contentType !== null && (typeof value.provenance.sourceAsset.contentType !== "string" || value.provenance.sourceAsset.contentType.length < 1 || value.provenance.sourceAsset.contentType.length > 160) || typeof value.provenance.sourceAsset.contentDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.provenance.sourceAsset.contentDigest) || value.provenance.sourceAsset.analysisRevision !== null && (!Number.isSafeInteger(value.provenance.sourceAsset.analysisRevision) || value.provenance.sourceAsset.analysisRevision < 0) || value.provenance.sourceAsset.analysisDigest !== null && (typeof value.provenance.sourceAsset.analysisDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(
3275
4123
  value.provenance.sourceAsset.analysisDigest
3276
4124
  )) || value.provenance.sourceAsset.assetId !== value.provenance.sourceAssetId) || value.provenance.sourceAssetId === null !== (value.provenance.sourceAsset === null) || value.provenance.messageId !== null && (typeof value.provenance.messageId !== "string" || value.provenance.messageId.length < 1 || value.provenance.messageId.length > 200) || value.provenance.turnId !== null && (typeof value.provenance.turnId !== "string" || value.provenance.turnId.length < 1 || value.provenance.turnId.length > 200) || !Array.isArray(value.provenance.taintLabels) || value.provenance.taintLabels.length > 16 || value.provenance.taintLabels.some((label) => typeof label !== "string" || label.length < 1 || label.length > 120) || new Set(value.provenance.taintLabels).size !== value.provenance.taintLabels.length) throw new BrandInputError('"reviewedProposal.provenance" is invalid');
3277
4125
  return value;
@@ -3444,7 +4292,7 @@ async function proposalReceiptByMutation(ctx, mutationKey) {
3444
4292
  const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
3445
4293
  if (!row || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== row.bookId) return void 0;
3446
4294
  const { book: _book, ...receipt2 } = row;
3447
- return receipt2;
4295
+ return receiptAsWritten(receipt2);
3448
4296
  }
3449
4297
  async function consumeProposalAuthority(ctx, req, bookId, proposalId, actionDigest, mutationKey) {
3450
4298
  const authority = await ctx.consumeHumanExact({
@@ -3478,7 +4326,8 @@ async function receipt(ctx, id, actionDigest, expected) {
3478
4326
  });
3479
4327
  const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
3480
4328
  if (!row || row.actionDigest !== actionDigest || row.resolution !== "accepted" || row.bookId !== expected.bookId || row.reviewedProposal.bookId !== expected.bookId || row.reviewedProposal.kind !== expected.kind || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== expected.bookId || expected.paletteId !== void 0 && row.paletteId !== expected.paletteId || expected.proposalId !== void 0 && row.proposalId !== expected.proposalId || expected.content !== void 0 && canonicalBrandJson(row.reviewedProposal.payload.content) !== canonicalBrandJson(expected.content)) throw new BrandConflictError(`approval receipt ${id} is invalid`);
3481
- const { book: _book, ...unhydrated } = row;
4329
+ const { book: _book, ...stored } = row;
4330
+ const unhydrated = receiptAsWritten(stored);
3482
4331
  if (!await verifyBrandApprovalReceipt(unhydrated))
3483
4332
  throw new BrandConflictError(`approval receipt ${id} is invalid`);
3484
4333
  return unhydrated;
@@ -3716,9 +4565,9 @@ async function proposalDecisionState(ctx, req, book, proposal, resolution, recei
3716
4565
  }
3717
4566
 
3718
4567
  // src/routes/proposals.ts
3719
- var record4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4568
+ var record5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3720
4569
  var sameStrings2 = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
3721
- var guardFailure2 = (error) => record4(error) && error.code === "transact_guard_failed";
4570
+ var guardFailure2 = (error) => record5(error) && error.code === "transact_guard_failed";
3722
4571
  async function handleProposalResolution(ctx, req, bookId, proposalId) {
3723
4572
  if (req.method !== "POST") return methodNotAllowed();
3724
4573
  const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
@@ -3765,7 +4614,7 @@ async function handleProposalResolution(ctx, req, bookId, proposalId) {
3765
4614
  book: {}
3766
4615
  }
3767
4616
  });
3768
- const proposal = (result[BRAND_NS.proposal] ?? [])[0];
4617
+ const proposal = proposalAsWritten((result[BRAND_NS.proposal] ?? [])[0]);
3769
4618
  if (!proposal || proposal.bookId !== book.id || !Array.isArray(proposal.book) || proposal.book.length !== 1 || proposal.book[0]?.id !== book.id) throw new BrandNotFoundError(`proposal ${proposalId}`);
3770
4619
  const current = proposalReviewSnapshot(proposal);
3771
4620
  const { reviewDigest, ...unsignedReview } = current;
@@ -3898,7 +4747,7 @@ async function handleProposalResolution(ctx, req, bookId, proposalId) {
3898
4747
  }
3899
4748
 
3900
4749
  // src/routes/tokens.ts
3901
- var record5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4750
+ var record6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3902
4751
  var missing = (bookId) => {
3903
4752
  throw new BrandNotFoundError(`compiled tokens for brand book ${bookId}`);
3904
4753
  };
@@ -3911,13 +4760,14 @@ async function acceptedReceipt(db, bookId, receiptId, actionDigest) {
3911
4760
  });
3912
4761
  const receipt2 = (result[BRAND_NS.approvalReceipt] ?? [])[0];
3913
4762
  if (!receipt2 || receipt2.resolution !== "accepted" || receipt2.actionDigest !== actionDigest || !Array.isArray(receipt2.book) || receipt2.book.length !== 1 || receipt2.book[0]?.id !== bookId) return missing(bookId);
3914
- const { book: _book, ...unhydrated } = receipt2;
4763
+ const { book: _book, ...stored } = receipt2;
4764
+ const unhydrated = receiptAsWritten(stored);
3915
4765
  if (!await verifyBrandApprovalReceipt(unhydrated)) return missing(bookId);
3916
4766
  return unhydrated;
3917
4767
  }
3918
4768
  async function approvedCache(db, book) {
3919
4769
  const cache = book.tokens;
3920
- if (!cache || !record5(cache.light) || !record5(cache.dark) || !book.activePaletteId || cache.paletteId !== book.activePaletteId || !cache.paletteReceiptId || !cache.paletteActionDigest || !cache.sourceDigest) return missing(book.id);
4770
+ if (!cache || !record6(cache.light) || !record6(cache.dark) || !book.activePaletteId || cache.paletteId !== book.activePaletteId || !cache.paletteReceiptId || !cache.paletteActionDigest || !cache.sourceDigest) return missing(book.id);
3921
4771
  const paletteResult = await db.query({
3922
4772
  [BRAND_NS.palette]: {
3923
4773
  $: { where: { id: cache.paletteId, bookId: book.id } },
@@ -4276,6 +5126,10 @@ async function route(ctx, req, url, seg) {
4276
5126
  if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
4277
5127
  if (seg.length === 5 && action === "content")
4278
5128
  return handleAssetContent(ctx, req, id, subId);
5129
+ if (seg.length === 5 && action === "preview")
5130
+ return handleDesignPreview(ctx, req, id, subId);
5131
+ if (seg.length === 5 && action === "design")
5132
+ return handleDesignDigest(ctx, req, id, subId);
4279
5133
  return null;
4280
5134
  }
4281
5135
  if (sub === "proposals" && seg.length === 5 && action === "resolve")
@@ -4297,7 +5151,8 @@ function createBrandRoutes(options) {
4297
5151
  newId: options.newId ?? (() => crypto.randomUUID()),
4298
5152
  maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
4299
5153
  discussionBasePath: options.discussionBasePath ?? "/",
4300
- fetchPrivateAsset: options.fetchPrivateAsset ?? fetch
5154
+ fetchPrivateAsset: options.fetchPrivateAsset ?? fetch,
5155
+ previewFrameAncestors: options.previewFrameAncestors ?? ["'self'"]
4301
5156
  };
4302
5157
  return async (req) => {
4303
5158
  const url = new URL(req.url);