@odla-ai/brand 0.4.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.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  PICK_TEXT_DEFAULT_CANDIDATES,
13
13
  RAMP_L_MAX,
14
14
  RAMP_L_MIN,
15
+ ROLE_TOKEN,
15
16
  adjustLightnessUntil,
16
17
  analogous,
17
18
  clamp01,
@@ -51,7 +52,7 @@ import {
51
52
  tintShadeRamp,
52
53
  toHex,
53
54
  triadic
54
- } from "./chunk-APLECQBR.js";
55
+ } from "./chunk-52DO72LS.js";
55
56
 
56
57
  // src/constants.ts
57
58
  var BRAND_NS = {
@@ -69,7 +70,15 @@ var PALETTE_STATUSES = ["active", "archived"];
69
70
  var PALETTE_SOURCES = ["extracted", "derived", "manual"];
70
71
  var PROPOSAL_KINDS = SECTION_KINDS;
71
72
  var PROPOSAL_STATUSES = ["open", "accepted", "rejected", "superseded"];
72
- var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "other"];
73
+ var ASSET_KINDS = [
74
+ "logo",
75
+ "wordmark",
76
+ "inspiration",
77
+ "document",
78
+ "design",
79
+ "other"
80
+ ];
81
+ var DESIGN_ASSET_KIND = "design";
73
82
  var SWATCH_ROLES = [
74
83
  "primary",
75
84
  "secondary",
@@ -293,7 +302,7 @@ var BRAND_SCHEMA = {
293
302
  id: uniq("string"),
294
303
  bookId: idx("string"),
295
304
  kind: idx("string"),
296
- // logo | wordmark | inspiration | document | other
305
+ // logo | wordmark | inspiration | document | design | other
297
306
  path: idx("string"),
298
307
  storageObjectId: idx("string"),
299
308
  contentDigest: idx("string"),
@@ -305,6 +314,11 @@ var BRAND_SCHEMA = {
305
314
  analysis: opt("json"),
306
315
  // { description, dominantColors: hex[], tags }
307
316
  analyzedAt: opt("date"),
317
+ // `design` kind only: the DesignDigest computed from the uploaded
318
+ // bundle at upload time. Machine-derived and deterministic — unlike
319
+ // `analysis`, which is what a model reports seeing — so it needs no
320
+ // review gate and is rewritten only by re-uploading.
321
+ design: opt("json"),
308
322
  analysisRevision: a("number"),
309
323
  analysisDigest: opt("string"),
310
324
  analyzedBy: opt("string"),
@@ -399,6 +413,7 @@ var ASSET_CONTENT_TYPES = /* @__PURE__ */ new Set([
399
413
  "image/webp",
400
414
  "application/pdf"
401
415
  ]);
416
+ var DESIGN_CONTENT_TYPES = /* @__PURE__ */ new Set(["text/html"]);
402
417
  var HEX_RGB = /^#[0-9a-f]{3}$/;
403
418
  var HEX_RRGGBB = /^#[0-9a-f]{6}$/;
404
419
  var HEX_ALPHA = /^#[0-9a-f]{4}$|^#[0-9a-f]{8}$/;
@@ -520,12 +535,13 @@ function safeFileName(name) {
520
535
  throw new BrandInputError("file name is empty or a dot segment after sanitizing");
521
536
  return cleaned.slice(0, 120);
522
537
  }
523
- function assertAssetContentType(value) {
538
+ function assertAssetContentType(value, kind) {
524
539
  if (typeof value !== "string") throw new BrandInputError("contentType must be a string");
525
540
  const ct = value.split(";")[0].trim().toLowerCase();
526
- if (!ASSET_CONTENT_TYPES.has(ct))
541
+ const allowed = kind === DESIGN_ASSET_KIND ? DESIGN_CONTENT_TYPES : ASSET_CONTENT_TYPES;
542
+ if (!allowed.has(ct))
527
543
  throw new BrandInputError(
528
- `unsupported content type ${ct || "(empty)"}; allowed: ${[...ASSET_CONTENT_TYPES].join(", ")}`
544
+ `unsupported content type ${ct || "(empty)"} for kind ${kind ?? "other"}; allowed: ${[...allowed].join(", ")}`
529
545
  );
530
546
  return ct;
531
547
  }
@@ -1072,7 +1088,11 @@ function rejectProposalOps(input) {
1072
1088
  function createAssetOps(input) {
1073
1089
  if (!ASSET_KINDS.includes(input.kind))
1074
1090
  throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
1075
- const contentType = assertAssetContentType(input.contentType);
1091
+ const contentType = assertAssetContentType(input.contentType, input.kind);
1092
+ if (input.kind === DESIGN_ASSET_KIND && input.design === void 0)
1093
+ throw new BrandInputError("a design asset must carry its parsed digest");
1094
+ if (input.kind !== DESIGN_ASSET_KIND && input.design !== void 0)
1095
+ throw new BrandInputError(`only ${DESIGN_ASSET_KIND} assets may carry a design digest`);
1076
1096
  if (typeof input.size !== "number" || !Number.isFinite(input.size) || input.size <= 0)
1077
1097
  throw new BrandInputError("size must be a positive byte count");
1078
1098
  if (!/^sha256:[0-9a-f]{64}$/.test(input.contentDigest))
@@ -1102,7 +1122,8 @@ function createAssetOps(input) {
1102
1122
  200
1103
1123
  ),
1104
1124
  createdAt: input.now,
1105
- ...title ? { title } : {}
1125
+ ...title ? { title } : {},
1126
+ ...input.design ? { design: input.design } : {}
1106
1127
  }
1107
1128
  },
1108
1129
  { t: "link", ns: BRAND_NS.asset, id: input.id, label: "book", target: input.bookId }
@@ -1154,6 +1175,563 @@ async function recordAnalysisOps(assetId, analysis, priorRevision, analyzedBy, a
1154
1175
  ];
1155
1176
  }
1156
1177
 
1178
+ // src/design/bundle.ts
1179
+ var MAX_THUMBNAIL_CHARS = 16384;
1180
+ function island(html, name) {
1181
+ const open = `<script type="__bundler/${name}">`;
1182
+ const start = html.indexOf(open);
1183
+ if (start < 0) return null;
1184
+ const from = start + open.length;
1185
+ const end = html.indexOf("</script>", from);
1186
+ return end < 0 ? null : html.slice(from, end);
1187
+ }
1188
+ function isDesignBundle(html) {
1189
+ return html.includes('<script type="__bundler/manifest">') && html.includes('<script type="__bundler/template">');
1190
+ }
1191
+ function base64ByteLength(data) {
1192
+ const len = data.length;
1193
+ if (len === 0) return 0;
1194
+ const pad = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
1195
+ return Math.max(0, Math.floor(len * 3 / 4) - pad);
1196
+ }
1197
+ function parseIslandJson(text, name) {
1198
+ try {
1199
+ return JSON.parse(text);
1200
+ } catch {
1201
+ throw new BrandInputError(`design bundle's ${name} island is not valid JSON`);
1202
+ }
1203
+ }
1204
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1205
+ function readAssets(raw) {
1206
+ if (!isRecord2(raw)) throw new BrandInputError("design bundle's manifest island must be an object");
1207
+ const assets = [];
1208
+ for (const [uuid, value] of Object.entries(raw)) {
1209
+ if (!isRecord2(value)) continue;
1210
+ const data = value.data;
1211
+ const mime = value.mime;
1212
+ if (typeof data !== "string" || typeof mime !== "string") continue;
1213
+ assets.push({
1214
+ uuid,
1215
+ mime: mime.slice(0, 120),
1216
+ bytes: base64ByteLength(data),
1217
+ compressed: value.compressed === true
1218
+ });
1219
+ }
1220
+ return assets;
1221
+ }
1222
+ function readExternals(raw) {
1223
+ if (raw === null || raw === void 0) return [];
1224
+ if (!Array.isArray(raw))
1225
+ throw new BrandInputError("design bundle's ext_resources island must be an array");
1226
+ const out = [];
1227
+ for (const entry of raw) {
1228
+ if (isRecord2(entry) && typeof entry.id === "string") out.push(entry.id.slice(0, 512));
1229
+ }
1230
+ return out;
1231
+ }
1232
+ function readPageOrder(raw) {
1233
+ if (raw === null || raw === void 0) return [];
1234
+ if (!Array.isArray(raw))
1235
+ throw new BrandInputError("design bundle's page_order island must be an array");
1236
+ return raw.filter((v) => typeof v === "string");
1237
+ }
1238
+ function extractThumbnailSvg(html) {
1239
+ const anchor = html.indexOf("__bundler_thumbnail");
1240
+ if (anchor < 0) return void 0;
1241
+ const open = html.indexOf("<svg", anchor);
1242
+ if (open < 0) return void 0;
1243
+ const close = html.indexOf("</svg>", open);
1244
+ if (close < 0) return void 0;
1245
+ const svg = html.slice(open, close + "</svg>".length);
1246
+ return svg.length > MAX_THUMBNAIL_CHARS ? void 0 : svg;
1247
+ }
1248
+ function readDesignManifest(html) {
1249
+ const raw = island(html, "manifest");
1250
+ if (raw === null) throw new BrandInputError("design bundle has no manifest island");
1251
+ const parsed = parseIslandJson(raw, "manifest");
1252
+ if (!isRecord2(parsed)) throw new BrandInputError("design bundle's manifest island must be an object");
1253
+ const out = {};
1254
+ for (const [uuid, value] of Object.entries(parsed)) {
1255
+ if (!isRecord2(value)) continue;
1256
+ const { data, mime } = value;
1257
+ if (typeof data !== "string" || typeof mime !== "string") continue;
1258
+ out[uuid] = { mime, compressed: value.compressed === true, data };
1259
+ }
1260
+ return out;
1261
+ }
1262
+ function parseDesignBundle(html) {
1263
+ if (!isDesignBundle(html))
1264
+ throw new BrandInputError(
1265
+ "not a Claude Design bundle: no __bundler/manifest and __bundler/template script islands. Export the design as standalone HTML and upload that file."
1266
+ );
1267
+ const templateRaw = island(html, "template");
1268
+ if (templateRaw === null)
1269
+ throw new BrandInputError("design bundle's template island is unterminated");
1270
+ const template = parseIslandJson(templateRaw, "template");
1271
+ if (typeof template !== "string")
1272
+ throw new BrandInputError("design bundle's template island must be a JSON string");
1273
+ const manifestRaw = island(html, "manifest");
1274
+ if (manifestRaw === null)
1275
+ throw new BrandInputError("design bundle's manifest island is unterminated");
1276
+ const extRaw = island(html, "ext_resources");
1277
+ const pageRaw = island(html, "page_order");
1278
+ const thumbnailSvg = extractThumbnailSvg(html);
1279
+ return {
1280
+ template,
1281
+ assets: readAssets(parseIslandJson(manifestRaw, "manifest")),
1282
+ externals: readExternals(extRaw === null ? null : parseIslandJson(extRaw, "ext_resources")),
1283
+ pageOrder: readPageOrder(pageRaw === null ? null : parseIslandJson(pageRaw, "page_order")),
1284
+ ...thumbnailSvg ? { thumbnailSvg } : {}
1285
+ };
1286
+ }
1287
+
1288
+ // src/design/css-scan.ts
1289
+ function stripCssComments(css) {
1290
+ let out = "";
1291
+ let i = 0;
1292
+ for (; ; ) {
1293
+ const start = css.indexOf("/*", i);
1294
+ if (start < 0) return out + css.slice(i);
1295
+ out += css.slice(i, start);
1296
+ const end = css.indexOf("*/", start + 2);
1297
+ if (end < 0) return out;
1298
+ i = end + 2;
1299
+ }
1300
+ }
1301
+ function styleSheetText(html) {
1302
+ const parts = [];
1303
+ let i = 0;
1304
+ for (; ; ) {
1305
+ const open = html.indexOf("<style", i);
1306
+ if (open < 0) break;
1307
+ const gt = html.indexOf(">", open);
1308
+ if (gt < 0) break;
1309
+ const close = html.indexOf("</style>", gt);
1310
+ if (close < 0) break;
1311
+ parts.push(html.slice(gt + 1, close));
1312
+ i = close + "</style>".length;
1313
+ }
1314
+ return parts.join("\n");
1315
+ }
1316
+ function scanCustomProperties(css) {
1317
+ const text = stripCssComments(css);
1318
+ const found = [];
1319
+ const stack = [];
1320
+ let paren = 0;
1321
+ let start = 0;
1322
+ const flush = (end) => {
1323
+ if (stack.length === 0) return;
1324
+ const chunk = text.slice(start, end).trim();
1325
+ if (!chunk.startsWith("--")) return;
1326
+ const colon = chunk.indexOf(":");
1327
+ if (colon < 0) return;
1328
+ const name = chunk.slice(0, colon).trim();
1329
+ if (name.length < 3) return;
1330
+ found.push({ selectors: [...stack], name, value: chunk.slice(colon + 1).trim() });
1331
+ };
1332
+ for (let i = 0; i < text.length; i++) {
1333
+ const ch = text[i];
1334
+ if (ch === "(") paren++;
1335
+ else if (ch === ")") paren = Math.max(0, paren - 1);
1336
+ if (paren !== 0) continue;
1337
+ if (ch === "{") {
1338
+ stack.push(text.slice(start, i).trim());
1339
+ start = i + 1;
1340
+ } else if (ch === "}") {
1341
+ flush(i);
1342
+ stack.pop();
1343
+ start = i + 1;
1344
+ } else if (ch === ";") {
1345
+ flush(i);
1346
+ start = i + 1;
1347
+ }
1348
+ }
1349
+ return found;
1350
+ }
1351
+
1352
+ // src/design/decompile.ts
1353
+ var CHART_TOKENS = [
1354
+ "--ui-chart-1",
1355
+ "--ui-chart-2",
1356
+ "--ui-chart-3",
1357
+ "--ui-chart-4",
1358
+ "--ui-chart-5",
1359
+ "--ui-chart-6"
1360
+ ];
1361
+ function cssColorToHex(value) {
1362
+ const text = value.trim();
1363
+ if (text.startsWith("#")) {
1364
+ try {
1365
+ return assertHex(text);
1366
+ } catch {
1367
+ return null;
1368
+ }
1369
+ }
1370
+ const fn = /^rgba?\(([^)]*)\)$/i.exec(text);
1371
+ if (!fn) return null;
1372
+ const parts = (fn[1] ?? "").split(/[,/\s]+/).filter((p) => p !== "");
1373
+ if (parts.length < 3 || parts.length > 4) return null;
1374
+ if (parts.length === 4) {
1375
+ const alpha = parts[3].endsWith("%") ? Number.parseFloat(parts[3]) / 100 : Number.parseFloat(parts[3]);
1376
+ if (!Number.isFinite(alpha) || alpha < 1) return null;
1377
+ }
1378
+ const channels = parts.slice(0, 3).map((part) => {
1379
+ const n = Number.parseFloat(part);
1380
+ if (!Number.isFinite(n)) return Number.NaN;
1381
+ return Math.round(part.endsWith("%") ? n / 100 * 255 : n);
1382
+ });
1383
+ if (channels.some((c) => !Number.isFinite(c) || c < 0 || c > 255)) return null;
1384
+ return `#${channels.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
1385
+ }
1386
+ 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";
1387
+ function swatchesFromDesignTokens(tokens) {
1388
+ const light = tokens.light;
1389
+ const swatches = [];
1390
+ const skipped = [];
1391
+ const missing2 = [];
1392
+ for (const [role, token] of Object.entries(ROLE_TOKEN)) {
1393
+ if (role === "chart") continue;
1394
+ const value = light[token];
1395
+ if (value === void 0) {
1396
+ missing2.push(role);
1397
+ continue;
1398
+ }
1399
+ const hex = cssColorToHex(value);
1400
+ if (hex === null) {
1401
+ skipped.push({ token, value, reason: skipReason(value) });
1402
+ continue;
1403
+ }
1404
+ swatches.push({ role, hex, rationale: `declared by the design as ${token}` });
1405
+ }
1406
+ let anyChart = false;
1407
+ for (const token of CHART_TOKENS) {
1408
+ const value = light[token];
1409
+ if (value === void 0) continue;
1410
+ const hex = cssColorToHex(value);
1411
+ if (hex === null) {
1412
+ skipped.push({ token, value, reason: skipReason(value) });
1413
+ continue;
1414
+ }
1415
+ anyChart = true;
1416
+ swatches.push({ role: "chart", hex, rationale: `declared by the design as ${token}` });
1417
+ }
1418
+ if (!anyChart) missing2.push("chart");
1419
+ return { swatches, skipped, missing: missing2 };
1420
+ }
1421
+
1422
+ // src/design/html-text.ts
1423
+ var NAMED_ENTITIES = {
1424
+ amp: "&",
1425
+ lt: "<",
1426
+ gt: ">",
1427
+ quot: '"',
1428
+ apos: "'",
1429
+ nbsp: "\xA0",
1430
+ mdash: "\u2014",
1431
+ ndash: "\u2013",
1432
+ hellip: "\u2026",
1433
+ rsquo: "\u2019",
1434
+ lsquo: "\u2018",
1435
+ ldquo: "\u201C",
1436
+ rdquo: "\u201D"
1437
+ };
1438
+ function decodeEntities(text) {
1439
+ return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body) => {
1440
+ if (body.startsWith("#x") || body.startsWith("#X")) {
1441
+ const code = Number.parseInt(body.slice(2), 16);
1442
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
1443
+ }
1444
+ if (body.startsWith("#")) {
1445
+ const code = Number.parseInt(body.slice(1), 10);
1446
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
1447
+ }
1448
+ return NAMED_ENTITIES[body.toLowerCase()] ?? whole;
1449
+ });
1450
+ }
1451
+ var collapseWhitespace = (text) => text.replace(/\s+/g, " ").trim();
1452
+ function htmlToText(fragment) {
1453
+ const withoutCode = fragment.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, " ");
1454
+ return collapseWhitespace(decodeEntities(withoutCode.replace(/<[^>]*>/g, " ")));
1455
+ }
1456
+
1457
+ // src/design/outline.ts
1458
+ var MAX_OUTLINE_ENTRIES = 120;
1459
+ var MAX_HEADING_CHARS = 200;
1460
+ function extractTitle(html) {
1461
+ const match = /<title\b[^>]*>([\s\S]{0,2000}?)<\/title\s*>/i.exec(html);
1462
+ if (!match) return void 0;
1463
+ const text = htmlToText(match[1] ?? "");
1464
+ return text === "" ? void 0 : text.slice(0, MAX_HEADING_CHARS);
1465
+ }
1466
+ function extractOutline(html) {
1467
+ const pattern = /<h([1-6])\b[^>]*>([\s\S]{0,4000}?)<\/h\1\s*>/gi;
1468
+ const entries = [];
1469
+ for (; ; ) {
1470
+ const match = pattern.exec(html);
1471
+ if (match === null) break;
1472
+ const text = htmlToText(match[2] ?? "");
1473
+ if (text === "") continue;
1474
+ if (entries.length >= MAX_OUTLINE_ENTRIES) return { entries, truncated: true };
1475
+ entries.push({ level: Number(match[1]), text: text.slice(0, MAX_HEADING_CHARS) });
1476
+ }
1477
+ return { entries, truncated: false };
1478
+ }
1479
+
1480
+ // src/design/props.ts
1481
+ var MAX_PROPS = 60;
1482
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1483
+ function attributeValue(html, attr) {
1484
+ const at = html.indexOf(`${attr}="`);
1485
+ if (at >= 0) {
1486
+ const from2 = at + attr.length + 2;
1487
+ const end2 = html.indexOf('"', from2);
1488
+ return end2 < 0 ? null : html.slice(from2, end2);
1489
+ }
1490
+ const single = html.indexOf(`${attr}='`);
1491
+ if (single < 0) return null;
1492
+ const from = single + attr.length + 2;
1493
+ const end = html.indexOf("'", from);
1494
+ return end < 0 ? null : html.slice(from, end);
1495
+ }
1496
+ function defaultText(value) {
1497
+ if (value === void 0 || value === null) return void 0;
1498
+ if (typeof value === "string") return value.slice(0, 400);
1499
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1500
+ try {
1501
+ return JSON.stringify(value).slice(0, 400);
1502
+ } catch {
1503
+ return void 0;
1504
+ }
1505
+ }
1506
+ function toProp(name, spec) {
1507
+ const options = Array.isArray(spec.options) ? spec.options.filter((v) => typeof v === "string").slice(0, 24) : void 0;
1508
+ const declared = defaultText(spec.default);
1509
+ return {
1510
+ name: name.slice(0, 80),
1511
+ editor: typeof spec.editor === "string" ? spec.editor.slice(0, 40) : "unknown",
1512
+ ...options && options.length > 0 ? { options } : {},
1513
+ ...declared !== void 0 ? { default: declared } : {},
1514
+ ...typeof spec.section === "string" ? { section: spec.section.slice(0, 80) } : {},
1515
+ ...typeof spec.tsType === "string" ? { tsType: spec.tsType.slice(0, 200) } : {}
1516
+ };
1517
+ }
1518
+ function extractProps(html) {
1519
+ const at = html.indexOf("data-props=");
1520
+ if (at < 0) return { props: [], truncated: false };
1521
+ const raw = attributeValue(html.slice(at), "data-props");
1522
+ if (raw === null) return { props: [], truncated: false };
1523
+ let parsed;
1524
+ try {
1525
+ parsed = JSON.parse(decodeEntities(raw));
1526
+ } catch {
1527
+ return { props: [], truncated: false };
1528
+ }
1529
+ if (!isRecord3(parsed)) return { props: [], truncated: false };
1530
+ const entries = Object.entries(parsed).filter(
1531
+ (entry) => isRecord3(entry[1])
1532
+ );
1533
+ return {
1534
+ props: entries.slice(0, MAX_PROPS).map(([name, spec]) => toProp(name, spec)),
1535
+ truncated: entries.length > MAX_PROPS
1536
+ };
1537
+ }
1538
+
1539
+ // src/design/styles.ts
1540
+ var MAX_FONTS = 16;
1541
+ var MAX_COLORS = 24;
1542
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
1543
+ "serif",
1544
+ "sans-serif",
1545
+ "monospace",
1546
+ "cursive",
1547
+ "fantasy",
1548
+ "system-ui",
1549
+ "ui-serif",
1550
+ "ui-sans-serif",
1551
+ "ui-monospace",
1552
+ "ui-rounded",
1553
+ "math",
1554
+ "emoji",
1555
+ "inherit",
1556
+ "initial",
1557
+ "revert",
1558
+ "unset",
1559
+ "currentcolor"
1560
+ ]);
1561
+ var familyName = (raw) => raw.trim().replace(/^["']|["']$/g, "").trim();
1562
+ function fontFaceFamilies(css) {
1563
+ const seen = /* @__PURE__ */ new Set();
1564
+ const blocks = /@font-face\s*\{([^}]{0,4000})\}/gi;
1565
+ for (; ; ) {
1566
+ const block = blocks.exec(css);
1567
+ if (block === null) break;
1568
+ const declared = /font-family\s*:\s*([^;]{1,200})/i.exec(block[1] ?? "");
1569
+ if (!declared) continue;
1570
+ const name = familyName(declared[1] ?? "");
1571
+ if (name !== "" && !GENERIC_FAMILIES.has(name.toLowerCase())) seen.add(name);
1572
+ }
1573
+ return [...seen];
1574
+ }
1575
+ function stackFamilies(css) {
1576
+ const counts = /* @__PURE__ */ new Map();
1577
+ const stacks = /font-family\s*:\s*([^;{}]{1,400})/gi;
1578
+ for (; ; ) {
1579
+ const stack = stacks.exec(css);
1580
+ if (stack === null) break;
1581
+ for (const part of (stack[1] ?? "").split(",")) {
1582
+ if (!/["']/.test(part)) continue;
1583
+ const name = familyName(part);
1584
+ if (name === "" || GENERIC_FAMILIES.has(name.toLowerCase()) || name.includes("var(")) continue;
1585
+ counts.set(name, (counts.get(name) ?? 0) + 1);
1586
+ }
1587
+ }
1588
+ return [...counts.entries()].sort((a2, b) => b[1] - a2[1] || (a2[0] < b[0] ? -1 : 1)).map(([name]) => name);
1589
+ }
1590
+ function extractFonts(templateHtml) {
1591
+ const css = stripCssComments(styleSheetText(templateHtml));
1592
+ const embedded = fontFaceFamilies(css);
1593
+ return (embedded.length > 0 ? embedded : stackFamilies(css)).slice(0, MAX_FONTS);
1594
+ }
1595
+ function extractColors(templateHtml) {
1596
+ const css = stripCssComments(styleSheetText(templateHtml));
1597
+ const counts = /* @__PURE__ */ new Map();
1598
+ const hexes = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
1599
+ for (; ; ) {
1600
+ const found = hexes.exec(css);
1601
+ if (found === null) break;
1602
+ const raw = (found[1] ?? "").toLowerCase();
1603
+ const hex = raw.length === 3 ? `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}` : `#${raw}`;
1604
+ counts.set(hex, (counts.get(hex) ?? 0) + 1);
1605
+ }
1606
+ 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 }));
1607
+ }
1608
+
1609
+ // src/design/tokens.ts
1610
+ var MAX_VAR_DEPTH = 8;
1611
+ var DOC_SELECTOR = /(^|,)\s*(:root|html)\b|\[data-theme\s*=|\.ui-invert\b/;
1612
+ var DARK_SELECTOR = /data-theme\s*=\s*["']?dark|prefers-color-scheme\s*:\s*dark|\.ui-invert\b/;
1613
+ function splitVarArgs(inner) {
1614
+ let paren = 0;
1615
+ for (let i = 0; i < inner.length; i++) {
1616
+ const ch = inner[i];
1617
+ if (ch === "(") paren++;
1618
+ else if (ch === ")") paren--;
1619
+ else if (ch === "," && paren === 0)
1620
+ return { name: inner.slice(0, i).trim(), fallback: inner.slice(i + 1).trim() };
1621
+ }
1622
+ return { name: inner.trim() };
1623
+ }
1624
+ function resolveVarRefs(value, table, depth = 0) {
1625
+ if (depth >= MAX_VAR_DEPTH || !value.includes("var(")) return value;
1626
+ let out = "";
1627
+ let i = 0;
1628
+ for (; ; ) {
1629
+ const at = value.indexOf("var(", i);
1630
+ if (at < 0) return out + value.slice(i);
1631
+ out += value.slice(i, at);
1632
+ let paren = 1;
1633
+ let j = at + "var(".length;
1634
+ for (; j < value.length && paren > 0; j++) {
1635
+ if (value[j] === "(") paren++;
1636
+ else if (value[j] === ")") paren--;
1637
+ }
1638
+ if (paren > 0) return out + value.slice(at);
1639
+ const { name, fallback } = splitVarArgs(value.slice(at + "var(".length, j - 1));
1640
+ const referenced = table.get(name);
1641
+ const replacement = referenced !== void 0 ? resolveVarRefs(referenced, table, depth + 1) : fallback !== void 0 ? resolveVarRefs(fallback, table, depth + 1) : `var(${name})`;
1642
+ out += replacement;
1643
+ i = j;
1644
+ }
1645
+ }
1646
+ function foldDeclarations(html) {
1647
+ const light = /* @__PURE__ */ new Map();
1648
+ const darkOverrides = [];
1649
+ for (const decl of scanCustomProperties(styleSheetText(html))) {
1650
+ const innermost = decl.selectors[decl.selectors.length - 1] ?? "";
1651
+ if (!DOC_SELECTOR.test(innermost)) continue;
1652
+ if (decl.selectors.some((sel) => DARK_SELECTOR.test(sel))) darkOverrides.push([decl.name, decl.value]);
1653
+ else light.set(decl.name, decl.value);
1654
+ }
1655
+ const dark = new Map(light);
1656
+ for (const [name, value] of darkOverrides) dark.set(name, value);
1657
+ return { light, dark };
1658
+ }
1659
+ function resolveUiTokens(table) {
1660
+ const out = {};
1661
+ for (const [name, value] of table) {
1662
+ if (!name.startsWith("--ui-")) continue;
1663
+ out[name] = resolveVarRefs(value, table);
1664
+ }
1665
+ return out;
1666
+ }
1667
+ function extractDesignTokens(templateHtml) {
1668
+ const { light, dark } = foldDeclarations(templateHtml);
1669
+ return { light: resolveUiTokens(light), dark: resolveUiTokens(dark) };
1670
+ }
1671
+
1672
+ // src/design/digest.ts
1673
+ var MAX_EXTERNALS = 24;
1674
+ function groupAssets(bundle) {
1675
+ const groups = /* @__PURE__ */ new Map();
1676
+ for (const asset of bundle.assets) {
1677
+ const group = groups.get(asset.mime) ?? { mime: asset.mime, count: 0, bytes: 0 };
1678
+ group.count += 1;
1679
+ group.bytes += asset.bytes;
1680
+ groups.set(asset.mime, group);
1681
+ }
1682
+ return [...groups.values()].sort((a2, b) => b.bytes - a2.bytes || (a2.mime < b.mime ? -1 : 1));
1683
+ }
1684
+ function digestDesignBundle(bundle) {
1685
+ const template = bundle.template;
1686
+ const outline = extractOutline(template);
1687
+ const props = extractProps(template);
1688
+ const assetGroups = groupAssets(bundle);
1689
+ const title = extractTitle(template);
1690
+ const truncated = [];
1691
+ if (outline.truncated) truncated.push("outline");
1692
+ if (props.truncated) truncated.push("props");
1693
+ if (bundle.externals.length > MAX_EXTERNALS) truncated.push("externals");
1694
+ return {
1695
+ format: "claude-design-bundle/1",
1696
+ ...title ? { title } : {},
1697
+ templateBytes: new TextEncoder().encode(template).byteLength,
1698
+ assetBytes: bundle.assets.reduce((total, asset) => total + asset.bytes, 0),
1699
+ assetCount: bundle.assets.length,
1700
+ assetGroups,
1701
+ externals: bundle.externals.slice(0, MAX_EXTERNALS),
1702
+ pageCount: bundle.pageOrder.length,
1703
+ tokens: extractDesignTokens(template),
1704
+ fonts: extractFonts(template),
1705
+ colors: extractColors(template),
1706
+ props: props.props,
1707
+ outline: outline.entries,
1708
+ ...bundle.thumbnailSvg ? { thumbnailSvg: bundle.thumbnailSvg } : {},
1709
+ truncated
1710
+ };
1711
+ }
1712
+ function digestDesignHtml(html) {
1713
+ return digestDesignBundle(parseDesignBundle(html));
1714
+ }
1715
+
1716
+ // src/palette-report.ts
1717
+ var round2 = (n) => Math.round(n * 100) / 100;
1718
+ function contrastReport(swatches) {
1719
+ const byRole = (role) => swatches.find((s) => s.role === role)?.hex;
1720
+ const bg = byRole("bg") ?? "#ffffff";
1721
+ const report = {};
1722
+ const put = (label, fg) => {
1723
+ if (fg) report[label] = round2(contrastRatio(fg, bg));
1724
+ };
1725
+ put("text-on-bg", byRole("text"));
1726
+ put("primary-on-bg", byRole("primary"));
1727
+ put("good-on-bg", byRole("good"));
1728
+ put("warn-on-bg", byRole("warn"));
1729
+ put("danger-on-bg", byRole("danger"));
1730
+ const primary = byRole("primary");
1731
+ if (primary) report["text-on-primary"] = round2(contrastRatio(pickTextOn(primary), primary));
1732
+ return report;
1733
+ }
1734
+
1157
1735
  // src/skill/asset-tools.ts
1158
1736
  var MAX_VIEW_BYTES = 4718592;
1159
1737
  var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
@@ -1365,22 +1943,6 @@ function applyHarmony(swatches, seedHex, harmony) {
1365
1943
  return { ...s, hex, name: nearestNamedColor(hex).name, rationale: `${harmony} companion of the seed` };
1366
1944
  });
1367
1945
  }
