@odla-ai/brand 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/dist/{chunk-APLECQBR.js → chunk-52DO72LS.js} +4 -3
- package/dist/chunk-52DO72LS.js.map +1 -0
- package/dist/{index-CRl3IXHB.d.cts → index-r36KQK5n.d.cts} +16 -3
- package/dist/{index-CRl3IXHB.d.ts → index-r36KQK5n.d.ts} +16 -3
- package/dist/index.cjs +1061 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +370 -9
- package/dist/index.d.ts +370 -9
- package/dist/index.js +998 -35
- package/dist/index.js.map +1 -1
- package/dist/tokens/index.cjs +1 -0
- package/dist/tokens/index.cjs.map +1 -1
- package/dist/tokens/index.d.cts +1 -1
- package/dist/tokens/index.d.ts +1 -1
- package/dist/tokens/index.js +3 -1
- package/package.json +1 -1
- package/dist/chunk-APLECQBR.js.map +0 -1
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-
|
|
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 = [
|
|
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
|
-
|
|
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: ${[...
|
|
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.\
|
|
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: [
|
|
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
|
|
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",
|
|
@@ -2372,13 +3152,13 @@ async function proposalDecisionOps(input) {
|
|
|
2372
3152
|
|
|
2373
3153
|
// src/routes/proposal-resolution-receipts.ts
|
|
2374
3154
|
async function proposalResolutionMutationKey(bookId, proposalId, mutationId) {
|
|
2375
|
-
const
|
|
3155
|
+
const digest2 = await brandJsonDigest({
|
|
2376
3156
|
version: 3,
|
|
2377
3157
|
bookId,
|
|
2378
3158
|
proposalId,
|
|
2379
3159
|
mutationId
|
|
2380
3160
|
});
|
|
2381
|
-
return `brand:proposal-resolution:v3:${
|
|
3161
|
+
return `brand:proposal-resolution:v3:${digest2.slice("sha256:".length)}`;
|
|
2382
3162
|
}
|
|
2383
3163
|
async function proposalReceiptByMutation(ctx, mutationKey) {
|
|
2384
3164
|
const result = await ctx.db.query({
|
|
@@ -2928,6 +3708,130 @@ async function handleTokens(db, req, bookId, which, actor) {
|
|
|
2928
3708
|
});
|
|
2929
3709
|
}
|
|
2930
3710
|
|
|
3711
|
+
// src/routes/discussion-asset.ts
|
|
3712
|
+
var MAX_DISCUSSION_ASSET_BYTES = 4718592;
|
|
3713
|
+
var SIGN_TTL_SECONDS = 30;
|
|
3714
|
+
var TYPES = /* @__PURE__ */ new Set([
|
|
3715
|
+
"image/png",
|
|
3716
|
+
"image/jpeg",
|
|
3717
|
+
"image/gif",
|
|
3718
|
+
"image/webp",
|
|
3719
|
+
"application/pdf"
|
|
3720
|
+
]);
|
|
3721
|
+
var bare = (value) => (value?.split(";", 1)[0] ?? "").trim().toLowerCase();
|
|
3722
|
+
var starts = (bytes, expected) => expected.every((value, index) => bytes[index] === value);
|
|
3723
|
+
function magicMatches(contentType, bytes) {
|
|
3724
|
+
if (contentType === "image/png") {
|
|
3725
|
+
return starts(bytes, [137, 80, 78, 71, 13, 10, 26, 10]);
|
|
3726
|
+
}
|
|
3727
|
+
if (contentType === "image/jpeg") {
|
|
3728
|
+
return starts(bytes, [255, 216, 255]);
|
|
3729
|
+
}
|
|
3730
|
+
const prefix = new TextDecoder().decode(bytes.subarray(0, 16));
|
|
3731
|
+
if (contentType === "image/gif") {
|
|
3732
|
+
return prefix.startsWith("GIF87a") || prefix.startsWith("GIF89a");
|
|
3733
|
+
}
|
|
3734
|
+
if (contentType === "image/webp") {
|
|
3735
|
+
return prefix.startsWith("RIFF") && prefix.slice(8, 12) === "WEBP";
|
|
3736
|
+
}
|
|
3737
|
+
return contentType === "application/pdf" && prefix.startsWith("%PDF-");
|
|
3738
|
+
}
|
|
3739
|
+
async function boundedBytes(response, max) {
|
|
3740
|
+
const length = Number(response.headers.get("content-length"));
|
|
3741
|
+
if (Number.isFinite(length) && length > max) return null;
|
|
3742
|
+
if (!response.body) return new Uint8Array();
|
|
3743
|
+
const reader = response.body.getReader();
|
|
3744
|
+
const chunks = [];
|
|
3745
|
+
let total = 0;
|
|
3746
|
+
while (true) {
|
|
3747
|
+
const next = await reader.read();
|
|
3748
|
+
if (next.done) break;
|
|
3749
|
+
total += next.value.byteLength;
|
|
3750
|
+
if (total > max) {
|
|
3751
|
+
await reader.cancel();
|
|
3752
|
+
return null;
|
|
3753
|
+
}
|
|
3754
|
+
chunks.push(next.value);
|
|
3755
|
+
}
|
|
3756
|
+
const out = new Uint8Array(total);
|
|
3757
|
+
let offset = 0;
|
|
3758
|
+
for (const chunk of chunks) {
|
|
3759
|
+
out.set(chunk, offset);
|
|
3760
|
+
offset += chunk.byteLength;
|
|
3761
|
+
}
|
|
3762
|
+
return out;
|
|
3763
|
+
}
|
|
3764
|
+
async function digest(bytes) {
|
|
3765
|
+
const value = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
|
|
3766
|
+
return `sha256:${[...new Uint8Array(value)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
3767
|
+
}
|
|
3768
|
+
function exactAsset(beforeBook, before, afterBook, after) {
|
|
3769
|
+
return afterBook.id === beforeBook.id && after.id === before.id && after.bookId === before.bookId && after.status === "live" && after.path === before.path && after.storageObjectId === before.storageObjectId && after.contentDigest === before.contentDigest && after.contentType === before.contentType && after.size === before.size;
|
|
3770
|
+
}
|
|
3771
|
+
function safeSignedUrl(value) {
|
|
3772
|
+
try {
|
|
3773
|
+
const url = new URL(value);
|
|
3774
|
+
return value.length <= 4096 && url.protocol === "https:" && !url.username && !url.password ? url : null;
|
|
3775
|
+
} catch {
|
|
3776
|
+
return null;
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
async function handleBrandDiscussionAsset(ctx, req, target) {
|
|
3780
|
+
const beforeBook = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id);
|
|
3781
|
+
const before = await linkedAsset(ctx, beforeBook, target.resourceId);
|
|
3782
|
+
const contentType = bare(before.contentType);
|
|
3783
|
+
if (!TYPES.has(contentType)) {
|
|
3784
|
+
return json({ error: "asset content type is not viewable" }, 415);
|
|
3785
|
+
}
|
|
3786
|
+
if (before.size < 1 || before.size > MAX_DISCUSSION_ASSET_BYTES) {
|
|
3787
|
+
return json({ error: "asset exceeds the discussion media limit" }, 413);
|
|
3788
|
+
}
|
|
3789
|
+
const signed = safeSignedUrl(
|
|
3790
|
+
await ctx.db.storage.sign(before.path, SIGN_TTL_SECONDS)
|
|
3791
|
+
);
|
|
3792
|
+
if (!signed) throw new BrandInputError("private asset signing failed");
|
|
3793
|
+
const response = await ctx.fetchPrivateAsset(signed, {
|
|
3794
|
+
headers: { accept: contentType },
|
|
3795
|
+
redirect: "manual",
|
|
3796
|
+
signal: req.signal
|
|
3797
|
+
});
|
|
3798
|
+
if (!response.ok || response.type === "opaqueredirect") {
|
|
3799
|
+
throw new BrandNotFoundError(`asset ${before.id}`);
|
|
3800
|
+
}
|
|
3801
|
+
const served = bare(response.headers.get("content-type"));
|
|
3802
|
+
if (served === "image/svg+xml" || served && served !== "application/octet-stream" && served !== contentType) {
|
|
3803
|
+
return json({ error: "asset content type is inconsistent" }, 415);
|
|
3804
|
+
}
|
|
3805
|
+
const bytes = await boundedBytes(response, MAX_DISCUSSION_ASSET_BYTES);
|
|
3806
|
+
if (!bytes || bytes.byteLength !== before.size || !magicMatches(contentType, bytes) || await digest(bytes) !== before.contentDigest) {
|
|
3807
|
+
return json({ error: "asset bytes failed validation" }, 422);
|
|
3808
|
+
}
|
|
3809
|
+
const afterBook = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id);
|
|
3810
|
+
const after = await linkedAsset(ctx, afterBook, target.resourceId);
|
|
3811
|
+
if (!exactAsset(beforeBook, before, afterBook, after)) {
|
|
3812
|
+
return json({ error: "asset changed during inspection" }, 409);
|
|
3813
|
+
}
|
|
3814
|
+
return json({
|
|
3815
|
+
asset: {
|
|
3816
|
+
reference: { kind: "brand:asset", id: `${before.bookId}/${before.id}` },
|
|
3817
|
+
contentType,
|
|
3818
|
+
byteLength: bytes.byteLength,
|
|
3819
|
+
data: base64(bytes),
|
|
3820
|
+
taint: "untrusted_project_material"
|
|
3821
|
+
}
|
|
3822
|
+
}, 200, {
|
|
3823
|
+
"cache-control": "private, no-store",
|
|
3824
|
+
"x-content-type-options": "nosniff"
|
|
3825
|
+
});
|
|
3826
|
+
}
|
|
3827
|
+
function base64(bytes) {
|
|
3828
|
+
let binary = "";
|
|
3829
|
+
for (let index = 0; index < bytes.length; index += 8192) {
|
|
3830
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + 8192));
|
|
3831
|
+
}
|
|
3832
|
+
return btoa(binary);
|
|
3833
|
+
}
|
|
3834
|
+
|
|
2931
3835
|
// src/routes/discussion-references.ts
|
|
2932
3836
|
var capLimit = (raw) => {
|
|
2933
3837
|
const parsed = Number(raw ?? 20);
|
|
@@ -2959,6 +3863,7 @@ var projection = (req, ctx, book, target, row) => {
|
|
|
2959
3863
|
let summary = `${book.name} brand book`;
|
|
2960
3864
|
let status = book.status;
|
|
2961
3865
|
let destination = "Brand Studio \xB7 Brand book";
|
|
3866
|
+
let swatches;
|
|
2962
3867
|
if (target.kind === "brand:asset") {
|
|
2963
3868
|
const asset = row;
|
|
2964
3869
|
label = asset.title?.trim() || `${asset.kind} asset`;
|
|
@@ -2968,6 +3873,11 @@ var projection = (req, ctx, book, target, row) => {
|
|
|
2968
3873
|
destination = "Brand Studio \xB7 Conversation";
|
|
2969
3874
|
} else if (target.kind === "brand:palette") {
|
|
2970
3875
|
const palette = row;
|
|
3876
|
+
swatches = assertSwatches(palette.swatches).map((swatch) => ({
|
|
3877
|
+
role: swatch.role,
|
|
3878
|
+
hex: swatch.hex,
|
|
3879
|
+
...swatch.name ? { name: swatch.name } : {}
|
|
3880
|
+
}));
|
|
2971
3881
|
label = palette.name;
|
|
2972
3882
|
hint = `${book.name} \xB7 ${palette.status} palette`;
|
|
2973
3883
|
summary = `${palette.swatches.length}-color palette in ${book.name}`;
|
|
@@ -3000,7 +3910,8 @@ var projection = (req, ctx, book, target, row) => {
|
|
|
3000
3910
|
new URL(req.url),
|
|
3001
3911
|
target,
|
|
3002
3912
|
ctx.discussionBasePath
|
|
3003
|
-
)
|
|
3913
|
+
),
|
|
3914
|
+
...swatches ? { swatches } : {}
|
|
3004
3915
|
};
|
|
3005
3916
|
};
|
|
3006
3917
|
async function exact(ctx, req, target) {
|
|
@@ -3054,11 +3965,23 @@ async function search(ctx, req, url) {
|
|
|
3054
3965
|
}
|
|
3055
3966
|
async function handleBrandDiscussionReferences(ctx, req, url) {
|
|
3056
3967
|
if (req.method !== "GET") return methodNotAllowed();
|
|
3968
|
+
const inspect = url.searchParams.get("inspect");
|
|
3057
3969
|
const kind = url.searchParams.get("kind");
|
|
3058
3970
|
const id = url.searchParams.get("id");
|
|
3059
3971
|
if (kind && !id || !kind && id) return json({ error: "kind and id must be paired" }, 400);
|
|
3060
3972
|
const target = targetFromQuery(url);
|
|
3061
3973
|
if ((kind || id) && !target) return json({ items: [] });
|
|
3974
|
+
if (inspect === "asset-content") {
|
|
3975
|
+
const allowed = /* @__PURE__ */ new Set(["inspect", "kind", "id"]);
|
|
3976
|
+
if ([...url.searchParams.keys()].some((key) => !allowed.has(key)) || target?.kind !== "brand:asset" || !target.resourceId) return json({ error: "exact brand asset kind and id required" }, 400);
|
|
3977
|
+
return handleBrandDiscussionAsset(ctx, req, {
|
|
3978
|
+
bookId: target.bookId,
|
|
3979
|
+
resourceId: target.resourceId
|
|
3980
|
+
});
|
|
3981
|
+
}
|
|
3982
|
+
if (inspect !== null && inspect !== "1") {
|
|
3983
|
+
return json({ error: "invalid inspection mode" }, 400);
|
|
3984
|
+
}
|
|
3062
3985
|
return json({
|
|
3063
3986
|
items: target ? await exact(ctx, req, target) : await search(ctx, req, url)
|
|
3064
3987
|
});
|
|
@@ -3079,6 +4002,10 @@ async function route(ctx, req, url, seg) {
|
|
|
3079
4002
|
if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
|
|
3080
4003
|
if (seg.length === 5 && action === "content")
|
|
3081
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);
|
|
3082
4009
|
return null;
|
|
3083
4010
|
}
|
|
3084
4011
|
if (sub === "proposals" && seg.length === 5 && action === "resolve")
|
|
@@ -3099,7 +4026,9 @@ function createBrandRoutes(options) {
|
|
|
3099
4026
|
now: options.now ?? Date.now,
|
|
3100
4027
|
newId: options.newId ?? (() => crypto.randomUUID()),
|
|
3101
4028
|
maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
|
|
3102
|
-
discussionBasePath: options.discussionBasePath ?? "/"
|
|
4029
|
+
discussionBasePath: options.discussionBasePath ?? "/",
|
|
4030
|
+
fetchPrivateAsset: options.fetchPrivateAsset ?? fetch,
|
|
4031
|
+
previewFrameAncestors: options.previewFrameAncestors ?? ["'self'"]
|
|
3103
4032
|
};
|
|
3104
4033
|
return async (req) => {
|
|
3105
4034
|
const url = new URL(req.url);
|
|
@@ -3392,7 +4321,16 @@ export {
|
|
|
3392
4321
|
DARK_FLIP_L_MIN,
|
|
3393
4322
|
DEFAULT_ACCENT_SEED,
|
|
3394
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,
|
|
3395
4332
|
MAX_SWATCHES,
|
|
4333
|
+
MAX_THUMBNAIL_CHARS,
|
|
3396
4334
|
MAX_VIEW_BYTES,
|
|
3397
4335
|
PALETTE_SOURCES,
|
|
3398
4336
|
PALETTE_STATUSES,
|
|
@@ -3401,6 +4339,7 @@ export {
|
|
|
3401
4339
|
PROPOSAL_STATUSES,
|
|
3402
4340
|
RAMP_L_MAX,
|
|
3403
4341
|
RAMP_L_MIN,
|
|
4342
|
+
ROLE_TOKEN,
|
|
3404
4343
|
SECTION_KINDS,
|
|
3405
4344
|
SECTION_STATUSES,
|
|
3406
4345
|
SWATCH_ROLES,
|
|
@@ -3415,6 +4354,7 @@ export {
|
|
|
3415
4354
|
assetTools,
|
|
3416
4355
|
attachedBrandAssetIds,
|
|
3417
4356
|
audienceFanoutOps,
|
|
4357
|
+
base64ByteLength,
|
|
3418
4358
|
base64FromBytes,
|
|
3419
4359
|
beginAssetDeleteOps,
|
|
3420
4360
|
bookForChannel,
|
|
@@ -3433,27 +4373,43 @@ export {
|
|
|
3433
4373
|
capStringArray,
|
|
3434
4374
|
clamp01,
|
|
3435
4375
|
clampToGamut,
|
|
4376
|
+
collapseWhitespace,
|
|
3436
4377
|
compileBrandTokens,
|
|
3437
4378
|
complementary,
|
|
3438
4379
|
contrastRatio,
|
|
4380
|
+
contrastReport,
|
|
3439
4381
|
createAssetOps,
|
|
3440
4382
|
createBookOps,
|
|
3441
4383
|
createBrandIntegration,
|
|
3442
4384
|
createBrandPersona,
|
|
3443
4385
|
createBrandRoutes,
|
|
4386
|
+
cssColorToHex,
|
|
4387
|
+
decodeEntities,
|
|
3444
4388
|
deltaEOK,
|
|
3445
4389
|
deltaEOKLab,
|
|
3446
4390
|
deriveChartColors,
|
|
3447
4391
|
deriveDarkTokens,
|
|
3448
4392
|
derivePalette,
|
|
4393
|
+
digestDesignBundle,
|
|
4394
|
+
digestDesignHtml,
|
|
3449
4395
|
dispatchBrandTurn,
|
|
4396
|
+
extractColors,
|
|
4397
|
+
extractDesignTokens,
|
|
4398
|
+
extractFonts,
|
|
4399
|
+
extractOutline,
|
|
4400
|
+
extractProps,
|
|
4401
|
+
extractThumbnailSvg,
|
|
4402
|
+
extractTitle,
|
|
3450
4403
|
finishAssetDeleteOps,
|
|
3451
4404
|
formatBrandDiscussionReference,
|
|
4405
|
+
groupAssets,
|
|
3452
4406
|
hexToOklch,
|
|
3453
4407
|
hslToRgb,
|
|
4408
|
+
htmlToText,
|
|
3454
4409
|
inSrgbGamut,
|
|
3455
4410
|
isBoundedBrandJson,
|
|
3456
4411
|
isBrandHumanAuthorityConsumption,
|
|
4412
|
+
isDesignBundle,
|
|
3457
4413
|
linearToSrgb,
|
|
3458
4414
|
mapPaletteToTokens,
|
|
3459
4415
|
meetsAA,
|
|
@@ -3468,25 +4424,32 @@ export {
|
|
|
3468
4424
|
paletteTools,
|
|
3469
4425
|
parseBrandDiscussionReference,
|
|
3470
4426
|
parseBrandDispatch,
|
|
4427
|
+
parseDesignBundle,
|
|
3471
4428
|
parseHex,
|
|
3472
4429
|
pickTextOn,
|
|
3473
4430
|
proposalReviewSnapshot,
|
|
3474
4431
|
proposePaletteOps,
|
|
3475
4432
|
proposeSectionOps,
|
|
4433
|
+
readDesignManifest,
|
|
3476
4434
|
readTools,
|
|
3477
4435
|
recordAnalysisOps,
|
|
3478
4436
|
rejectProposalOps,
|
|
3479
4437
|
relativeLuminance,
|
|
3480
4438
|
renderTokensCss,
|
|
3481
4439
|
resolveDeps,
|
|
4440
|
+
resolveVarRefs,
|
|
3482
4441
|
rgbToHsl,
|
|
3483
4442
|
rgbToOklab,
|
|
3484
4443
|
rotateHue,
|
|
3485
4444
|
safeFileName,
|
|
4445
|
+
scanCustomProperties,
|
|
3486
4446
|
sectionKey,
|
|
3487
4447
|
splitComplementary,
|
|
3488
4448
|
srgbToLinear,
|
|
4449
|
+
stripCssComments,
|
|
4450
|
+
styleSheetText,
|
|
3489
4451
|
supportsBrandVision,
|
|
4452
|
+
swatchesFromDesignTokens,
|
|
3490
4453
|
tetradic,
|
|
3491
4454
|
tintShadeRamp,
|
|
3492
4455
|
toHex,
|