1368
- function contrastReport(swatches) {
1369
- const byRole = (role) => swatches.find((s) => s.role === role)?.hex;
1370
- const bg = byRole("bg") ?? "#ffffff";
1371
- const report = {};
1372
- const put = (label, fg) => {
1373
- if (fg) report[label] = Math.round(contrastRatio(fg, bg) * 100) / 100;
1374
- };
1375
- put("text-on-bg", byRole("text"));
1376
- put("primary-on-bg", byRole("primary"));
1377
- put("good-on-bg", byRole("good"));
1378
- put("warn-on-bg", byRole("warn"));
1379
- put("danger-on-bg", byRole("danger"));
1380
- const primary = byRole("primary");
1381
- if (primary) report["text-on-primary"] = Math.round(contrastRatio(pickTextOn(primary), primary) * 100) / 100;
1382
- return report;
1383
- }
1384
1946
  var INCLUDE_SECTIONS = ["harmony", "ramp"];
1385
1947
  function analyzeLines(hex, include) {
1386
1948
  const lch = hexToOklch(hex);
@@ -1537,6 +2099,160 @@ function paletteTools(ctx) {
1537
2099
  return [analyzeColor, evaluateContrast, proposePalette];
1538
2100
  }
1539
2101
 
2102
+ // src/skill/design-tools.ts
2103
+ var MAX_SOURCE_WINDOW = 12e3;
2104
+ function describeDigest(asset, digest2) {
2105
+ const lines = [
2106
+ `Design ${asset.id}${digest2.title ? ` \u2014 "${digest2.title}"` : ""}${asset.title ? ` (uploaded as "${asset.title}")` : ""}`,
2107
+ `Template ${digest2.templateBytes} bytes; ${digest2.assetCount} embedded assets (${digest2.assetBytes} bytes); ${digest2.pageCount} nested pages.`
2108
+ ];
2109
+ if (digest2.assetGroups.length > 0)
2110
+ lines.push(
2111
+ `Assets: ${digest2.assetGroups.map((g) => `${g.count}\xD7 ${g.mime} (${g.bytes} B)`).join(", ")}`
2112
+ );
2113
+ if (digest2.externals.length > 0) lines.push(`Built against: ${digest2.externals.join(", ")}`);
2114
+ if (digest2.fonts.length > 0) lines.push(`Typefaces: ${digest2.fonts.join(", ")}`);
2115
+ const tokenNames = Object.keys(digest2.tokens.light);
2116
+ lines.push(
2117
+ 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."
2118
+ );
2119
+ if (digest2.colors.length > 0)
2120
+ lines.push(`Literal colors: ${digest2.colors.map((c) => `${c.hex}\xD7${c.count}`).join(", ")}`);
2121
+ if (digest2.props.length > 0)
2122
+ lines.push(
2123
+ "Configurable props:\n" + digest2.props.map(
2124
+ (p) => ` ${p.name} (${p.editor}${p.options ? `: ${p.options.join("|")}` : ""})${p.default === void 0 ? "" : ` default ${p.default}`}${p.section ? ` [${p.section}]` : ""}`
2125
+ ).join("\n")
2126
+ );
2127
+ if (digest2.outline.length > 0)
2128
+ lines.push(
2129
+ "Outline:\n" + digest2.outline.map((h) => `${" ".repeat(h.level - 1)}h${h.level} ${h.text}`).join("\n")
2130
+ );
2131
+ if (digest2.truncated.length > 0)
2132
+ lines.push(`NOTE: truncated to fit digest caps: ${digest2.truncated.join(", ")}.`);
2133
+ return lines.join("\n");
2134
+ }
2135
+ function sourceWindow(template, input) {
2136
+ const length = Math.min(
2137
+ MAX_SOURCE_WINDOW,
2138
+ Math.max(1, typeof input.length === "number" ? input.length : MAX_SOURCE_WINDOW)
2139
+ );
2140
+ let start;
2141
+ if (input.find !== void 0 && input.find !== "") {
2142
+ const at = template.indexOf(input.find);
2143
+ if (at < 0) throw new BrandNotFoundError(`"${input.find}" in the design source`);
2144
+ start = Math.max(0, at - 400);
2145
+ } else {
2146
+ start = Math.max(0, Math.min(template.length, Math.trunc(input.offset ?? 0)));
2147
+ }
2148
+ const end = Math.min(template.length, start + length);
2149
+ return { text: template.slice(start, end), start, end };
2150
+ }
2151
+ function designTools(ctx) {
2152
+ const loadDesign = async (assetId) => {
2153
+ await ctx.authority("brand.read");
2154
+ const res = await ctx.db.query({
2155
+ [BRAND_NS.asset]: { $: { where: { id: assetId, bookId: ctx.bookId, status: "live" } } }
2156
+ });
2157
+ const row = (res[BRAND_NS.asset] ?? [])[0];
2158
+ if (!row || row.kind !== DESIGN_ASSET_KIND || !row.design)
2159
+ throw new BrandNotFoundError(`design asset ${assetId}`);
2160
+ return row;
2161
+ };
2162
+ const readDesign = {
2163
+ name: "read_design",
2164
+ 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.",
2165
+ inputSchema: {
2166
+ type: "object",
2167
+ required: ["assetId"],
2168
+ properties: {
2169
+ assetId: { type: "string", description: "A design asset id from list_assets." }
2170
+ }
2171
+ },
2172
+ handler: ctx.guard(async (input) => {
2173
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
2174
+ return { content: describeDigest(asset, asset.design) };
2175
+ })
2176
+ };
2177
+ const readDesignSource = {
2178
+ name: "read_design_source",
2179
+ 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.",
2180
+ // The template is author-supplied content, not instructions.
2181
+ outputTaint: ["tool_untrusted:read_design_source"],
2182
+ inputSchema: {
2183
+ type: "object",
2184
+ required: ["assetId"],
2185
+ properties: {
2186
+ assetId: { type: "string" },
2187
+ find: { type: "string", description: "Jump to the first occurrence of this string." },
2188
+ offset: { type: "number", description: "Character offset to read from (ignored with find)." },
2189
+ length: { type: "number", description: `Characters to return (max ${MAX_SOURCE_WINDOW}).` }
2190
+ }
2191
+ },
2192
+ handler: ctx.guard(async (input) => {
2193
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
2194
+ const template = await ctx.readDesignTemplate(asset.id);
2195
+ const found = sourceWindow(template, {
2196
+ ...typeof input.find === "string" ? { find: input.find } : {},
2197
+ ...typeof input.offset === "number" ? { offset: input.offset } : {},
2198
+ ...typeof input.length === "number" ? { length: input.length } : {}
2199
+ });
2200
+ return {
2201
+ content: `Design ${asset.id} source, characters ${found.start}\u2013${found.end} of ${template.length}:
2202
+ ` + found.text
2203
+ };
2204
+ })
2205
+ };
2206
+ const proposeFromDesign = {
2207
+ name: "propose_palette_from_design",
2208
+ 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.",
2209
+ acceptsTaint: ["tool_untrusted:read_design_source"],
2210
+ inputSchema: {
2211
+ type: "object",
2212
+ required: ["assetId", "rationale"],
2213
+ properties: {
2214
+ assetId: { type: "string" },
2215
+ name: { type: "string", description: "Palette name; defaults to the design's title." },
2216
+ rationale: { type: "string", description: "Why this design's palette should become the brand's." }
2217
+ }
2218
+ },
2219
+ handler: ctx.guard(async (input) => {
2220
+ const book = await ctx.loadBook();
2221
+ await ctx.authority("brand.edit");
2222
+ const asset = await loadDesign(capString(input.assetId, "assetId", 200));
2223
+ const digest2 = asset.design;
2224
+ const rationale = capString(input.rationale, "rationale", 2e3);
2225
+ const name = capString(
2226
+ input.name ?? digest2.title ?? asset.title ?? "Design palette",
2227
+ "name",
2228
+ 120
2229
+ );
2230
+ const { swatches, skipped, missing: missing2 } = swatchesFromDesignTokens(digest2.tokens);
2231
+ if (swatches.length === 0)
2232
+ throw new BrandInputError(
2233
+ `design ${asset.id} declares no --ui-* tokens that resolve to opaque colors; propose a palette explicitly instead.`
2234
+ );
2235
+ const report = contrastReport(swatches);
2236
+ const proposal = await ctx.createProposal({
2237
+ mutationId: ctx.newId(),
2238
+ kind: "palette",
2239
+ payload: { name, swatches, contrastReport: report, source: "design", designAssetId: asset.id },
2240
+ rationale,
2241
+ sourceAssetId: asset.id
2242
+ });
2243
+ 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");
2244
+ const notes = [
2245
+ skipped.length > 0 ? `Not imported (not literal opaque colors): ${skipped.map((s) => `${s.token} \u2014 ${s.reason}`).join("; ")}.` : "",
2246
+ missing2.length > 0 ? `Roles the design declares no token for: ${missing2.join(", ")}.` : ""
2247
+ ].filter((n) => n !== "");
2248
+ return {
2249
+ 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()
2250
+ };
2251
+ })
2252
+ };
2253
+ return [readDesign, readDesignSource, proposeFromDesign];
2254
+ }
2255
+
1540
2256
  // src/skill/read-tools.ts
1541
2257
  var iso = (ms) => new Date(ms).toISOString();
1542
2258
  var shortId = (id) => id.length <= 12 ? id : `${id.slice(0, 6)}\u2026${id.slice(-4)}`;
@@ -1652,10 +2368,11 @@ function readTools(ctx) {
1652
2368
  }
1653
2369
 
1654
2370
  // src/skill/skill.ts
1655
- 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.";
2371
+ 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.";
1656
2372
  function brandSkill(opts) {
1657
2373
  if (opts.agentDbBinding.principalId !== opts.self.selfId || !opts.agentDbBinding.credentialRef) throw new BrandForbiddenError("brand db is not bound to the acting agent");
1658
2374
  const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId });
2375
+ const designTemplates = /* @__PURE__ */ new Map();
1659
2376
  const authority = (capability) => Promise.resolve(opts.authorizeCapability({
1660
2377
  agentId: opts.self.selfId,
1661
2378
  bookId: opts.bookId,
@@ -1688,6 +2405,16 @@ function brandSkill(opts) {
1688
2405
  bookId: opts.bookId,
1689
2406
  assetId
1690
2407
  }),
2408
+ readDesignTemplate: (assetId) => {
2409
+ const cached = designTemplates.get(assetId);
2410
+ if (cached) return cached;
2411
+ const pending = opts.agentBridge.readAssetContent({ jobId: opts.agentJobId, bookId: opts.bookId, assetId }).then(
2412
+ (fetched) => parseDesignBundle(new TextDecoder().decode(fetched.bytes)).template
2413
+ );
2414
+ pending.catch(() => designTemplates.delete(assetId));
2415
+ designTemplates.set(assetId, pending);
2416
+ return pending;
2417
+ },
1691
2418
  resolvePrincipals: (principalIds) => {
1692
2419
  const ids = [...new Set(principalIds)].slice(0, 100);
1693
2420
  return opts.resolvePrincipals({
@@ -1719,7 +2446,13 @@ function brandSkill(opts) {
1719
2446
  return {
1720
2447
  name: "brand",
1721
2448
  instructions: BRAND_INSTRUCTIONS,
1722
- tools: [...readTools(ctx), ...assetTools(ctx), ...paletteTools(ctx), ...bookTools(ctx)]
2449
+ tools: [
2450
+ ...readTools(ctx),
2451
+ ...assetTools(ctx),
2452
+ ...designTools(ctx),
2453
+ ...paletteTools(ctx),
2454
+ ...bookTools(ctx)
2455
+ ]
1723
2456
  };
1724
2457
  }
1725
2458
 
@@ -1850,9 +2583,13 @@ async function uploadAsset(ctx, req, bookId) {
1850
2583
  const file = form.get("file");
1851
2584
  if (!(file instanceof File))
1852
2585
  throw new BrandInputError('"file" must be an uploaded file field');
2586
+ const kindRaw = form.get("kind");
2587
+ const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
2588
+ if (!ASSET_KINDS.includes(kind))
2589
+ throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
1853
2590
  let contentType;
1854
2591
  try {
1855
- contentType = assertAssetContentType(file.type);
2592
+ contentType = assertAssetContentType(file.type, kind);
1856
2593
  } catch (error) {
1857
2594
  if (error instanceof BrandInputError)
1858
2595
  return json({ error: error.message }, 415);
@@ -1860,10 +2597,7 @@ async function uploadAsset(ctx, req, bookId) {
1860
2597
  }
1861
2598
  if (file.size > ctx.maxUploadBytes)
1862
2599
  return json({ error: `file exceeds ${ctx.maxUploadBytes} bytes` }, 413);
1863
- const kindRaw = form.get("kind");
1864
- const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
1865
- if (!ASSET_KINDS.includes(kind))
1866
- throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
2600
+ const design = kind === DESIGN_ASSET_KIND ? digestDesignHtml(await file.text()) : void 0;
1867
2601
  const titleRaw = form.get("title");
1868
2602
  const title = typeof titleRaw === "string" && titleRaw ? capString(titleRaw, "title", 160) : void 0;
1869
2603
  const fileName = safeFileName(file.name);
@@ -1911,6 +2645,7 @@ async function uploadAsset(ctx, req, bookId) {
1911
2645
  uploadedAuthorityRef: authority.authorityRef,
1912
2646
  audience: book.memberIds,
1913
2647
  title,
2648
+ ...design ? { design } : {},
1914
2649
  now: ctx.now()
1915
2650
  }), {
1916
2651
  mutationId: `brand:asset-upload:v1:${id}`,
@@ -2152,6 +2887,51 @@ async function handleBookItem(ctx, req, bookId) {
2152
2887
  return json(await loadMemberBook(ctx.db, bookId, ctx.actor.id));
2153
2888
  }
2154
2889
 
2890
+ // src/routes/design-preview.ts
2891
+ var SIGNED_READ_TTL_SECONDS = 60;
2892
+ function designPreviewHeaders(frameAncestors) {
2893
+ return {
2894
+ "content-type": "text/html; charset=utf-8",
2895
+ "content-security-policy": [
2896
+ "sandbox allow-scripts",
2897
+ `frame-ancestors ${frameAncestors.join(" ")}`
2898
+ ].join("; "),
2899
+ "x-content-type-options": "nosniff",
2900
+ "referrer-policy": "no-referrer",
2901
+ "cache-control": "private, no-store"
2902
+ };
2903
+ }
2904
+ async function handleDesignPreview(ctx, req, bookId, assetId) {
2905
+ if (req.method !== "GET") return methodNotAllowed();
2906
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
2907
+ const asset = await linkedAsset(ctx, book, assetId);
2908
+ if (asset.kind !== DESIGN_ASSET_KIND) throw new BrandNotFoundError(`design ${assetId}`);
2909
+ const signed = await ctx.db.storage.sign(asset.path, SIGNED_READ_TTL_SECONDS);
2910
+ const fetched = await ctx.fetchPrivateAsset(signed, {
2911
+ headers: { accept: "text/html" },
2912
+ redirect: "manual",
2913
+ signal: req.signal
2914
+ });
2915
+ if (!fetched.ok || fetched.type === "opaqueredirect")
2916
+ throw new BrandNotFoundError(`design ${assetId}`);
2917
+ return new Response(new Uint8Array(await fetched.arrayBuffer()), {
2918
+ status: 200,
2919
+ headers: designPreviewHeaders(ctx.previewFrameAncestors)
2920
+ });
2921
+ }
2922
+ async function handleDesignDigest(ctx, req, bookId, assetId) {
2923
+ if (req.method !== "GET") return methodNotAllowed();
2924
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
2925
+ const asset = await linkedAsset(ctx, book, assetId);
2926
+ if (asset.kind !== DESIGN_ASSET_KIND || !asset.design)
2927
+ throw new BrandNotFoundError(`design ${assetId}`);
2928
+ return json(
2929
+ { assetId: asset.id, title: asset.title, digest: asset.design },
2930
+ 200,
2931
+ { "cache-control": "private, no-store" }
2932
+ );
2933
+ }
2934
+
2155
2935
  // src/routes/proposal-input.ts
2156
2936
  var SNAPSHOT_KEYS = [
2157
2937
  "audience",
@@ -3222,6 +4002,10 @@ async function route(ctx, req, url, seg) {
3222
4002
  if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
3223
4003
  if (seg.length === 5 && action === "content")
3224
4004
  return handleAssetContent(ctx, req, id, subId);
4005
+ if (seg.length === 5 && action === "preview")
4006
+ return handleDesignPreview(ctx, req, id, subId);
4007
+ if (seg.length === 5 && action === "design")
4008
+ return handleDesignDigest(ctx, req, id, subId);
3225
4009
  return null;
3226
4010
  }
3227
4011
  if (sub === "proposals" && seg.length === 5 && action === "resolve")
@@ -3243,7 +4027,8 @@ function createBrandRoutes(options) {
3243
4027
  newId: options.newId ?? (() => crypto.randomUUID()),
3244
4028
  maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
3245
4029
  discussionBasePath: options.discussionBasePath ?? "/",
3246
- fetchPrivateAsset: options.fetchPrivateAsset ?? fetch
4030
+ fetchPrivateAsset: options.fetchPrivateAsset ?? fetch,
4031
+ previewFrameAncestors: options.previewFrameAncestors ?? ["'self'"]
3247
4032
  };
3248
4033
  return async (req) => {
3249
4034
  const url = new URL(req.url);
@@ -3536,7 +4321,16 @@ export {
3536
4321
  DARK_FLIP_L_MIN,
3537
4322
  DEFAULT_ACCENT_SEED,
3538
4323
  DEFAULT_BRAND_SYSTEM,
4324
+ DESIGN_ASSET_KIND,
4325
+ DESIGN_CONTENT_TYPES,
4326
+ MAX_COLORS,
4327
+ MAX_EXTERNALS,
4328
+ MAX_FONTS,
4329
+ MAX_HEADING_CHARS,
4330
+ MAX_OUTLINE_ENTRIES,
4331
+ MAX_PROPS,
3539
4332
  MAX_SWATCHES,
4333
+ MAX_THUMBNAIL_CHARS,
3540
4334
  MAX_VIEW_BYTES,
3541
4335
  PALETTE_SOURCES,
3542
4336
  PALETTE_STATUSES,
@@ -3545,6 +4339,7 @@ export {
3545
4339
  PROPOSAL_STATUSES,
3546
4340
  RAMP_L_MAX,
3547
4341
  RAMP_L_MIN,
4342
+ ROLE_TOKEN,
3548
4343
  SECTION_KINDS,
3549
4344
  SECTION_STATUSES,
3550
4345
  SWATCH_ROLES,
@@ -3559,6 +4354,7 @@ export {
3559
4354
  assetTools,
3560
4355
  attachedBrandAssetIds,
3561
4356
  audienceFanoutOps,
4357
+ base64ByteLength,
3562
4358
  base64FromBytes,
3563
4359
  beginAssetDeleteOps,
3564
4360
  bookForChannel,
@@ -3577,27 +4373,43 @@ export {
3577
4373
  capStringArray,
3578
4374
  clamp01,
3579
4375
  clampToGamut,
4376
+ collapseWhitespace,
3580
4377
  compileBrandTokens,
3581
4378
  complementary,
3582
4379
  contrastRatio,
4380
+ contrastReport,
3583
4381
  createAssetOps,
3584
4382
  createBookOps,
3585
4383
  createBrandIntegration,
3586
4384
  createBrandPersona,
3587
4385
  createBrandRoutes,
4386
+ cssColorToHex,
4387
+ decodeEntities,
3588
4388
  deltaEOK,
3589
4389
  deltaEOKLab,
3590
4390
  deriveChartColors,
3591
4391
  deriveDarkTokens,
3592
4392
  derivePalette,
4393
+ digestDesignBundle,
4394
+ digestDesignHtml,
3593
4395
  dispatchBrandTurn,
4396
+ extractColors,
4397
+ extractDesignTokens,
4398
+ extractFonts,
4399
+ extractOutline,
4400
+ extractProps,
4401
+ extractThumbnailSvg,
4402
+ extractTitle,
3594
4403
  finishAssetDeleteOps,
3595
4404
  formatBrandDiscussionReference,
4405
+ groupAssets,
3596
4406
  hexToOklch,
3597
4407
  hslToRgb,
4408
+ htmlToText,
3598
4409
  inSrgbGamut,
3599
4410
  isBoundedBrandJson,
3600
4411
  isBrandHumanAuthorityConsumption,
4412
+ isDesignBundle,
3601
4413
  linearToSrgb,
3602
4414
  mapPaletteToTokens,
3603
4415
  meetsAA,
@@ -3612,25 +4424,32 @@ export {
3612
4424
  paletteTools,
3613
4425
  parseBrandDiscussionReference,
3614
4426
  parseBrandDispatch,
4427
+ parseDesignBundle,
3615
4428
  parseHex,
3616
4429
  pickTextOn,
3617
4430
  proposalReviewSnapshot,
3618
4431
  proposePaletteOps,
3619
4432
  proposeSectionOps,
4433
+ readDesignManifest,
3620
4434
  readTools,
3621
4435
  recordAnalysisOps,
3622
4436
  rejectProposalOps,
3623
4437
  relativeLuminance,
3624
4438
  renderTokensCss,
3625
4439
  resolveDeps,
4440
+ resolveVarRefs,
3626
4441
  rgbToHsl,
3627
4442
  rgbToOklab,
3628
4443
  rotateHue,
3629
4444
  safeFileName,
4445
+ scanCustomProperties,
3630
4446
  sectionKey,
3631
4447
  splitComplementary,
3632
4448
  srgbToLinear,
4449
+ stripCssComments,
4450
+ styleSheetText,
3633
4451
  supportsBrandVision,
4452
+ swatchesFromDesignTokens,
3634
4453
  tetradic,
3635
4454
  tintShadeRamp,
3636
4455
  toHex,