@odla-ai/brand 0.1.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 +68 -0
- package/dist/index.cjs +2648 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1342 -0
- package/dist/index.d.ts +1342 -0
- package/dist/index.js +2625 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2625 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var BRAND_NS = {
|
|
3
|
+
book: "brand_book",
|
|
4
|
+
section: "brand_section",
|
|
5
|
+
palette: "brand_palette",
|
|
6
|
+
proposal: "brand_proposal",
|
|
7
|
+
asset: "brand_asset"
|
|
8
|
+
};
|
|
9
|
+
var BOOK_STATUSES = ["draft", "active", "archived"];
|
|
10
|
+
var SECTION_KINDS = ["palette", "typography", "voice", "logo", "imagery"];
|
|
11
|
+
var SECTION_STATUSES = ["draft", "approved"];
|
|
12
|
+
var PALETTE_STATUSES = ["active", "archived"];
|
|
13
|
+
var PALETTE_SOURCES = ["extracted", "derived", "manual"];
|
|
14
|
+
var PROPOSAL_KINDS = ["palette", "typography", "voice", "logo"];
|
|
15
|
+
var PROPOSAL_STATUSES = ["open", "accepted", "rejected", "superseded"];
|
|
16
|
+
var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "font", "other"];
|
|
17
|
+
var SWATCH_ROLES = [
|
|
18
|
+
"primary",
|
|
19
|
+
"secondary",
|
|
20
|
+
"highlight",
|
|
21
|
+
"bg",
|
|
22
|
+
"surface",
|
|
23
|
+
"text",
|
|
24
|
+
"neutral",
|
|
25
|
+
"good",
|
|
26
|
+
"warn",
|
|
27
|
+
"danger",
|
|
28
|
+
"chart",
|
|
29
|
+
"custom"
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// src/errors.ts
|
|
33
|
+
var BrandInputError = class extends Error {
|
|
34
|
+
fields;
|
|
35
|
+
constructor(message, fields) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "BrandInputError";
|
|
38
|
+
this.fields = fields;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var BrandNotFoundError = class extends Error {
|
|
42
|
+
constructor(what) {
|
|
43
|
+
super(`${what} not found`);
|
|
44
|
+
this.name = "BrandNotFoundError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/deps.ts
|
|
49
|
+
async function defaultFetchBytes(url) {
|
|
50
|
+
const res = await fetch(url);
|
|
51
|
+
if (!res.ok) throw new Error(`asset fetch failed: ${res.status} for ${url}`);
|
|
52
|
+
return {
|
|
53
|
+
bytes: new Uint8Array(await res.arrayBuffer()),
|
|
54
|
+
contentType: res.headers.get("content-type") ?? "application/octet-stream"
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function resolveDeps(deps) {
|
|
58
|
+
return {
|
|
59
|
+
db: deps.db,
|
|
60
|
+
now: deps.now ?? Date.now,
|
|
61
|
+
newId: deps.newId ?? (() => crypto.randomUUID()),
|
|
62
|
+
fetchBytes: deps.fetchBytes ?? defaultFetchBytes
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/schema.ts
|
|
67
|
+
var a = (type, o = {}) => ({
|
|
68
|
+
type,
|
|
69
|
+
unique: false,
|
|
70
|
+
indexed: false,
|
|
71
|
+
optional: false,
|
|
72
|
+
...o
|
|
73
|
+
});
|
|
74
|
+
var uniq = (type) => a(type, { unique: true, indexed: true });
|
|
75
|
+
var idx = (type, optional = false) => a(type, { indexed: true, optional });
|
|
76
|
+
var opt = (type) => a(type, { optional: true });
|
|
77
|
+
var BRAND_SCHEMA = {
|
|
78
|
+
entities: {
|
|
79
|
+
[BRAND_NS.book]: {
|
|
80
|
+
attrs: {
|
|
81
|
+
id: uniq("string"),
|
|
82
|
+
slug: uniq("string"),
|
|
83
|
+
name: idx("string"),
|
|
84
|
+
status: idx("string"),
|
|
85
|
+
// draft | active | archived
|
|
86
|
+
ownerId: idx("string"),
|
|
87
|
+
memberIds: a("json"),
|
|
88
|
+
// the auth roster, incl. the bot agent id
|
|
89
|
+
channelId: idx("string", true),
|
|
90
|
+
activePaletteId: opt("string"),
|
|
91
|
+
tokens: opt("json"),
|
|
92
|
+
// { light, dark, warnings, compiledAt }
|
|
93
|
+
summary: opt("string"),
|
|
94
|
+
createdAt: idx("date"),
|
|
95
|
+
updatedAt: idx("date")
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[BRAND_NS.section]: {
|
|
99
|
+
attrs: {
|
|
100
|
+
key: uniq("string"),
|
|
101
|
+
// `${bookId}:${kind}` — one section per kind
|
|
102
|
+
bookId: idx("string"),
|
|
103
|
+
kind: idx("string"),
|
|
104
|
+
// palette | typography | voice | logo | imagery
|
|
105
|
+
status: idx("string"),
|
|
106
|
+
// draft | approved
|
|
107
|
+
content: a("json"),
|
|
108
|
+
audience: a("json"),
|
|
109
|
+
updatedBy: a("string"),
|
|
110
|
+
updatedAt: idx("date")
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
[BRAND_NS.palette]: {
|
|
114
|
+
attrs: {
|
|
115
|
+
id: uniq("string"),
|
|
116
|
+
bookId: idx("string"),
|
|
117
|
+
name: a("string"),
|
|
118
|
+
status: idx("string"),
|
|
119
|
+
// active | archived
|
|
120
|
+
swatches: a("json"),
|
|
121
|
+
// Swatch[] — see the header design note
|
|
122
|
+
seedHex: opt("string"),
|
|
123
|
+
source: a("string"),
|
|
124
|
+
// extracted | derived | manual
|
|
125
|
+
rationale: opt("string"),
|
|
126
|
+
proposalId: opt("string"),
|
|
127
|
+
audience: a("json"),
|
|
128
|
+
createdAt: idx("date"),
|
|
129
|
+
updatedAt: idx("date")
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
[BRAND_NS.proposal]: {
|
|
133
|
+
attrs: {
|
|
134
|
+
id: uniq("string"),
|
|
135
|
+
bookId: idx("string"),
|
|
136
|
+
kind: idx("string"),
|
|
137
|
+
// palette | typography | voice | logo
|
|
138
|
+
status: idx("string"),
|
|
139
|
+
// open | accepted | rejected | superseded
|
|
140
|
+
payload: a("json"),
|
|
141
|
+
rationale: a("string"),
|
|
142
|
+
sourceAssetId: opt("string"),
|
|
143
|
+
messageId: opt("string"),
|
|
144
|
+
audience: a("json"),
|
|
145
|
+
createdBy: a("string"),
|
|
146
|
+
createdAt: idx("date"),
|
|
147
|
+
resolvedBy: opt("string"),
|
|
148
|
+
resolvedAt: opt("date"),
|
|
149
|
+
resolutionNote: opt("string")
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
[BRAND_NS.asset]: {
|
|
153
|
+
attrs: {
|
|
154
|
+
id: uniq("string"),
|
|
155
|
+
bookId: idx("string"),
|
|
156
|
+
kind: idx("string"),
|
|
157
|
+
// logo | wordmark | inspiration | document | font | other
|
|
158
|
+
path: idx("string"),
|
|
159
|
+
url: a("string"),
|
|
160
|
+
contentType: a("string"),
|
|
161
|
+
size: a("number"),
|
|
162
|
+
title: opt("string"),
|
|
163
|
+
analysis: opt("json"),
|
|
164
|
+
// { description, dominantColors: hex[], tags }
|
|
165
|
+
analyzedAt: opt("date"),
|
|
166
|
+
audience: a("json"),
|
|
167
|
+
uploadedBy: a("string"),
|
|
168
|
+
createdAt: idx("date"),
|
|
169
|
+
deletedAt: opt("date")
|
|
170
|
+
// tombstone — asset rows are never row-deleted
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
links: {}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// src/rules.ts
|
|
178
|
+
var AUDIENCE = "auth.id in data.audience";
|
|
179
|
+
var BRAND_RULES = {
|
|
180
|
+
[BRAND_NS.book]: {
|
|
181
|
+
view: "auth.id in data.memberIds",
|
|
182
|
+
// Creator is the owner and must include itself in the roster.
|
|
183
|
+
create: "auth.signedIn && auth.id == data.ownerId && auth.id in data.memberIds",
|
|
184
|
+
update: "auth.id == data.ownerId",
|
|
185
|
+
delete: "auth.id == data.ownerId"
|
|
186
|
+
},
|
|
187
|
+
[BRAND_NS.section]: {
|
|
188
|
+
view: AUDIENCE,
|
|
189
|
+
create: AUDIENCE,
|
|
190
|
+
update: AUDIENCE,
|
|
191
|
+
delete: "false"
|
|
192
|
+
// sections are upserted in place, never removed
|
|
193
|
+
},
|
|
194
|
+
[BRAND_NS.palette]: {
|
|
195
|
+
view: AUDIENCE,
|
|
196
|
+
create: AUDIENCE,
|
|
197
|
+
update: AUDIENCE,
|
|
198
|
+
delete: "false"
|
|
199
|
+
// archive via status, keep provenance
|
|
200
|
+
},
|
|
201
|
+
[BRAND_NS.proposal]: {
|
|
202
|
+
view: AUDIENCE,
|
|
203
|
+
create: AUDIENCE,
|
|
204
|
+
update: AUDIENCE,
|
|
205
|
+
delete: "false"
|
|
206
|
+
// resolution history is the audit trail
|
|
207
|
+
},
|
|
208
|
+
[BRAND_NS.asset]: {
|
|
209
|
+
view: "false",
|
|
210
|
+
create: "false",
|
|
211
|
+
update: "false",
|
|
212
|
+
delete: "false"
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
function brandRules() {
|
|
216
|
+
return Object.fromEntries(Object.entries(BRAND_RULES).map(([ns, r]) => [ns, { ...r }]));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/validate.ts
|
|
220
|
+
var MAX_SWATCHES = 24;
|
|
221
|
+
var ASSET_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
222
|
+
"image/png",
|
|
223
|
+
"image/jpeg",
|
|
224
|
+
"image/gif",
|
|
225
|
+
"image/webp",
|
|
226
|
+
"image/svg+xml",
|
|
227
|
+
"application/pdf"
|
|
228
|
+
]);
|
|
229
|
+
var HEX_RGB = /^#[0-9a-f]{3}$/;
|
|
230
|
+
var HEX_RRGGBB = /^#[0-9a-f]{6}$/;
|
|
231
|
+
var HEX_ALPHA = /^#[0-9a-f]{4}$|^#[0-9a-f]{8}$/;
|
|
232
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
233
|
+
function assertHex(value, label = "color") {
|
|
234
|
+
if (typeof value !== "string")
|
|
235
|
+
throw new BrandInputError(`${label} must be a hex string like #1a2b3c`);
|
|
236
|
+
const hex = value.trim().toLowerCase();
|
|
237
|
+
if (HEX_ALPHA.test(hex))
|
|
238
|
+
throw new BrandInputError(`${label} must not carry alpha (got ${value}); use #rrggbb`);
|
|
239
|
+
if (HEX_RGB.test(hex)) return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
|
|
240
|
+
if (!HEX_RRGGBB.test(hex))
|
|
241
|
+
throw new BrandInputError(`${label} must be #rgb or #rrggbb hex (got ${value})`);
|
|
242
|
+
return hex;
|
|
243
|
+
}
|
|
244
|
+
function capString(value, label, max) {
|
|
245
|
+
if (typeof value !== "string") throw new BrandInputError(`${label} must be a string`);
|
|
246
|
+
const s = value.trim();
|
|
247
|
+
if (s === "") throw new BrandInputError(`${label} must not be empty`);
|
|
248
|
+
if (s.length > max)
|
|
249
|
+
throw new BrandInputError(`${label} must be at most ${max} characters (got ${s.length})`);
|
|
250
|
+
return s;
|
|
251
|
+
}
|
|
252
|
+
function capStringArray(value, label, opts) {
|
|
253
|
+
if (!Array.isArray(value)) throw new BrandInputError(`${label} must be an array of strings`);
|
|
254
|
+
const min = opts.minItems ?? 0;
|
|
255
|
+
if (value.length < min)
|
|
256
|
+
throw new BrandInputError(`${label} must have at least ${min} item${min === 1 ? "" : "s"}`);
|
|
257
|
+
if (value.length > opts.maxItems)
|
|
258
|
+
throw new BrandInputError(`${label} must have at most ${opts.maxItems} items`);
|
|
259
|
+
return value.map((v, i) => capString(v, `${label}[${i}]`, opts.maxLen));
|
|
260
|
+
}
|
|
261
|
+
function assertSwatches(value) {
|
|
262
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
263
|
+
throw new BrandInputError("swatches must be a non-empty array");
|
|
264
|
+
if (value.length > MAX_SWATCHES)
|
|
265
|
+
throw new BrandInputError(`swatches must have at most ${MAX_SWATCHES} entries`);
|
|
266
|
+
return value.map((raw, i) => {
|
|
267
|
+
if (!isRecord(raw)) throw new BrandInputError(`swatches[${i}] must be an object`);
|
|
268
|
+
const role = raw.role;
|
|
269
|
+
if (typeof role !== "string" || !SWATCH_ROLES.includes(role))
|
|
270
|
+
throw new BrandInputError(
|
|
271
|
+
`swatches[${i}].role must be one of: ${SWATCH_ROLES.join(", ")}`
|
|
272
|
+
);
|
|
273
|
+
const out = { role, hex: assertHex(raw.hex, `swatches[${i}].hex`) };
|
|
274
|
+
if (raw.name !== void 0) out.name = capString(raw.name, `swatches[${i}].name`, 80);
|
|
275
|
+
if (raw.rationale !== void 0)
|
|
276
|
+
out.rationale = capString(raw.rationale, `swatches[${i}].rationale`, 500);
|
|
277
|
+
return out;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
function assertPaletteSection(c) {
|
|
281
|
+
return {
|
|
282
|
+
paletteId: capString(c.paletteId, "content.paletteId", 128),
|
|
283
|
+
name: capString(c.name, "content.name", 120),
|
|
284
|
+
swatches: assertSwatches(c.swatches)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function assertTypographySection(c) {
|
|
288
|
+
const out = {};
|
|
289
|
+
if (c.fontDisplay !== void 0) out.fontDisplay = capString(c.fontDisplay, "content.fontDisplay", 120);
|
|
290
|
+
if (c.fontBody !== void 0) out.fontBody = capString(c.fontBody, "content.fontBody", 120);
|
|
291
|
+
if (c.fontMono !== void 0) out.fontMono = capString(c.fontMono, "content.fontMono", 120);
|
|
292
|
+
if (c.scale !== void 0) {
|
|
293
|
+
if (typeof c.scale !== "number" || !Number.isFinite(c.scale) || c.scale <= 1 || c.scale > 2)
|
|
294
|
+
throw new BrandInputError("content.scale must be a modular type-scale ratio in (1, 2]");
|
|
295
|
+
out.scale = c.scale;
|
|
296
|
+
}
|
|
297
|
+
if (c.notes !== void 0) out.notes = capString(c.notes, "content.notes", 2e3);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
function assertVoiceSection(c) {
|
|
301
|
+
const out = {
|
|
302
|
+
tone: capString(c.tone, "content.tone", 200),
|
|
303
|
+
principles: capStringArray(c.principles, "content.principles", { maxItems: 12, maxLen: 200, minItems: 1 })
|
|
304
|
+
};
|
|
305
|
+
if (c.examples !== void 0)
|
|
306
|
+
out.examples = capStringArray(c.examples, "content.examples", { maxItems: 12, maxLen: 500 });
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
function assertLogoSection(c) {
|
|
310
|
+
const out = {
|
|
311
|
+
usage: capStringArray(c.usage, "content.usage", { maxItems: 16, maxLen: 300, minItems: 1 }),
|
|
312
|
+
donts: capStringArray(c.donts, "content.donts", { maxItems: 16, maxLen: 300 })
|
|
313
|
+
};
|
|
314
|
+
if (c.clearspace !== void 0) out.clearspace = capString(c.clearspace, "content.clearspace", 120);
|
|
315
|
+
if (c.minSize !== void 0) out.minSize = capString(c.minSize, "content.minSize", 120);
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
function assertImagerySection(c) {
|
|
319
|
+
return {
|
|
320
|
+
style: capString(c.style, "content.style", 200),
|
|
321
|
+
guidance: capStringArray(c.guidance, "content.guidance", { maxItems: 16, maxLen: 300, minItems: 1 })
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function assertSectionContent(kind, content) {
|
|
325
|
+
if (!isRecord(content)) throw new BrandInputError("content must be an object");
|
|
326
|
+
switch (kind) {
|
|
327
|
+
case "palette":
|
|
328
|
+
return { ...assertPaletteSection(content) };
|
|
329
|
+
case "typography":
|
|
330
|
+
return { ...assertTypographySection(content) };
|
|
331
|
+
case "voice":
|
|
332
|
+
return { ...assertVoiceSection(content) };
|
|
333
|
+
case "logo":
|
|
334
|
+
return { ...assertLogoSection(content) };
|
|
335
|
+
case "imagery":
|
|
336
|
+
return { ...assertImagerySection(content) };
|
|
337
|
+
default:
|
|
338
|
+
throw new BrandInputError(`unknown section kind ${kind}; expected one of: ${SECTION_KINDS.join(", ")}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function safeFileName(name) {
|
|
342
|
+
if (typeof name !== "string") throw new BrandInputError("file name must be a string");
|
|
343
|
+
const cleaned = name.replace(/[/\\]/g, "").replace(/[\u0000-\u001f\u007f]/g, "").trim().replace(/^\.+/, "");
|
|
344
|
+
if (cleaned === "") throw new BrandInputError("file name is empty after sanitizing");
|
|
345
|
+
return cleaned.slice(0, 120);
|
|
346
|
+
}
|
|
347
|
+
function assertAssetContentType(value) {
|
|
348
|
+
if (typeof value !== "string") throw new BrandInputError("contentType must be a string");
|
|
349
|
+
const ct = value.split(";")[0].trim().toLowerCase();
|
|
350
|
+
if (!ASSET_CONTENT_TYPES.has(ct))
|
|
351
|
+
throw new BrandInputError(
|
|
352
|
+
`unsupported content type ${ct || "(empty)"}; allowed: ${[...ASSET_CONTENT_TYPES].join(", ")}`
|
|
353
|
+
);
|
|
354
|
+
return ct;
|
|
355
|
+
}
|
|
356
|
+
function assertAnalysis(value) {
|
|
357
|
+
if (!isRecord(value)) throw new BrandInputError("analysis must be an object");
|
|
358
|
+
const colors = value.dominantColors;
|
|
359
|
+
if (!Array.isArray(colors)) throw new BrandInputError("analysis.dominantColors must be an array");
|
|
360
|
+
if (colors.length > 12)
|
|
361
|
+
throw new BrandInputError("analysis.dominantColors must have at most 12 entries");
|
|
362
|
+
return {
|
|
363
|
+
description: capString(value.description, "analysis.description", 2e3),
|
|
364
|
+
dominantColors: colors.map((c, i) => assertHex(c, `analysis.dominantColors[${i}]`)),
|
|
365
|
+
tags: capStringArray(value.tags, "analysis.tags", { maxItems: 24, maxLen: 60 })
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/ops/books.ts
|
|
370
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
371
|
+
function createBookOps(input) {
|
|
372
|
+
const slug = capString(input.slug, "slug", 80);
|
|
373
|
+
if (!SLUG_RE.test(slug))
|
|
374
|
+
throw new BrandInputError("slug must be lowercase letters, digits, and inner hyphens");
|
|
375
|
+
const name = capString(input.name, "name", 120);
|
|
376
|
+
const memberIds = Array.from(/* @__PURE__ */ new Set([input.ownerId, ...input.memberIds]));
|
|
377
|
+
return [
|
|
378
|
+
{
|
|
379
|
+
t: "update",
|
|
380
|
+
ns: BRAND_NS.book,
|
|
381
|
+
id: input.id,
|
|
382
|
+
attrs: {
|
|
383
|
+
id: input.id,
|
|
384
|
+
slug,
|
|
385
|
+
name,
|
|
386
|
+
status: "draft",
|
|
387
|
+
ownerId: input.ownerId,
|
|
388
|
+
memberIds,
|
|
389
|
+
createdAt: input.now,
|
|
390
|
+
updatedAt: input.now,
|
|
391
|
+
...input.channelId ? { channelId: input.channelId } : {}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary", "activePaletteId", "tokens"]);
|
|
397
|
+
var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary", "activePaletteId", "tokens"]);
|
|
398
|
+
function validateBookField(key, value) {
|
|
399
|
+
switch (key) {
|
|
400
|
+
case "name":
|
|
401
|
+
return capString(value, "name", 120);
|
|
402
|
+
case "status":
|
|
403
|
+
if (typeof value !== "string" || !BOOK_STATUSES.includes(value))
|
|
404
|
+
throw new BrandInputError(`status must be one of: ${BOOK_STATUSES.join(", ")}`);
|
|
405
|
+
return value;
|
|
406
|
+
case "channelId":
|
|
407
|
+
return capString(value, "channelId", 128);
|
|
408
|
+
case "summary":
|
|
409
|
+
return capString(value, "summary", 2e3);
|
|
410
|
+
case "activePaletteId":
|
|
411
|
+
return capString(value, "activePaletteId", 128);
|
|
412
|
+
default: {
|
|
413
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
414
|
+
throw new BrandInputError("tokens must be an object");
|
|
415
|
+
return value;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function updateBookOps(bookId, patch, now) {
|
|
420
|
+
const attrs = {};
|
|
421
|
+
const retract = [];
|
|
422
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
423
|
+
if (value === void 0) continue;
|
|
424
|
+
if (!PATCHABLE.has(key)) throw new BrandInputError(`unknown book field: ${key}`);
|
|
425
|
+
if (value === null) {
|
|
426
|
+
if (!CLEARABLE.has(key)) throw new BrandInputError(`${key} is required and cannot be cleared`);
|
|
427
|
+
retract.push(key);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
attrs[key] = validateBookField(key, value);
|
|
431
|
+
}
|
|
432
|
+
if (Object.keys(attrs).length === 0 && retract.length === 0) return [];
|
|
433
|
+
const ops = [
|
|
434
|
+
{ t: "update", ns: BRAND_NS.book, id: bookId, attrs: { ...attrs, updatedAt: now } }
|
|
435
|
+
];
|
|
436
|
+
if (retract.length > 0) ops.push({ t: "retract", ns: BRAND_NS.book, id: bookId, attrs: retract });
|
|
437
|
+
return ops;
|
|
438
|
+
}
|
|
439
|
+
function audienceFanoutOps(book, newMemberIds, children, now) {
|
|
440
|
+
const memberIds = Array.from(/* @__PURE__ */ new Set([book.ownerId, ...newMemberIds]));
|
|
441
|
+
const ops = [
|
|
442
|
+
{ t: "update", ns: BRAND_NS.book, id: book.id, attrs: { memberIds, updatedAt: now } }
|
|
443
|
+
];
|
|
444
|
+
const fan = (ns, rows) => {
|
|
445
|
+
for (const row of rows ?? []) ops.push({ t: "update", ns, id: row.id, attrs: { audience: memberIds } });
|
|
446
|
+
};
|
|
447
|
+
fan(BRAND_NS.section, children.sections);
|
|
448
|
+
fan(BRAND_NS.palette, children.palettes);
|
|
449
|
+
fan(BRAND_NS.proposal, children.proposals);
|
|
450
|
+
fan(BRAND_NS.asset, children.assets);
|
|
451
|
+
return ops;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/ops/sections.ts
|
|
455
|
+
function sectionKey(bookId, kind) {
|
|
456
|
+
return `${bookId}:${kind}`;
|
|
457
|
+
}
|
|
458
|
+
function upsertSectionOps(input) {
|
|
459
|
+
const status = input.status ?? "draft";
|
|
460
|
+
if (!SECTION_STATUSES.includes(status))
|
|
461
|
+
throw new BrandInputError(`status must be one of: ${SECTION_STATUSES.join(", ")}`);
|
|
462
|
+
const content = assertSectionContent(input.kind, input.content);
|
|
463
|
+
const key = sectionKey(input.bookId, input.kind);
|
|
464
|
+
return [
|
|
465
|
+
{
|
|
466
|
+
t: "update",
|
|
467
|
+
ns: BRAND_NS.section,
|
|
468
|
+
id: { ns: BRAND_NS.section, attr: "key", value: key },
|
|
469
|
+
attrs: {
|
|
470
|
+
key,
|
|
471
|
+
bookId: input.bookId,
|
|
472
|
+
kind: input.kind,
|
|
473
|
+
status,
|
|
474
|
+
content,
|
|
475
|
+
audience: input.audience,
|
|
476
|
+
updatedBy: input.updatedBy,
|
|
477
|
+
updatedAt: input.now
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/ops/palettes.ts
|
|
484
|
+
function proposePaletteOps(input) {
|
|
485
|
+
const name = capString(input.name, "name", 120);
|
|
486
|
+
const rationale = capString(input.rationale, "rationale", 2e3);
|
|
487
|
+
const swatches = assertSwatches(input.swatches);
|
|
488
|
+
const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
|
|
489
|
+
const payload = {
|
|
490
|
+
name,
|
|
491
|
+
swatches,
|
|
492
|
+
...seedHex ? { seedHex } : {},
|
|
493
|
+
...input.contrastReport !== void 0 ? { contrastReport: input.contrastReport } : {}
|
|
494
|
+
};
|
|
495
|
+
return [
|
|
496
|
+
{
|
|
497
|
+
t: "update",
|
|
498
|
+
ns: BRAND_NS.proposal,
|
|
499
|
+
id: input.id,
|
|
500
|
+
attrs: {
|
|
501
|
+
id: input.id,
|
|
502
|
+
bookId: input.bookId,
|
|
503
|
+
kind: "palette",
|
|
504
|
+
status: "open",
|
|
505
|
+
payload,
|
|
506
|
+
rationale,
|
|
507
|
+
audience: input.audience,
|
|
508
|
+
createdBy: input.createdBy,
|
|
509
|
+
createdAt: input.now,
|
|
510
|
+
...input.sourceAssetId ? { sourceAssetId: input.sourceAssetId } : {},
|
|
511
|
+
...input.messageId ? { messageId: input.messageId } : {}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
function acceptProposalOps(input) {
|
|
517
|
+
const { proposal, paletteId, resolvedBy, now } = input;
|
|
518
|
+
if (proposal.kind !== "palette")
|
|
519
|
+
throw new BrandInputError(`proposal ${proposal.id} is a ${proposal.kind} proposal, not a palette`);
|
|
520
|
+
if (proposal.status !== "open")
|
|
521
|
+
throw new BrandInputError(`proposal ${proposal.id} is ${proposal.status}, not open`);
|
|
522
|
+
const payload = proposal.payload;
|
|
523
|
+
const name = capString(payload.name, "payload.name", 120);
|
|
524
|
+
const swatches = assertSwatches(payload.swatches);
|
|
525
|
+
const seedHex = payload.seedHex === void 0 ? void 0 : assertHex(payload.seedHex, "payload.seedHex");
|
|
526
|
+
const source = proposal.sourceAssetId ? "extracted" : seedHex ? "derived" : "manual";
|
|
527
|
+
return [
|
|
528
|
+
{
|
|
529
|
+
t: "update",
|
|
530
|
+
ns: BRAND_NS.palette,
|
|
531
|
+
id: paletteId,
|
|
532
|
+
attrs: {
|
|
533
|
+
id: paletteId,
|
|
534
|
+
bookId: proposal.bookId,
|
|
535
|
+
name,
|
|
536
|
+
status: "active",
|
|
537
|
+
swatches,
|
|
538
|
+
source,
|
|
539
|
+
rationale: proposal.rationale,
|
|
540
|
+
proposalId: proposal.id,
|
|
541
|
+
audience: proposal.audience,
|
|
542
|
+
createdAt: now,
|
|
543
|
+
updatedAt: now,
|
|
544
|
+
...seedHex ? { seedHex } : {}
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
...upsertSectionOps({
|
|
548
|
+
bookId: proposal.bookId,
|
|
549
|
+
kind: "palette",
|
|
550
|
+
content: { paletteId, name, swatches },
|
|
551
|
+
status: "approved",
|
|
552
|
+
audience: proposal.audience,
|
|
553
|
+
updatedBy: resolvedBy,
|
|
554
|
+
now
|
|
555
|
+
}),
|
|
556
|
+
{
|
|
557
|
+
t: "update",
|
|
558
|
+
ns: BRAND_NS.book,
|
|
559
|
+
id: proposal.bookId,
|
|
560
|
+
attrs: { activePaletteId: paletteId, updatedAt: now }
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
t: "update",
|
|
564
|
+
ns: BRAND_NS.proposal,
|
|
565
|
+
id: proposal.id,
|
|
566
|
+
attrs: {
|
|
567
|
+
status: "accepted",
|
|
568
|
+
resolvedBy,
|
|
569
|
+
resolvedAt: now,
|
|
570
|
+
...input.resolutionNote ? { resolutionNote: input.resolutionNote } : {}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
];
|
|
574
|
+
}
|
|
575
|
+
function rejectProposalOps(input) {
|
|
576
|
+
const { proposal, resolvedBy, now } = input;
|
|
577
|
+
if (proposal.status !== "open")
|
|
578
|
+
throw new BrandInputError(`proposal ${proposal.id} is ${proposal.status}, not open`);
|
|
579
|
+
return [
|
|
580
|
+
{
|
|
581
|
+
t: "update",
|
|
582
|
+
ns: BRAND_NS.proposal,
|
|
583
|
+
id: proposal.id,
|
|
584
|
+
attrs: {
|
|
585
|
+
status: "rejected",
|
|
586
|
+
resolvedBy,
|
|
587
|
+
resolvedAt: now,
|
|
588
|
+
...input.resolutionNote ? { resolutionNote: input.resolutionNote } : {}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
];
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/ops/assets.ts
|
|
595
|
+
function createAssetOps(input) {
|
|
596
|
+
if (!ASSET_KINDS.includes(input.kind))
|
|
597
|
+
throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
|
|
598
|
+
const contentType = assertAssetContentType(input.contentType);
|
|
599
|
+
if (typeof input.size !== "number" || !Number.isFinite(input.size) || input.size <= 0)
|
|
600
|
+
throw new BrandInputError("size must be a positive byte count");
|
|
601
|
+
const title = input.title === void 0 ? void 0 : capString(input.title, "title", 160);
|
|
602
|
+
return [
|
|
603
|
+
{
|
|
604
|
+
t: "update",
|
|
605
|
+
ns: BRAND_NS.asset,
|
|
606
|
+
id: input.id,
|
|
607
|
+
attrs: {
|
|
608
|
+
id: input.id,
|
|
609
|
+
bookId: input.bookId,
|
|
610
|
+
kind: input.kind,
|
|
611
|
+
path: capString(input.path, "path", 512),
|
|
612
|
+
url: capString(input.url, "url", 1024),
|
|
613
|
+
contentType,
|
|
614
|
+
size: input.size,
|
|
615
|
+
audience: input.audience,
|
|
616
|
+
uploadedBy: input.uploadedBy,
|
|
617
|
+
createdAt: input.now,
|
|
618
|
+
...title ? { title } : {}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
];
|
|
622
|
+
}
|
|
623
|
+
function tombstoneAssetOps(assetId, now) {
|
|
624
|
+
return [{ t: "update", ns: BRAND_NS.asset, id: assetId, attrs: { deletedAt: now } }];
|
|
625
|
+
}
|
|
626
|
+
function recordAnalysisOps(assetId, analysis, now) {
|
|
627
|
+
return [
|
|
628
|
+
{
|
|
629
|
+
t: "update",
|
|
630
|
+
ns: BRAND_NS.asset,
|
|
631
|
+
id: assetId,
|
|
632
|
+
attrs: { analysis: assertAnalysis(analysis), analyzedAt: now }
|
|
633
|
+
}
|
|
634
|
+
];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// src/color/hex.ts
|
|
638
|
+
function clamp01(x) {
|
|
639
|
+
return Number.isNaN(x) ? 0 : x < 0 ? 0 : x > 1 ? 1 : x;
|
|
640
|
+
}
|
|
641
|
+
var HEX3 = /^#[0-9a-f]{3}$/;
|
|
642
|
+
var HEX6 = /^#[0-9a-f]{6}$/;
|
|
643
|
+
var HEX_ALPHA2 = /^#([0-9a-f]{4}|[0-9a-f]{8})$/;
|
|
644
|
+
function parseHex(hex) {
|
|
645
|
+
const s = hex.trim().toLowerCase();
|
|
646
|
+
if (HEX_ALPHA2.test(s)) {
|
|
647
|
+
throw new RangeError(
|
|
648
|
+
`parseHex: alpha hex "${hex}" is not supported \u2014 use an opaque "#rrggbb" value`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (HEX3.test(s)) {
|
|
652
|
+
return {
|
|
653
|
+
r: parseInt(s[1] + s[1], 16) / 255,
|
|
654
|
+
g: parseInt(s[2] + s[2], 16) / 255,
|
|
655
|
+
b: parseInt(s[3] + s[3], 16) / 255
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (HEX6.test(s)) {
|
|
659
|
+
return {
|
|
660
|
+
r: parseInt(s.slice(1, 3), 16) / 255,
|
|
661
|
+
g: parseInt(s.slice(3, 5), 16) / 255,
|
|
662
|
+
b: parseInt(s.slice(5, 7), 16) / 255
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
throw new RangeError(`parseHex: expected "#rgb" or "#rrggbb", got "${hex}"`);
|
|
666
|
+
}
|
|
667
|
+
function toHex(rgb) {
|
|
668
|
+
const ch = (c) => Math.round(clamp01(c) * 255).toString(16).padStart(2, "0");
|
|
669
|
+
return `#${ch(rgb.r)}${ch(rgb.g)}${ch(rgb.b)}`;
|
|
670
|
+
}
|
|
671
|
+
function normalizeHex(hex) {
|
|
672
|
+
return toHex(parseHex(hex));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/color/convert.ts
|
|
676
|
+
function srgbToLinear(c) {
|
|
677
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
678
|
+
}
|
|
679
|
+
function linearToSrgb(c) {
|
|
680
|
+
return c <= 31308e-7 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
681
|
+
}
|
|
682
|
+
function rgbToHsl({ r, g, b }) {
|
|
683
|
+
const max = Math.max(r, g, b);
|
|
684
|
+
const min = Math.min(r, g, b);
|
|
685
|
+
const l = (max + min) / 2;
|
|
686
|
+
const d = max - min;
|
|
687
|
+
if (d === 0) return { h: 0, s: 0, l };
|
|
688
|
+
const s = d / (1 - Math.abs(2 * l - 1));
|
|
689
|
+
let h;
|
|
690
|
+
if (max === r) h = 60 * ((g - b) / d % 6);
|
|
691
|
+
else if (max === g) h = 60 * ((b - r) / d + 2);
|
|
692
|
+
else h = 60 * ((r - g) / d + 4);
|
|
693
|
+
return { h: (h + 360) % 360, s, l };
|
|
694
|
+
}
|
|
695
|
+
function hslToRgb({ h, s, l }) {
|
|
696
|
+
const hue = (h % 360 + 360) % 360;
|
|
697
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
698
|
+
const x = c * (1 - Math.abs(hue / 60 % 2 - 1));
|
|
699
|
+
const m = l - c / 2;
|
|
700
|
+
const sextants = [
|
|
701
|
+
[c, x, 0],
|
|
702
|
+
[x, c, 0],
|
|
703
|
+
[0, c, x],
|
|
704
|
+
[0, x, c],
|
|
705
|
+
[x, 0, c],
|
|
706
|
+
[c, 0, x]
|
|
707
|
+
];
|
|
708
|
+
const [r, g, b] = sextants[Math.floor(hue / 60) % 6];
|
|
709
|
+
return { r: r + m, g: g + m, b: b + m };
|
|
710
|
+
}
|
|
711
|
+
function rgbToOklab({ r, g, b }) {
|
|
712
|
+
const R = srgbToLinear(r);
|
|
713
|
+
const G = srgbToLinear(g);
|
|
714
|
+
const B = srgbToLinear(b);
|
|
715
|
+
const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
|
|
716
|
+
const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
|
|
717
|
+
const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
|
|
718
|
+
return {
|
|
719
|
+
L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
720
|
+
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
721
|
+
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function oklabToRgb({ L, a: a2, b }) {
|
|
725
|
+
const l = (L + 0.3963377774 * a2 + 0.2158037573 * b) ** 3;
|
|
726
|
+
const m = (L - 0.1055613458 * a2 - 0.0638541728 * b) ** 3;
|
|
727
|
+
const s = (L - 0.0894841775 * a2 - 1.291485548 * b) ** 3;
|
|
728
|
+
return {
|
|
729
|
+
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
730
|
+
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
731
|
+
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
function oklabToOklch({ L, a: a2, b }) {
|
|
735
|
+
const C = Math.hypot(a2, b);
|
|
736
|
+
const h = C < 1e-9 ? 0 : (Math.atan2(b, a2) * 180 / Math.PI + 360) % 360;
|
|
737
|
+
return { L, C, h };
|
|
738
|
+
}
|
|
739
|
+
function oklchToOklab({ L, C, h }) {
|
|
740
|
+
const rad = h * Math.PI / 180;
|
|
741
|
+
return { L, a: C * Math.cos(rad), b: C * Math.sin(rad) };
|
|
742
|
+
}
|
|
743
|
+
function hexToOklch(hex) {
|
|
744
|
+
return oklabToOklch(rgbToOklab(parseHex(hex)));
|
|
745
|
+
}
|
|
746
|
+
function oklchToHex(lch) {
|
|
747
|
+
return toHex(oklabToRgb(oklchToOklab(lch)));
|
|
748
|
+
}
|
|
749
|
+
var GAMUT_EPS = 1e-6;
|
|
750
|
+
function inSrgbGamut({ r, g, b }) {
|
|
751
|
+
const ok = (c) => c >= -GAMUT_EPS && c <= 1 + GAMUT_EPS;
|
|
752
|
+
return ok(r) && ok(g) && ok(b);
|
|
753
|
+
}
|
|
754
|
+
function clampToGamut(lch) {
|
|
755
|
+
const L = clamp01(lch.L);
|
|
756
|
+
const h = lch.h;
|
|
757
|
+
if (inSrgbGamut(oklabToRgb(oklchToOklab({ L, C: lch.C, h })))) return { L, C: lch.C, h };
|
|
758
|
+
let lo = 0;
|
|
759
|
+
let hi = lch.C;
|
|
760
|
+
for (let i = 0; i < 24; i++) {
|
|
761
|
+
const mid = (lo + hi) / 2;
|
|
762
|
+
if (inSrgbGamut(oklabToRgb(oklchToOklab({ L, C: mid, h })))) lo = mid;
|
|
763
|
+
else hi = mid;
|
|
764
|
+
}
|
|
765
|
+
return { L, C: lo, h };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/color/contrast.ts
|
|
769
|
+
function relativeLuminance(hex) {
|
|
770
|
+
const { r, g, b } = parseHex(hex);
|
|
771
|
+
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
|
772
|
+
}
|
|
773
|
+
function contrastRatio(hexA, hexB) {
|
|
774
|
+
const la = relativeLuminance(hexA);
|
|
775
|
+
const lb = relativeLuminance(hexB);
|
|
776
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
777
|
+
}
|
|
778
|
+
var AA = { text: 4.5, "large-text": 3, ui: 3 };
|
|
779
|
+
var AAA = { text: 7, "large-text": 4.5 };
|
|
780
|
+
function meetsAA(ratio, kind = "text") {
|
|
781
|
+
return ratio >= AA[kind];
|
|
782
|
+
}
|
|
783
|
+
function meetsAAA(ratio, kind = "text") {
|
|
784
|
+
return ratio >= AAA[kind];
|
|
785
|
+
}
|
|
786
|
+
var PICK_TEXT_DEFAULT_CANDIDATES = ["#ffffff", "#050505"];
|
|
787
|
+
function pickTextOn(bgHex, candidates = PICK_TEXT_DEFAULT_CANDIDATES) {
|
|
788
|
+
if (candidates.length === 0) throw new RangeError("pickTextOn: candidates must be non-empty");
|
|
789
|
+
let best = candidates[0];
|
|
790
|
+
let bestRatio = -1;
|
|
791
|
+
for (const candidate of candidates) {
|
|
792
|
+
const ratio = contrastRatio(bgHex, candidate);
|
|
793
|
+
if (ratio > bestRatio) {
|
|
794
|
+
best = candidate;
|
|
795
|
+
bestRatio = ratio;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return best;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/color/harmony.ts
|
|
802
|
+
function rotateHue(hex, degrees) {
|
|
803
|
+
const { L, C, h } = hexToOklch(hex);
|
|
804
|
+
return oklchToHex(clampToGamut({ L, C, h: ((h + degrees) % 360 + 360) % 360 }));
|
|
805
|
+
}
|
|
806
|
+
function complementary(hex) {
|
|
807
|
+
return rotateHue(hex, 180);
|
|
808
|
+
}
|
|
809
|
+
function analogous(hex, angle = 30) {
|
|
810
|
+
return [rotateHue(hex, -angle), rotateHue(hex, angle)];
|
|
811
|
+
}
|
|
812
|
+
function triadic(hex) {
|
|
813
|
+
return [rotateHue(hex, -120), rotateHue(hex, 120)];
|
|
814
|
+
}
|
|
815
|
+
function splitComplementary(hex) {
|
|
816
|
+
return [rotateHue(hex, -150), rotateHue(hex, 150)];
|
|
817
|
+
}
|
|
818
|
+
function tetradic(hex) {
|
|
819
|
+
return [rotateHue(hex, 90), rotateHue(hex, 180), rotateHue(hex, 270)];
|
|
820
|
+
}
|
|
821
|
+
function monochrome(hex, steps = 5) {
|
|
822
|
+
if (!Number.isInteger(steps) || steps < 2) {
|
|
823
|
+
throw new RangeError(`monochrome: steps must be an integer >= 2, got ${steps}`);
|
|
824
|
+
}
|
|
825
|
+
const { L, C, h } = hexToOklch(hex);
|
|
826
|
+
const out = [];
|
|
827
|
+
for (let i = 0; i < steps; i++) {
|
|
828
|
+
out.push(oklchToHex(clampToGamut({ L, C: C * (1 - i / (steps - 1)), h })));
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// src/color/ramp.ts
|
|
834
|
+
var RAMP_L_MAX = 0.96;
|
|
835
|
+
var RAMP_L_MIN = 0.27;
|
|
836
|
+
var CHROMA_FLOOR = 0.25;
|
|
837
|
+
function tintShadeRamp(hex, steps = 9) {
|
|
838
|
+
if (!Number.isInteger(steps) || steps < 2) {
|
|
839
|
+
throw new RangeError(`tintShadeRamp: steps must be an integer >= 2, got ${steps}`);
|
|
840
|
+
}
|
|
841
|
+
const { C, h } = hexToOklch(hex);
|
|
842
|
+
const out = [];
|
|
843
|
+
for (let i = 0; i < steps; i++) {
|
|
844
|
+
const t = i / (steps - 1);
|
|
845
|
+
const L = RAMP_L_MAX + (RAMP_L_MIN - RAMP_L_MAX) * t;
|
|
846
|
+
const taper = CHROMA_FLOOR + (1 - CHROMA_FLOOR) * (1 - Math.abs(2 * t - 1));
|
|
847
|
+
out.push(oklchToHex(clampToGamut({ L, C: C * taper, h })));
|
|
848
|
+
}
|
|
849
|
+
return out;
|
|
850
|
+
}
|
|
851
|
+
var SCAN_STEPS = 16;
|
|
852
|
+
var BISECT_STEPS = 22;
|
|
853
|
+
function adjustLightnessUntil(hex, predicate, direction) {
|
|
854
|
+
const start = normalizeHex(hex);
|
|
855
|
+
if (predicate(start)) return start;
|
|
856
|
+
const { L, C, h } = hexToOklch(start);
|
|
857
|
+
const bound = direction === "lighten" ? 1 : 0;
|
|
858
|
+
const at = (l) => oklchToHex(clampToGamut({ L: l, C, h }));
|
|
859
|
+
let lastFail = L;
|
|
860
|
+
let firstPass = Number.NaN;
|
|
861
|
+
for (let i = 1; i <= SCAN_STEPS; i++) {
|
|
862
|
+
const l = L + (bound - L) * i / SCAN_STEPS;
|
|
863
|
+
if (predicate(at(l))) {
|
|
864
|
+
firstPass = l;
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
lastFail = l;
|
|
868
|
+
}
|
|
869
|
+
if (Number.isNaN(firstPass)) return null;
|
|
870
|
+
for (let i = 0; i < BISECT_STEPS; i++) {
|
|
871
|
+
const mid = (lastFail + firstPass) / 2;
|
|
872
|
+
if (predicate(at(mid))) firstPass = mid;
|
|
873
|
+
else lastFail = mid;
|
|
874
|
+
}
|
|
875
|
+
return at(firstPass);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/color/delta.ts
|
|
879
|
+
function deltaEOKLab(x, y) {
|
|
880
|
+
return Math.hypot(x.L - y.L, x.a - y.a, x.b - y.b);
|
|
881
|
+
}
|
|
882
|
+
function deltaEOK(hexA, hexB) {
|
|
883
|
+
return deltaEOKLab(rgbToOklab(parseHex(hexA)), rgbToOklab(parseHex(hexB)));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/color/names.ts
|
|
887
|
+
var TABLE = [
|
|
888
|
+
["aliceblue", "#f0f8ff"],
|
|
889
|
+
["antiquewhite", "#faebd7"],
|
|
890
|
+
["aqua", "#00ffff"],
|
|
891
|
+
["aquamarine", "#7fffd4"],
|
|
892
|
+
["azure", "#f0ffff"],
|
|
893
|
+
["beige", "#f5f5dc"],
|
|
894
|
+
["bisque", "#ffe4c4"],
|
|
895
|
+
["black", "#000000"],
|
|
896
|
+
["blanchedalmond", "#ffebcd"],
|
|
897
|
+
["blue", "#0000ff"],
|
|
898
|
+
["blueviolet", "#8a2be2"],
|
|
899
|
+
["brown", "#a52a2a"],
|
|
900
|
+
["burlywood", "#deb887"],
|
|
901
|
+
["cadetblue", "#5f9ea0"],
|
|
902
|
+
["chartreuse", "#7fff00"],
|
|
903
|
+
["chocolate", "#d2691e"],
|
|
904
|
+
["coral", "#ff7f50"],
|
|
905
|
+
["cornflowerblue", "#6495ed"],
|
|
906
|
+
["cornsilk", "#fff8dc"],
|
|
907
|
+
["crimson", "#dc143c"],
|
|
908
|
+
["cyan", "#00ffff"],
|
|
909
|
+
["darkblue", "#00008b"],
|
|
910
|
+
["darkcyan", "#008b8b"],
|
|
911
|
+
["darkgoldenrod", "#b8860b"],
|
|
912
|
+
["darkgray", "#a9a9a9"],
|
|
913
|
+
["darkgreen", "#006400"],
|
|
914
|
+
["darkgrey", "#a9a9a9"],
|
|
915
|
+
["darkkhaki", "#bdb76b"],
|
|
916
|
+
["darkmagenta", "#8b008b"],
|
|
917
|
+
["darkolivegreen", "#556b2f"],
|
|
918
|
+
["darkorange", "#ff8c00"],
|
|
919
|
+
["darkorchid", "#9932cc"],
|
|
920
|
+
["darkred", "#8b0000"],
|
|
921
|
+
["darksalmon", "#e9967a"],
|
|
922
|
+
["darkseagreen", "#8fbc8f"],
|
|
923
|
+
["darkslateblue", "#483d8b"],
|
|
924
|
+
["darkslategray", "#2f4f4f"],
|
|
925
|
+
["darkslategrey", "#2f4f4f"],
|
|
926
|
+
["darkturquoise", "#00ced1"],
|
|
927
|
+
["darkviolet", "#9400d3"],
|
|
928
|
+
["deeppink", "#ff1493"],
|
|
929
|
+
["deepskyblue", "#00bfff"],
|
|
930
|
+
["dimgray", "#696969"],
|
|
931
|
+
["dimgrey", "#696969"],
|
|
932
|
+
["dodgerblue", "#1e90ff"],
|
|
933
|
+
["firebrick", "#b22222"],
|
|
934
|
+
["floralwhite", "#fffaf0"],
|
|
935
|
+
["forestgreen", "#228b22"],
|
|
936
|
+
["fuchsia", "#ff00ff"],
|
|
937
|
+
["gainsboro", "#dcdcdc"],
|
|
938
|
+
["ghostwhite", "#f8f8ff"],
|
|
939
|
+
["gold", "#ffd700"],
|
|
940
|
+
["goldenrod", "#daa520"],
|
|
941
|
+
["gray", "#808080"],
|
|
942
|
+
["green", "#008000"],
|
|
943
|
+
["greenyellow", "#adff2f"],
|
|
944
|
+
["grey", "#808080"],
|
|
945
|
+
["honeydew", "#f0fff0"],
|
|
946
|
+
["hotpink", "#ff69b4"],
|
|
947
|
+
["indianred", "#cd5c5c"],
|
|
948
|
+
["indigo", "#4b0082"],
|
|
949
|
+
["ivory", "#fffff0"],
|
|
950
|
+
["khaki", "#f0e68c"],
|
|
951
|
+
["lavender", "#e6e6fa"],
|
|
952
|
+
["lavenderblush", "#fff0f5"],
|
|
953
|
+
["lawngreen", "#7cfc00"],
|
|
954
|
+
["lemonchiffon", "#fffacd"],
|
|
955
|
+
["lightblue", "#add8e6"],
|
|
956
|
+
["lightcoral", "#f08080"],
|
|
957
|
+
["lightcyan", "#e0ffff"],
|
|
958
|
+
["lightgoldenrodyellow", "#fafad2"],
|
|
959
|
+
["lightgray", "#d3d3d3"],
|
|
960
|
+
["lightgreen", "#90ee90"],
|
|
961
|
+
["lightgrey", "#d3d3d3"],
|
|
962
|
+
["lightpink", "#ffb6c1"],
|
|
963
|
+
["lightsalmon", "#ffa07a"],
|
|
964
|
+
["lightseagreen", "#20b2aa"],
|
|
965
|
+
["lightskyblue", "#87cefa"],
|
|
966
|
+
["lightslategray", "#778899"],
|
|
967
|
+
["lightslategrey", "#778899"],
|
|
968
|
+
["lightsteelblue", "#b0c4de"],
|
|
969
|
+
["lightyellow", "#ffffe0"],
|
|
970
|
+
["lime", "#00ff00"],
|
|
971
|
+
["limegreen", "#32cd32"],
|
|
972
|
+
["linen", "#faf0e6"],
|
|
973
|
+
["magenta", "#ff00ff"],
|
|
974
|
+
["maroon", "#800000"],
|
|
975
|
+
["mediumaquamarine", "#66cdaa"],
|
|
976
|
+
["mediumblue", "#0000cd"],
|
|
977
|
+
["mediumorchid", "#ba55d3"],
|
|
978
|
+
["mediumpurple", "#9370db"],
|
|
979
|
+
["mediumseagreen", "#3cb371"],
|
|
980
|
+
["mediumslateblue", "#7b68ee"],
|
|
981
|
+
["mediumspringgreen", "#00fa9a"],
|
|
982
|
+
["mediumturquoise", "#48d1cc"],
|
|
983
|
+
["mediumvioletred", "#c71585"],
|
|
984
|
+
["midnightblue", "#191970"],
|
|
985
|
+
["mintcream", "#f5fffa"],
|
|
986
|
+
["mistyrose", "#ffe4e1"],
|
|
987
|
+
["moccasin", "#ffe4b5"],
|
|
988
|
+
["navajowhite", "#ffdead"],
|
|
989
|
+
["navy", "#000080"],
|
|
990
|
+
["oldlace", "#fdf5e6"],
|
|
991
|
+
["olive", "#808000"],
|
|
992
|
+
["olivedrab", "#6b8e23"],
|
|
993
|
+
["orange", "#ffa500"],
|
|
994
|
+
["orangered", "#ff4500"],
|
|
995
|
+
["orchid", "#da70d6"],
|
|
996
|
+
["palegoldenrod", "#eee8aa"],
|
|
997
|
+
["palegreen", "#98fb98"],
|
|
998
|
+
["paleturquoise", "#afeeee"],
|
|
999
|
+
["palevioletred", "#db7093"],
|
|
1000
|
+
["papayawhip", "#ffefd5"],
|
|
1001
|
+
["peachpuff", "#ffdab9"],
|
|
1002
|
+
["peru", "#cd853f"],
|
|
1003
|
+
["pink", "#ffc0cb"],
|
|
1004
|
+
["plum", "#dda0dd"],
|
|
1005
|
+
["powderblue", "#b0e0e6"],
|
|
1006
|
+
["purple", "#800080"],
|
|
1007
|
+
["rebeccapurple", "#663399"],
|
|
1008
|
+
["red", "#ff0000"],
|
|
1009
|
+
["rosybrown", "#bc8f8f"],
|
|
1010
|
+
["royalblue", "#4169e1"],
|
|
1011
|
+
["saddlebrown", "#8b4513"],
|
|
1012
|
+
["salmon", "#fa8072"],
|
|
1013
|
+
["sandybrown", "#f4a460"],
|
|
1014
|
+
["seagreen", "#2e8b57"],
|
|
1015
|
+
["seashell", "#fff5ee"],
|
|
1016
|
+
["sienna", "#a0522d"],
|
|
1017
|
+
["silver", "#c0c0c0"],
|
|
1018
|
+
["skyblue", "#87ceeb"],
|
|
1019
|
+
["slateblue", "#6a5acd"],
|
|
1020
|
+
["slategray", "#708090"],
|
|
1021
|
+
["slategrey", "#708090"],
|
|
1022
|
+
["snow", "#fffafa"],
|
|
1023
|
+
["springgreen", "#00ff7f"],
|
|
1024
|
+
["steelblue", "#4682b4"],
|
|
1025
|
+
["tan", "#d2b48c"],
|
|
1026
|
+
["teal", "#008080"],
|
|
1027
|
+
["thistle", "#d8bfd8"],
|
|
1028
|
+
["tomato", "#ff6347"],
|
|
1029
|
+
["turquoise", "#40e0d0"],
|
|
1030
|
+
["violet", "#ee82ee"],
|
|
1031
|
+
["wheat", "#f5deb3"],
|
|
1032
|
+
["white", "#ffffff"],
|
|
1033
|
+
["whitesmoke", "#f5f5f5"],
|
|
1034
|
+
["yellow", "#ffff00"],
|
|
1035
|
+
["yellowgreen", "#9acd32"]
|
|
1036
|
+
];
|
|
1037
|
+
var CSS_NAMED_COLORS = TABLE.map(([name, hex]) => ({
|
|
1038
|
+
name,
|
|
1039
|
+
hex
|
|
1040
|
+
}));
|
|
1041
|
+
var labCache = null;
|
|
1042
|
+
function nearestNamedColor(hex) {
|
|
1043
|
+
const target = rgbToOklab(parseHex(hex));
|
|
1044
|
+
labCache ??= TABLE.map(([, value2]) => rgbToOklab(parseHex(value2)));
|
|
1045
|
+
let bestIdx = 0;
|
|
1046
|
+
let bestD = Infinity;
|
|
1047
|
+
for (let i = 0; i < labCache.length; i++) {
|
|
1048
|
+
const d = deltaEOKLab(target, labCache[i]);
|
|
1049
|
+
if (d < bestD) {
|
|
1050
|
+
bestD = d;
|
|
1051
|
+
bestIdx = i;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
const [name, value] = TABLE[bestIdx];
|
|
1055
|
+
return { name, hex: value, deltaEOK: bestD };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// src/color/palette.ts
|
|
1059
|
+
var CHART_DELTA_MIN = 0.1;
|
|
1060
|
+
var CHART_L = 0.7;
|
|
1061
|
+
var CHART_C = 0.115;
|
|
1062
|
+
var ANCHOR_HUE = 250;
|
|
1063
|
+
var ACHROMATIC_C = 0.02;
|
|
1064
|
+
var STATUS_HUES = { good: 145, warn: 85, danger: 25 };
|
|
1065
|
+
var STATUS_L = 0.7;
|
|
1066
|
+
var STATUS_C = 0.14;
|
|
1067
|
+
var ROTATIONS = [180, 120, -120, 150, -150];
|
|
1068
|
+
function deriveChartColors(seedHex, opts = {}) {
|
|
1069
|
+
const count = opts.count ?? 6;
|
|
1070
|
+
const deltaMin = opts.deltaMin ?? CHART_DELTA_MIN;
|
|
1071
|
+
if (!Number.isInteger(count) || count < 1 || count > 12) {
|
|
1072
|
+
throw new RangeError(`deriveChartColors: count must be an integer in 1..12, got ${count}`);
|
|
1073
|
+
}
|
|
1074
|
+
const lch = hexToOklch(normalizeHex(seedHex));
|
|
1075
|
+
const base = lch.C < ACHROMATIC_C ? ANCHOR_HUE : lch.h;
|
|
1076
|
+
const offsets = [0, 60, 120, 180, 240, 300, 30, 90, 150, 210, 270, 330];
|
|
1077
|
+
const candidates = offsets.map(
|
|
1078
|
+
(deg) => oklchToHex(clampToGamut({ L: CHART_L, C: CHART_C, h: (base + deg) % 360 }))
|
|
1079
|
+
);
|
|
1080
|
+
const picked = [];
|
|
1081
|
+
for (const hex of candidates) {
|
|
1082
|
+
if (picked.length >= count) break;
|
|
1083
|
+
if (picked.includes(hex)) continue;
|
|
1084
|
+
if (picked.every((p) => deltaEOK(p, hex) >= deltaMin)) picked.push(hex);
|
|
1085
|
+
}
|
|
1086
|
+
for (const hex of candidates) {
|
|
1087
|
+
if (picked.length >= count) break;
|
|
1088
|
+
if (!picked.includes(hex)) picked.push(hex);
|
|
1089
|
+
}
|
|
1090
|
+
return picked;
|
|
1091
|
+
}
|
|
1092
|
+
function derivePalette(seedHex, opts = {}) {
|
|
1093
|
+
const withNames = opts.names !== false;
|
|
1094
|
+
const seed = normalizeHex(seedHex);
|
|
1095
|
+
const seedLch = hexToOklch(seed);
|
|
1096
|
+
const achromatic = seedLch.C < ACHROMATIC_C;
|
|
1097
|
+
const hue = achromatic ? ANCHOR_HUE : seedLch.h;
|
|
1098
|
+
const neutralAt = (L, C) => oklchToHex(clampToGamut({ L, C, h: hue }));
|
|
1099
|
+
const bg = neutralAt(0.985, 5e-3);
|
|
1100
|
+
const surface = neutralAt(0.955, 8e-3);
|
|
1101
|
+
const text = neutralAt(0.24, 0.012);
|
|
1102
|
+
const neutral = neutralAt(0.62, 0.012);
|
|
1103
|
+
const accentBase = achromatic ? oklchToHex(
|
|
1104
|
+
clampToGamut({ L: Math.min(Math.max(seedLch.L, 0.45), 0.75), C: CHART_C, h: ANCHOR_HUE })
|
|
1105
|
+
) : seed;
|
|
1106
|
+
const cands = ROTATIONS.map((deg) => {
|
|
1107
|
+
const hex = rotateHue(accentBase, deg);
|
|
1108
|
+
return { deg, hex, d: deltaEOK(seed, hex) };
|
|
1109
|
+
});
|
|
1110
|
+
let secondary = cands[0];
|
|
1111
|
+
for (const c of cands) if (c.d > secondary.d) secondary = c;
|
|
1112
|
+
let highlight = cands[0] === secondary ? cands[1] : cands[0];
|
|
1113
|
+
let hiScore = Math.min(highlight.d, deltaEOK(highlight.hex, secondary.hex));
|
|
1114
|
+
for (const c of cands) {
|
|
1115
|
+
if (c === secondary || c === highlight) continue;
|
|
1116
|
+
const score = Math.min(c.d, deltaEOK(c.hex, secondary.hex));
|
|
1117
|
+
if (score > hiScore) {
|
|
1118
|
+
highlight = c;
|
|
1119
|
+
hiScore = score;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const status = (anchor) => {
|
|
1123
|
+
const start = oklchToHex(clampToGamut({ L: STATUS_L, C: STATUS_C, h: anchor }));
|
|
1124
|
+
return adjustLightnessUntil(start, (c) => contrastRatio(c, bg) >= 3, "darken") ?? text;
|
|
1125
|
+
};
|
|
1126
|
+
const sw = (role, hex, rationale) => ({
|
|
1127
|
+
role,
|
|
1128
|
+
hex,
|
|
1129
|
+
rationale
|
|
1130
|
+
});
|
|
1131
|
+
const swatches = [
|
|
1132
|
+
sw("primary", seed, "seed color"),
|
|
1133
|
+
sw("secondary", secondary.hex, `harmony rotation ${secondary.deg}\xB0, \u0394EOK ${secondary.d.toFixed(3)} from primary`),
|
|
1134
|
+
sw("highlight", highlight.hex, `harmony rotation ${highlight.deg}\xB0, spread from primary and secondary`),
|
|
1135
|
+
sw("good", status(STATUS_HUES.good), "green anchor 145\xB0, \u22653:1 on bg"),
|
|
1136
|
+
sw("warn", status(STATUS_HUES.warn), "amber anchor 85\xB0, \u22653:1 on bg"),
|
|
1137
|
+
sw("danger", status(STATUS_HUES.danger), "red anchor 25\xB0, \u22653:1 on bg"),
|
|
1138
|
+
sw("bg", bg, "near-white tinted with the seed hue"),
|
|
1139
|
+
sw("surface", surface, "raised surface, one step below bg"),
|
|
1140
|
+
sw("text", text, "near-black tinted with the seed hue"),
|
|
1141
|
+
sw("neutral", neutral, "mid neutral for borders and muted marks"),
|
|
1142
|
+
...deriveChartColors(seed).map((hex, i) => sw("chart", hex, `chart series ${i + 1}`))
|
|
1143
|
+
];
|
|
1144
|
+
return withNames ? swatches.map((s) => ({ ...s, name: nearestNamedColor(s.hex).name })) : swatches;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// src/tokens/roles.ts
|
|
1148
|
+
var BRAND_REQUIRED_TOKENS = [
|
|
1149
|
+
"--ui-bg",
|
|
1150
|
+
"--ui-surface",
|
|
1151
|
+
"--ui-surface-2",
|
|
1152
|
+
"--ui-text",
|
|
1153
|
+
"--ui-text-muted",
|
|
1154
|
+
"--ui-text-faint",
|
|
1155
|
+
"--ui-border",
|
|
1156
|
+
"--ui-border-strong",
|
|
1157
|
+
"--ui-accent",
|
|
1158
|
+
"--ui-accent-strong",
|
|
1159
|
+
"--ui-accent-soft",
|
|
1160
|
+
"--ui-on-accent",
|
|
1161
|
+
"--ui-good",
|
|
1162
|
+
"--ui-good-soft",
|
|
1163
|
+
"--ui-warn",
|
|
1164
|
+
"--ui-warn-soft",
|
|
1165
|
+
"--ui-danger",
|
|
1166
|
+
"--ui-danger-soft",
|
|
1167
|
+
"--ui-code-bg",
|
|
1168
|
+
"--ui-code-text",
|
|
1169
|
+
"--ui-shadow",
|
|
1170
|
+
"--ui-font-sans",
|
|
1171
|
+
"--ui-font-serif",
|
|
1172
|
+
"--ui-font-mono",
|
|
1173
|
+
"--ui-font-display"
|
|
1174
|
+
];
|
|
1175
|
+
var BRAND_DERIVED_TOKENS = [
|
|
1176
|
+
"--ui-accent-glow",
|
|
1177
|
+
"--ui-accent-2",
|
|
1178
|
+
"--ui-accent-2-soft",
|
|
1179
|
+
"--ui-highlight",
|
|
1180
|
+
"--ui-focus",
|
|
1181
|
+
"--ui-shadow-strong"
|
|
1182
|
+
];
|
|
1183
|
+
var BRAND_CHART_TOKENS = [
|
|
1184
|
+
"--ui-chart-1",
|
|
1185
|
+
"--ui-chart-2",
|
|
1186
|
+
"--ui-chart-3",
|
|
1187
|
+
"--ui-chart-4",
|
|
1188
|
+
"--ui-chart-5",
|
|
1189
|
+
"--ui-chart-6",
|
|
1190
|
+
"--ui-chart-band",
|
|
1191
|
+
"--ui-chart-band-strong",
|
|
1192
|
+
"--ui-chart-flow",
|
|
1193
|
+
"--ui-chart-glow"
|
|
1194
|
+
];
|
|
1195
|
+
var BRAND_CHAT_TOKENS = [
|
|
1196
|
+
"--ui-chat-user-bg",
|
|
1197
|
+
"--ui-chat-user-text",
|
|
1198
|
+
"--ui-chat-assistant-bg",
|
|
1199
|
+
"--ui-chat-thinking-bg",
|
|
1200
|
+
"--ui-chat-thinking-text",
|
|
1201
|
+
"--ui-chat-tool-accent"
|
|
1202
|
+
];
|
|
1203
|
+
var BRAND_EMITTED_TOKENS = [
|
|
1204
|
+
...BRAND_REQUIRED_TOKENS,
|
|
1205
|
+
...BRAND_DERIVED_TOKENS,
|
|
1206
|
+
...BRAND_CHART_TOKENS,
|
|
1207
|
+
...BRAND_CHAT_TOKENS
|
|
1208
|
+
];
|
|
1209
|
+
|
|
1210
|
+
// src/tokens/map.ts
|
|
1211
|
+
var DEFAULT_ACCENT_SEED = "#3b5e8c";
|
|
1212
|
+
var CHROMATIC_C = 0.02;
|
|
1213
|
+
var SANS_STACK = `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`;
|
|
1214
|
+
var SERIF_STACK = `"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif`;
|
|
1215
|
+
var MONO_STACK = `ui-monospace, "SF Mono", Menlo, Consolas, monospace`;
|
|
1216
|
+
var DISPLAY_STACK = `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
|
|
1217
|
+
var UI_CHART_DEFAULTS = [
|
|
1218
|
+
"var(--ui-accent)",
|
|
1219
|
+
"var(--ui-good)",
|
|
1220
|
+
"var(--ui-warn)",
|
|
1221
|
+
"var(--ui-danger)",
|
|
1222
|
+
"color-mix(in srgb, var(--ui-accent) 45%, var(--ui-text))",
|
|
1223
|
+
"var(--ui-text-faint)"
|
|
1224
|
+
];
|
|
1225
|
+
var ROLE_TOKEN = {
|
|
1226
|
+
primary: "--ui-accent",
|
|
1227
|
+
secondary: "--ui-accent-2",
|
|
1228
|
+
highlight: "--ui-highlight",
|
|
1229
|
+
bg: "--ui-bg",
|
|
1230
|
+
surface: "--ui-surface",
|
|
1231
|
+
text: "--ui-text",
|
|
1232
|
+
neutral: "--ui-border-strong",
|
|
1233
|
+
good: "--ui-good",
|
|
1234
|
+
warn: "--ui-warn",
|
|
1235
|
+
danger: "--ui-danger",
|
|
1236
|
+
chart: "--ui-chart-1"
|
|
1237
|
+
};
|
|
1238
|
+
var mixVar = (name, pct) => `color-mix(in srgb, var(${name}) ${pct}%, transparent)`;
|
|
1239
|
+
function shiftL(hex, dL) {
|
|
1240
|
+
const { L, C, h } = hexToOklch(hex);
|
|
1241
|
+
return oklchToHex(clampToGamut({ L: clamp01(L + dL), C, h }));
|
|
1242
|
+
}
|
|
1243
|
+
function lerpL(fromHex, toHex2, t) {
|
|
1244
|
+
const a2 = hexToOklch(fromHex);
|
|
1245
|
+
const b = hexToOklch(toHex2);
|
|
1246
|
+
return oklchToHex(clampToGamut({ L: a2.L + (b.L - a2.L) * t, C: a2.C, h: a2.h }));
|
|
1247
|
+
}
|
|
1248
|
+
function ensureOnBg(hex, bg, min) {
|
|
1249
|
+
const pred = (c) => contrastRatio(c, bg) >= min;
|
|
1250
|
+
const away = relativeLuminance(bg) >= relativeLuminance(hex) ? "darken" : "lighten";
|
|
1251
|
+
return adjustLightnessUntil(hex, pred, away) ?? adjustLightnessUntil(hex, pred, away === "darken" ? "lighten" : "darken") ?? pickTextOn(bg);
|
|
1252
|
+
}
|
|
1253
|
+
var rgbaOf = (hex, alpha) => {
|
|
1254
|
+
const { r, g, b } = parseHex(hex);
|
|
1255
|
+
const c = (v) => Math.round(v * 255);
|
|
1256
|
+
return `rgba(${c(r)}, ${c(g)}, ${c(b)}, ${alpha})`;
|
|
1257
|
+
};
|
|
1258
|
+
function fontValue(family, stack) {
|
|
1259
|
+
const f = family?.trim() ?? "";
|
|
1260
|
+
if (f === "") return stack;
|
|
1261
|
+
if (f.includes(",") || /^["']/.test(f) || /^[a-zA-Z][a-zA-Z0-9-]*$/.test(f))
|
|
1262
|
+
return `${f}, ${stack}`;
|
|
1263
|
+
return `"${f.replaceAll('"', "")}", ${stack}`;
|
|
1264
|
+
}
|
|
1265
|
+
function collect(swatches, warnings) {
|
|
1266
|
+
const byRole = {};
|
|
1267
|
+
const charts = [];
|
|
1268
|
+
if (!Array.isArray(swatches)) {
|
|
1269
|
+
warnings.push({ token: "--ui-accent", message: "swatches is not an array; compiling from defaults" });
|
|
1270
|
+
return { byRole, charts };
|
|
1271
|
+
}
|
|
1272
|
+
swatches.forEach((s, i) => {
|
|
1273
|
+
let hex;
|
|
1274
|
+
try {
|
|
1275
|
+
hex = normalizeHex(String(s?.hex));
|
|
1276
|
+
} catch {
|
|
1277
|
+
warnings.push({
|
|
1278
|
+
token: s?.role !== void 0 && ROLE_TOKEN[s.role] || "--ui-accent",
|
|
1279
|
+
message: `swatches[${i}] dropped: invalid hex ${String(s?.hex)}`
|
|
1280
|
+
});
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
const role = s?.role;
|
|
1284
|
+
if (role === "chart") charts.push(hex);
|
|
1285
|
+
else if (role !== void 0 && role !== "custom" && byRole[role] === void 0) byRole[role] = hex;
|
|
1286
|
+
});
|
|
1287
|
+
return { byRole, charts };
|
|
1288
|
+
}
|
|
1289
|
+
function mapPaletteToTokens(input) {
|
|
1290
|
+
const warnings = [];
|
|
1291
|
+
const { byRole, charts } = collect(input?.swatches, warnings);
|
|
1292
|
+
let accent = byRole.primary;
|
|
1293
|
+
if (accent === void 0) {
|
|
1294
|
+
const chromatic = [...Object.values(byRole), ...charts].find(
|
|
1295
|
+
(h) => h !== void 0 && hexToOklch(h).C >= CHROMATIC_C
|
|
1296
|
+
);
|
|
1297
|
+
accent = chromatic ?? DEFAULT_ACCENT_SEED;
|
|
1298
|
+
warnings.push({
|
|
1299
|
+
token: "--ui-accent",
|
|
1300
|
+
message: `no primary swatch; using ${chromatic !== void 0 ? "the first chromatic swatch" : "the neutral default seed"} ${accent}`
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
const fb = /* @__PURE__ */ new Map();
|
|
1304
|
+
for (const s of derivePalette(accent, { names: false })) if (!fb.has(s.role)) fb.set(s.role, s.hex);
|
|
1305
|
+
const pick = (role) => {
|
|
1306
|
+
const own = byRole[role];
|
|
1307
|
+
if (own !== void 0) return own;
|
|
1308
|
+
const derived = fb.get(role);
|
|
1309
|
+
warnings.push({ token: ROLE_TOKEN[role], message: `no ${role} swatch; derived ${derived} from ${accent}` });
|
|
1310
|
+
return derived;
|
|
1311
|
+
};
|
|
1312
|
+
const bg = pick("bg");
|
|
1313
|
+
const surface = pick("surface");
|
|
1314
|
+
const neutral = pick("neutral");
|
|
1315
|
+
const surface2 = shiftL(surface, -0.035);
|
|
1316
|
+
const rawText = pick("text");
|
|
1317
|
+
const text = ensureOnBg(rawText, bg, 4.5);
|
|
1318
|
+
if (text !== rawText)
|
|
1319
|
+
warnings.push({ token: "--ui-text", message: "adjusted to reach 4.5:1 on --ui-bg", adjustedFrom: rawText });
|
|
1320
|
+
const accentStrong = ensureOnBg(accent, bg, 4.5);
|
|
1321
|
+
if (accentStrong !== accent)
|
|
1322
|
+
warnings.push({
|
|
1323
|
+
token: "--ui-accent-strong",
|
|
1324
|
+
message: "moved --ui-accent in lightness to reach 4.5:1 on --ui-bg",
|
|
1325
|
+
adjustedFrom: accent
|
|
1326
|
+
});
|
|
1327
|
+
const status = (role) => {
|
|
1328
|
+
const own = pick(role);
|
|
1329
|
+
const fixed = ensureOnBg(own, bg, 3);
|
|
1330
|
+
if (fixed !== own)
|
|
1331
|
+
warnings.push({ token: ROLE_TOKEN[role], message: "adjusted to reach 3:1 on --ui-bg", adjustedFrom: own });
|
|
1332
|
+
return fixed;
|
|
1333
|
+
};
|
|
1334
|
+
const chromaticCharts = charts.filter((h) => hexToOklch(h).C >= CHROMATIC_C);
|
|
1335
|
+
let chartValues;
|
|
1336
|
+
if (chromaticCharts.length >= 3) {
|
|
1337
|
+
const picked = chromaticCharts.slice(0, 6);
|
|
1338
|
+
for (const c of deriveChartColors(accent, { count: 12 })) {
|
|
1339
|
+
if (picked.length >= 6) break;
|
|
1340
|
+
if (!picked.includes(c)) picked.push(c);
|
|
1341
|
+
}
|
|
1342
|
+
chartValues = picked;
|
|
1343
|
+
} else {
|
|
1344
|
+
warnings.push({
|
|
1345
|
+
token: "--ui-chart-1",
|
|
1346
|
+
message: `fewer than 3 chromatic chart swatches (${chromaticCharts.length}); using the ui derived chart defaults`
|
|
1347
|
+
});
|
|
1348
|
+
chartValues = UI_CHART_DEFAULTS;
|
|
1349
|
+
}
|
|
1350
|
+
const secondary = byRole.secondary;
|
|
1351
|
+
if (secondary === void 0)
|
|
1352
|
+
warnings.push({ token: "--ui-accent-2", message: "no secondary swatch; --ui-accent-2 collapses onto var(--ui-accent) (ui default)" });
|
|
1353
|
+
const highlight = byRole.highlight;
|
|
1354
|
+
if (highlight === void 0)
|
|
1355
|
+
warnings.push({ token: "--ui-highlight", message: "no highlight swatch; --ui-highlight aliases var(--ui-accent-strong) (ui default)" });
|
|
1356
|
+
const t = input?.typography ?? {};
|
|
1357
|
+
const tokens = {
|
|
1358
|
+
"--ui-bg": bg,
|
|
1359
|
+
"--ui-surface": surface,
|
|
1360
|
+
"--ui-surface-2": surface2,
|
|
1361
|
+
"--ui-text": text,
|
|
1362
|
+
"--ui-text-muted": ensureOnBg(lerpL(text, bg, 0.35), bg, 4.5),
|
|
1363
|
+
"--ui-text-faint": lerpL(text, bg, 0.58),
|
|
1364
|
+
"--ui-border": lerpL(neutral, bg, 0.55),
|
|
1365
|
+
"--ui-border-strong": neutral,
|
|
1366
|
+
"--ui-accent": accent,
|
|
1367
|
+
"--ui-accent-strong": accentStrong,
|
|
1368
|
+
"--ui-accent-soft": mixVar("--ui-accent", 10),
|
|
1369
|
+
"--ui-on-accent": pickTextOn(accent),
|
|
1370
|
+
"--ui-good": status("good"),
|
|
1371
|
+
"--ui-good-soft": mixVar("--ui-good", 12),
|
|
1372
|
+
"--ui-warn": status("warn"),
|
|
1373
|
+
"--ui-warn-soft": mixVar("--ui-warn", 12),
|
|
1374
|
+
"--ui-danger": status("danger"),
|
|
1375
|
+
"--ui-danger-soft": mixVar("--ui-danger", 10),
|
|
1376
|
+
"--ui-code-bg": surface2,
|
|
1377
|
+
"--ui-code-text": text,
|
|
1378
|
+
"--ui-shadow": `0 1px 2px ${rgbaOf(text, 0.04)}, 0 8px 24px ${rgbaOf(text, 0.06)}`,
|
|
1379
|
+
"--ui-font-sans": fontValue(t.fontBody, SANS_STACK),
|
|
1380
|
+
"--ui-font-serif": SERIF_STACK,
|
|
1381
|
+
"--ui-font-mono": fontValue(t.fontMono, MONO_STACK),
|
|
1382
|
+
"--ui-font-display": fontValue(t.fontDisplay ?? t.fontBody, DISPLAY_STACK),
|
|
1383
|
+
"--ui-accent-glow": mixVar("--ui-accent", 16),
|
|
1384
|
+
"--ui-accent-2": secondary ?? "var(--ui-accent)",
|
|
1385
|
+
"--ui-accent-2-soft": mixVar("--ui-accent-2", 10),
|
|
1386
|
+
"--ui-highlight": highlight ?? "var(--ui-accent-strong)",
|
|
1387
|
+
"--ui-focus": "0 0 0 3px var(--ui-accent-soft)",
|
|
1388
|
+
"--ui-shadow-strong": "var(--ui-shadow)",
|
|
1389
|
+
"--ui-chart-1": chartValues[0],
|
|
1390
|
+
"--ui-chart-2": chartValues[1],
|
|
1391
|
+
"--ui-chart-3": chartValues[2],
|
|
1392
|
+
"--ui-chart-4": chartValues[3],
|
|
1393
|
+
"--ui-chart-5": chartValues[4],
|
|
1394
|
+
"--ui-chart-6": chartValues[5],
|
|
1395
|
+
"--ui-chart-band": mixVar("--ui-accent", 10),
|
|
1396
|
+
"--ui-chart-band-strong": mixVar("--ui-accent", 22),
|
|
1397
|
+
"--ui-chart-flow": "var(--ui-accent)",
|
|
1398
|
+
"--ui-chart-glow": "var(--ui-accent-strong)",
|
|
1399
|
+
"--ui-chat-user-bg": "var(--ui-accent-soft)",
|
|
1400
|
+
"--ui-chat-user-text": "var(--ui-text)",
|
|
1401
|
+
"--ui-chat-assistant-bg": "var(--ui-surface)",
|
|
1402
|
+
"--ui-chat-thinking-bg": "var(--ui-surface-2)",
|
|
1403
|
+
"--ui-chat-thinking-text": "var(--ui-text-muted)",
|
|
1404
|
+
"--ui-chat-tool-accent": "var(--ui-accent)"
|
|
1405
|
+
};
|
|
1406
|
+
return { tokens, warnings };
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// src/tokens/dark.ts
|
|
1410
|
+
var DARK_FLIP_L_MIN = 0.2;
|
|
1411
|
+
var DARK_FLIP_L_MAX = 0.95;
|
|
1412
|
+
var DARK_SHADOW = "0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35)";
|
|
1413
|
+
var HEX62 = /^#[0-9a-f]{6}$/;
|
|
1414
|
+
var NEUTRAL_FLIP = /* @__PURE__ */ new Set([
|
|
1415
|
+
"--ui-bg",
|
|
1416
|
+
"--ui-surface",
|
|
1417
|
+
"--ui-surface-2",
|
|
1418
|
+
"--ui-text",
|
|
1419
|
+
"--ui-text-muted",
|
|
1420
|
+
"--ui-text-faint",
|
|
1421
|
+
"--ui-border",
|
|
1422
|
+
"--ui-border-strong",
|
|
1423
|
+
"--ui-code-bg",
|
|
1424
|
+
"--ui-code-text"
|
|
1425
|
+
]);
|
|
1426
|
+
var MIN_RUNG = 0.02;
|
|
1427
|
+
var ACCENT_AA = [
|
|
1428
|
+
"--ui-accent",
|
|
1429
|
+
"--ui-accent-strong",
|
|
1430
|
+
"--ui-accent-2",
|
|
1431
|
+
"--ui-highlight",
|
|
1432
|
+
"--ui-good",
|
|
1433
|
+
"--ui-warn",
|
|
1434
|
+
"--ui-danger"
|
|
1435
|
+
];
|
|
1436
|
+
var CHART_UI = [
|
|
1437
|
+
"--ui-chart-1",
|
|
1438
|
+
"--ui-chart-2",
|
|
1439
|
+
"--ui-chart-3",
|
|
1440
|
+
"--ui-chart-4",
|
|
1441
|
+
"--ui-chart-5",
|
|
1442
|
+
"--ui-chart-6"
|
|
1443
|
+
];
|
|
1444
|
+
function flipNeutral(hex) {
|
|
1445
|
+
const { L, C, h } = hexToOklch(hex);
|
|
1446
|
+
const flipped = DARK_FLIP_L_MIN + (1 - L) * (DARK_FLIP_L_MAX - DARK_FLIP_L_MIN);
|
|
1447
|
+
return oklchToHex(clampToGamut({ L: flipped, C, h }));
|
|
1448
|
+
}
|
|
1449
|
+
function rebuildSurfaces(out, light, lightBgL, darkBgL) {
|
|
1450
|
+
const lightSurface = light["--ui-surface"];
|
|
1451
|
+
const lightSurface2 = light["--ui-surface-2"];
|
|
1452
|
+
let surfaceDarkL;
|
|
1453
|
+
let surfaceLightL = lightBgL;
|
|
1454
|
+
if (lightSurface !== void 0 && HEX62.test(lightSurface)) {
|
|
1455
|
+
const { L, C, h } = hexToOklch(lightSurface);
|
|
1456
|
+
surfaceLightL = L;
|
|
1457
|
+
surfaceDarkL = darkBgL + Math.max(Math.abs(L - lightBgL), MIN_RUNG);
|
|
1458
|
+
out["--ui-surface"] = oklchToHex(clampToGamut({ L: surfaceDarkL, C, h }));
|
|
1459
|
+
}
|
|
1460
|
+
if (lightSurface2 !== void 0 && HEX62.test(lightSurface2)) {
|
|
1461
|
+
const { L, C, h } = hexToOklch(lightSurface2);
|
|
1462
|
+
const base = surfaceDarkL ?? darkBgL + MIN_RUNG;
|
|
1463
|
+
const L2 = base + Math.max(Math.abs(L - surfaceLightL), MIN_RUNG);
|
|
1464
|
+
out["--ui-surface-2"] = oklchToHex(clampToGamut({ L: L2, C, h }));
|
|
1465
|
+
}
|
|
1466
|
+
const lightCodeBg = light["--ui-code-bg"];
|
|
1467
|
+
if (lightCodeBg !== void 0 && HEX62.test(lightCodeBg)) {
|
|
1468
|
+
if (lightCodeBg === lightSurface2 && out["--ui-surface-2"] !== void 0) {
|
|
1469
|
+
out["--ui-code-bg"] = out["--ui-surface-2"];
|
|
1470
|
+
} else {
|
|
1471
|
+
const { L, C, h } = hexToOklch(lightCodeBg);
|
|
1472
|
+
const codeL = darkBgL + Math.max(Math.abs(L - lightBgL), MIN_RUNG);
|
|
1473
|
+
out["--ui-code-bg"] = oklchToHex(clampToGamut({ L: codeL, C, h }));
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
function relight(hex, bg, min) {
|
|
1478
|
+
const pred = (c) => contrastRatio(c, bg) >= min;
|
|
1479
|
+
return adjustLightnessUntil(hex, pred, "lighten") ?? adjustLightnessUntil(hex, pred, "darken") ?? pickTextOn(bg);
|
|
1480
|
+
}
|
|
1481
|
+
function deriveDarkTokens(light) {
|
|
1482
|
+
const out = {};
|
|
1483
|
+
for (const [name, value] of Object.entries(light)) {
|
|
1484
|
+
if (name === "--ui-shadow") out[name] = DARK_SHADOW;
|
|
1485
|
+
else if (NEUTRAL_FLIP.has(name) && HEX62.test(value)) out[name] = flipNeutral(value);
|
|
1486
|
+
else out[name] = value;
|
|
1487
|
+
}
|
|
1488
|
+
const bg = out["--ui-bg"];
|
|
1489
|
+
const lightBg = light["--ui-bg"];
|
|
1490
|
+
if (bg === void 0 || !HEX62.test(bg) || lightBg === void 0 || !HEX62.test(lightBg)) return out;
|
|
1491
|
+
rebuildSurfaces(out, light, hexToOklch(lightBg).L, hexToOklch(bg).L);
|
|
1492
|
+
for (const name of ["--ui-text", "--ui-text-muted"]) {
|
|
1493
|
+
const v = out[name];
|
|
1494
|
+
if (v !== void 0 && HEX62.test(v)) out[name] = relight(v, bg, 4.5);
|
|
1495
|
+
}
|
|
1496
|
+
const codeBg = out["--ui-code-bg"];
|
|
1497
|
+
const codeText = out["--ui-code-text"];
|
|
1498
|
+
if (codeText !== void 0 && HEX62.test(codeText))
|
|
1499
|
+
out["--ui-code-text"] = relight(codeText, codeBg !== void 0 && HEX62.test(codeBg) ? codeBg : bg, 4.5);
|
|
1500
|
+
for (const name of ACCENT_AA) {
|
|
1501
|
+
const v = out[name];
|
|
1502
|
+
if (v !== void 0 && HEX62.test(v)) out[name] = relight(v, bg, 4.5);
|
|
1503
|
+
}
|
|
1504
|
+
for (const name of CHART_UI) {
|
|
1505
|
+
const v = out[name];
|
|
1506
|
+
if (v !== void 0 && HEX62.test(v)) out[name] = relight(v, bg, 3);
|
|
1507
|
+
}
|
|
1508
|
+
const accent = out["--ui-accent"];
|
|
1509
|
+
const onAccent = out["--ui-on-accent"];
|
|
1510
|
+
if (accent !== void 0 && HEX62.test(accent) && onAccent !== void 0 && HEX62.test(onAccent))
|
|
1511
|
+
out["--ui-on-accent"] = pickTextOn(accent);
|
|
1512
|
+
return out;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// src/tokens/css.ts
|
|
1516
|
+
var HEADER = "/* Generated by @odla-ai/brand \u2014 @odla-ai/ui token overrides; pair with @odla-ai/ui css/tokens.css. */";
|
|
1517
|
+
var ORDER = new Map(
|
|
1518
|
+
BRAND_EMITTED_TOKENS.map((name, i) => [name, i])
|
|
1519
|
+
);
|
|
1520
|
+
function orderedNames(tokens) {
|
|
1521
|
+
return Object.keys(tokens).sort((a2, b) => {
|
|
1522
|
+
const ia = ORDER.get(a2);
|
|
1523
|
+
const ib = ORDER.get(b);
|
|
1524
|
+
if (ia !== void 0 && ib !== void 0) return ia - ib;
|
|
1525
|
+
if (ia !== void 0) return -1;
|
|
1526
|
+
if (ib !== void 0) return 1;
|
|
1527
|
+
return a2 < b ? -1 : a2 > b ? 1 : 0;
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
function renderBlock(selector, tokens, names, indent, lead) {
|
|
1531
|
+
const lines = [];
|
|
1532
|
+
if (lead !== void 0) lines.push(`${indent} ${lead}`);
|
|
1533
|
+
for (const name of names) lines.push(`${indent} ${name}: ${tokens[name]};`);
|
|
1534
|
+
if (lines.length === 0) return `${indent}${selector} {
|
|
1535
|
+
${indent}}`;
|
|
1536
|
+
return `${indent}${selector} {
|
|
1537
|
+
${lines.join("\n")}
|
|
1538
|
+
${indent}}`;
|
|
1539
|
+
}
|
|
1540
|
+
function renderTokensCss(light, opts = {}) {
|
|
1541
|
+
const selector = opts.selector ?? ":root";
|
|
1542
|
+
const scoped = selector !== ":root";
|
|
1543
|
+
const parts = [HEADER, renderBlock(selector, light, orderedNames(light), "")];
|
|
1544
|
+
const dark = opts.dark;
|
|
1545
|
+
if (dark !== void 0) {
|
|
1546
|
+
const diffNames = orderedNames(dark).filter((name) => light[name] !== dark[name]);
|
|
1547
|
+
if (diffNames.length > 0) {
|
|
1548
|
+
const attrSelector = scoped ? `[data-theme="dark"] ${selector}, ${selector}[data-theme="dark"]` : `[data-theme="dark"]`;
|
|
1549
|
+
const mediaSelector = scoped ? `:root:not([data-theme="light"]) ${selector}` : `:root:not([data-theme="light"])`;
|
|
1550
|
+
parts.push(renderBlock(attrSelector, dark, diffNames, ""));
|
|
1551
|
+
parts.push(
|
|
1552
|
+
`@media (prefers-color-scheme: dark) {
|
|
1553
|
+
${renderBlock(mediaSelector, dark, diffNames, " ")}
|
|
1554
|
+
}`
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
if (opts.includeInvert === true) {
|
|
1558
|
+
parts.push(renderBlock(".ui-invert", dark, orderedNames(dark), "", "color-scheme: dark;"));
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return `${parts.join("\n\n")}
|
|
1562
|
+
`;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// src/tokens/compile.ts
|
|
1566
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1567
|
+
function applyOverrides(light, overrides, warnings) {
|
|
1568
|
+
if (overrides === void 0) return;
|
|
1569
|
+
if (!isRecord2(overrides)) {
|
|
1570
|
+
warnings.push({ token: "--ui-accent", message: "overrides ignored: not an object" });
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
1574
|
+
if (!name.startsWith("--")) {
|
|
1575
|
+
warnings.push({ token: name, message: "override ignored: token names must start with --" });
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1579
|
+
warnings.push({ token: name, message: "override ignored: value must be a non-empty string" });
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
light[name] = value.trim();
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
function compileBrandTokens(input) {
|
|
1586
|
+
const { tokens: light, warnings } = mapPaletteToTokens(input);
|
|
1587
|
+
applyOverrides(light, input?.overrides, warnings);
|
|
1588
|
+
const dark = deriveDarkTokens(light);
|
|
1589
|
+
return { light, dark, warnings };
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// src/skill/asset-tools.ts
|
|
1593
|
+
var MAX_VIEW_BYTES = 4718592;
|
|
1594
|
+
var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
1595
|
+
function base64FromBytes(bytes) {
|
|
1596
|
+
if (typeof btoa === "function") {
|
|
1597
|
+
let binary = "";
|
|
1598
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
1599
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
1600
|
+
}
|
|
1601
|
+
return btoa(binary);
|
|
1602
|
+
}
|
|
1603
|
+
return Buffer.from(bytes).toString("base64");
|
|
1604
|
+
}
|
|
1605
|
+
var bareType = (ct) => (ct.split(";")[0] ?? "").trim().toLowerCase();
|
|
1606
|
+
function viewOutput(asset, bytes, contentType) {
|
|
1607
|
+
const caption = {
|
|
1608
|
+
type: "text",
|
|
1609
|
+
text: `Asset ${asset.id} (${asset.kind}, ${contentType}, ${bytes.byteLength} bytes${asset.title ? `, "${asset.title}"` : ""}):`
|
|
1610
|
+
};
|
|
1611
|
+
if (IMAGE_TYPES.has(contentType)) {
|
|
1612
|
+
const image = {
|
|
1613
|
+
type: "image",
|
|
1614
|
+
source: { type: "base64", mediaType: contentType, data: base64FromBytes(bytes) }
|
|
1615
|
+
};
|
|
1616
|
+
return { content: [caption, image] };
|
|
1617
|
+
}
|
|
1618
|
+
const document = {
|
|
1619
|
+
type: "document",
|
|
1620
|
+
source: { type: "base64", mediaType: "application/pdf", data: base64FromBytes(bytes) }
|
|
1621
|
+
};
|
|
1622
|
+
return { content: [caption, document] };
|
|
1623
|
+
}
|
|
1624
|
+
function assetTools(ctx) {
|
|
1625
|
+
const loadAsset = async (assetId) => {
|
|
1626
|
+
const res = await ctx.db.query({
|
|
1627
|
+
[BRAND_NS.asset]: { $: { where: { id: assetId, bookId: ctx.bookId } } }
|
|
1628
|
+
});
|
|
1629
|
+
const row = (res[BRAND_NS.asset] ?? [])[0];
|
|
1630
|
+
if (!row || row.deletedAt) throw new BrandNotFoundError(`asset ${assetId}`);
|
|
1631
|
+
return row;
|
|
1632
|
+
};
|
|
1633
|
+
const viewAsset = {
|
|
1634
|
+
name: "view_asset",
|
|
1635
|
+
description: "Look at an uploaded asset: fetches its bytes and returns the image (png/jpeg/gif/webp) or PDF for you to view. Other types cannot be viewed in-conversation.",
|
|
1636
|
+
inputSchema: {
|
|
1637
|
+
type: "object",
|
|
1638
|
+
required: ["assetId"],
|
|
1639
|
+
properties: { assetId: { type: "string", description: "The asset row id from list_assets." } }
|
|
1640
|
+
},
|
|
1641
|
+
outputTaint: ["tool_untrusted:view_asset"],
|
|
1642
|
+
handler: ctx.guard(async (input) => {
|
|
1643
|
+
const asset = await loadAsset(String(input.assetId));
|
|
1644
|
+
if (!ctx.visionInToolResults) {
|
|
1645
|
+
return {
|
|
1646
|
+
content: `This model cannot take images inside tool results. Asset ${asset.id} must be attached as a pre-turn image by the host instead \u2014 ask the human to re-send their message referencing the asset (the dispatcher attaches it up front), or describe it from what they tell you.`
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
const url = asset.url.startsWith("http") ? asset.url : ctx.fileBaseUrl + asset.url;
|
|
1650
|
+
const fetched = await ctx.fetchBytes(url);
|
|
1651
|
+
if (fetched.bytes.byteLength > MAX_VIEW_BYTES) {
|
|
1652
|
+
return {
|
|
1653
|
+
content: `Asset ${asset.id} is ${fetched.bytes.byteLength} bytes \u2014 over the ${MAX_VIEW_BYTES}-byte (4.5 MB) in-conversation viewing cap. Ask the human for a smaller export of this file.`
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
const served = bareType(fetched.contentType);
|
|
1657
|
+
const effective = IMAGE_TYPES.has(served) || served === "application/pdf" ? served : bareType(asset.contentType);
|
|
1658
|
+
if (!IMAGE_TYPES.has(effective) && effective !== "application/pdf") {
|
|
1659
|
+
return {
|
|
1660
|
+
content: `Asset ${asset.id} is ${effective} \u2014 stored but not viewable in-conversation. Ask the human to describe it, or to upload a PNG/JPEG export you can view.`
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
return viewOutput(asset, fetched.bytes, effective);
|
|
1664
|
+
})
|
|
1665
|
+
};
|
|
1666
|
+
const recordAnalysis = {
|
|
1667
|
+
name: "record_asset_analysis",
|
|
1668
|
+
description: "Record what you observed in an asset you viewed: a description, its dominant colors as #rrggbb hex, and tags. Overwrites any prior analysis.",
|
|
1669
|
+
inputSchema: {
|
|
1670
|
+
type: "object",
|
|
1671
|
+
required: ["assetId", "description", "dominantColors", "tags"],
|
|
1672
|
+
properties: {
|
|
1673
|
+
assetId: { type: "string" },
|
|
1674
|
+
description: { type: "string", description: "What the asset shows (max 2000 chars)." },
|
|
1675
|
+
dominantColors: { type: "array", items: { type: "string" }, maxItems: 12, description: "Dominant colors as hex." },
|
|
1676
|
+
tags: { type: "array", items: { type: "string" }, maxItems: 24 }
|
|
1677
|
+
}
|
|
1678
|
+
},
|
|
1679
|
+
handler: ctx.guard(async (input) => {
|
|
1680
|
+
const asset = await loadAsset(String(input.assetId));
|
|
1681
|
+
const analysis = assertAnalysis({
|
|
1682
|
+
description: input.description,
|
|
1683
|
+
dominantColors: input.dominantColors,
|
|
1684
|
+
tags: input.tags
|
|
1685
|
+
});
|
|
1686
|
+
await ctx.db.transact(recordAnalysisOps(asset.id, analysis, ctx.now()), { mutationId: ctx.newId() });
|
|
1687
|
+
return {
|
|
1688
|
+
content: `Recorded analysis for asset ${asset.id}: ${analysis.dominantColors.length} dominant color(s), ${analysis.tags.length} tag(s).`
|
|
1689
|
+
};
|
|
1690
|
+
})
|
|
1691
|
+
};
|
|
1692
|
+
return [viewAsset, recordAnalysis];
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/skill/book-tools.ts
|
|
1696
|
+
function bookTools(ctx) {
|
|
1697
|
+
const updateSection = {
|
|
1698
|
+
name: "update_section",
|
|
1699
|
+
description: "Create or replace one brand-book section (palette, typography, voice, logo, or imagery). Content is validated per kind. Status defaults to draft; a human approves.",
|
|
1700
|
+
inputSchema: {
|
|
1701
|
+
type: "object",
|
|
1702
|
+
required: ["kind", "content"],
|
|
1703
|
+
properties: {
|
|
1704
|
+
kind: { type: "string", enum: [...SECTION_KINDS] },
|
|
1705
|
+
content: { type: "object", description: "The section payload for its kind." },
|
|
1706
|
+
status: { type: "string", enum: ["draft", "approved"] }
|
|
1707
|
+
}
|
|
1708
|
+
},
|
|
1709
|
+
handler: ctx.guard(async (input) => {
|
|
1710
|
+
const book = await ctx.loadBook();
|
|
1711
|
+
const kind = String(input.kind);
|
|
1712
|
+
const ops = upsertSectionOps({
|
|
1713
|
+
bookId: ctx.bookId,
|
|
1714
|
+
kind,
|
|
1715
|
+
content: input.content,
|
|
1716
|
+
status: input.status === void 0 ? void 0 : input.status,
|
|
1717
|
+
audience: book.memberIds,
|
|
1718
|
+
updatedBy: ctx.self.selfId,
|
|
1719
|
+
now: ctx.now()
|
|
1720
|
+
});
|
|
1721
|
+
await ctx.db.transact(ops, { mutationId: ctx.newId() });
|
|
1722
|
+
const status = input.status === void 0 ? "draft" : String(input.status);
|
|
1723
|
+
return { content: `Saved ${kind} section as ${status} (key ${sectionKey(ctx.bookId, kind)}).` };
|
|
1724
|
+
})
|
|
1725
|
+
};
|
|
1726
|
+
const compileTokens = {
|
|
1727
|
+
name: "compile_tokens",
|
|
1728
|
+
description: "Compile the book's palette (paletteId argument, or the active palette) plus the typography section into light/dark @odla-ai/ui tokens, cached on the book. Returns the compiler's warnings.",
|
|
1729
|
+
inputSchema: {
|
|
1730
|
+
type: "object",
|
|
1731
|
+
properties: { paletteId: { type: "string", description: "Defaults to the book's active palette." } }
|
|
1732
|
+
},
|
|
1733
|
+
handler: ctx.guard(async (input) => {
|
|
1734
|
+
const book = await ctx.loadBook();
|
|
1735
|
+
const paletteId = input.paletteId === void 0 ? book.activePaletteId : capString(input.paletteId, "paletteId", 128);
|
|
1736
|
+
if (!paletteId) {
|
|
1737
|
+
throw new BrandInputError("no palette to compile: pass paletteId, or get a palette proposal accepted first");
|
|
1738
|
+
}
|
|
1739
|
+
const pres = await ctx.db.query({
|
|
1740
|
+
[BRAND_NS.palette]: { $: { where: { id: paletteId, bookId: ctx.bookId } } }
|
|
1741
|
+
});
|
|
1742
|
+
const palette = (pres[BRAND_NS.palette] ?? [])[0];
|
|
1743
|
+
if (!palette) throw new BrandNotFoundError(`palette ${paletteId}`);
|
|
1744
|
+
const sres = await ctx.db.query({
|
|
1745
|
+
[BRAND_NS.section]: { $: { where: { key: sectionKey(ctx.bookId, "typography") } } }
|
|
1746
|
+
});
|
|
1747
|
+
const typography = (sres[BRAND_NS.section] ?? [])[0];
|
|
1748
|
+
const compiled = compileBrandTokens({
|
|
1749
|
+
swatches: palette.swatches,
|
|
1750
|
+
...typography ? { typography: typography.content } : {}
|
|
1751
|
+
});
|
|
1752
|
+
const now = ctx.now();
|
|
1753
|
+
const snapshot = {
|
|
1754
|
+
light: compiled.light,
|
|
1755
|
+
dark: compiled.dark,
|
|
1756
|
+
warnings: compiled.warnings,
|
|
1757
|
+
compiledAt: now
|
|
1758
|
+
};
|
|
1759
|
+
await ctx.db.transact(updateBookOps(ctx.bookId, { tokens: snapshot }, now), { mutationId: ctx.newId() });
|
|
1760
|
+
const count = compiled.warnings.length;
|
|
1761
|
+
const lines = [
|
|
1762
|
+
`Compiled ${Object.keys(compiled.light).length} light + ${Object.keys(compiled.dark).length} dark tokens from palette ${paletteId}; ${count} warning(s).`,
|
|
1763
|
+
...compiled.warnings.slice(0, 3).map((w) => `- ${w.token}: ${w.message}${w.adjustedFrom ? ` (adjusted from ${w.adjustedFrom})` : ""}`)
|
|
1764
|
+
];
|
|
1765
|
+
if (count > 3) lines.push(`\u2026 and ${count - 3} more.`);
|
|
1766
|
+
return { content: lines.join("\n") };
|
|
1767
|
+
})
|
|
1768
|
+
};
|
|
1769
|
+
return [updateSection, compileTokens];
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/skill/palette-tools.ts
|
|
1773
|
+
var r2 = (n) => n.toFixed(2);
|
|
1774
|
+
var pf = (ok) => ok ? "pass" : "FAIL";
|
|
1775
|
+
var verdict = (ratio) => `${r2(ratio)}:1 \u2014 AA text ${pf(meetsAA(ratio))}, AA large/ui ${pf(meetsAA(ratio, "large-text"))}, AAA text ${pf(meetsAAA(ratio))}`;
|
|
1776
|
+
var HARMONY_COMPANIONS = {
|
|
1777
|
+
complementary: (s) => [complementary(s)],
|
|
1778
|
+
analogous: (s) => [...analogous(s)],
|
|
1779
|
+
triadic: (s) => [...triadic(s)],
|
|
1780
|
+
split: (s) => [...splitComplementary(s)],
|
|
1781
|
+
tetradic: (s) => tetradic(s).slice(0, 2),
|
|
1782
|
+
monochrome: (s) => monochrome(s, 4).slice(1, 3)
|
|
1783
|
+
};
|
|
1784
|
+
function applyHarmony(swatches, seedHex, harmony) {
|
|
1785
|
+
if (typeof harmony !== "string" || !(harmony in HARMONY_COMPANIONS)) {
|
|
1786
|
+
throw new BrandInputError(`harmony must be one of: ${Object.keys(HARMONY_COMPANIONS).join(", ")}`);
|
|
1787
|
+
}
|
|
1788
|
+
const [secondary, highlight] = HARMONY_COMPANIONS[harmony](seedHex);
|
|
1789
|
+
return swatches.map((s) => {
|
|
1790
|
+
const hex = s.role === "secondary" ? secondary : s.role === "highlight" ? highlight : void 0;
|
|
1791
|
+
if (!hex) return s;
|
|
1792
|
+
return { ...s, hex, name: nearestNamedColor(hex).name, rationale: `${harmony} companion of the seed` };
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
function contrastReport(swatches) {
|
|
1796
|
+
const byRole = (role) => swatches.find((s) => s.role === role)?.hex;
|
|
1797
|
+
const bg = byRole("bg") ?? "#ffffff";
|
|
1798
|
+
const report = {};
|
|
1799
|
+
const put = (label, fg) => {
|
|
1800
|
+
if (fg) report[label] = Math.round(contrastRatio(fg, bg) * 100) / 100;
|
|
1801
|
+
};
|
|
1802
|
+
put("text-on-bg", byRole("text"));
|
|
1803
|
+
put("primary-on-bg", byRole("primary"));
|
|
1804
|
+
put("good-on-bg", byRole("good"));
|
|
1805
|
+
put("warn-on-bg", byRole("warn"));
|
|
1806
|
+
put("danger-on-bg", byRole("danger"));
|
|
1807
|
+
const primary = byRole("primary");
|
|
1808
|
+
if (primary) report["text-on-primary"] = Math.round(contrastRatio(pickTextOn(primary), primary) * 100) / 100;
|
|
1809
|
+
return report;
|
|
1810
|
+
}
|
|
1811
|
+
var INCLUDE_SECTIONS = ["harmony", "ramp"];
|
|
1812
|
+
function analyzeLines(hex, include) {
|
|
1813
|
+
const lch = hexToOklch(hex);
|
|
1814
|
+
const named = nearestNamedColor(hex);
|
|
1815
|
+
const lines = [
|
|
1816
|
+
`${hex} \u2014 nearest CSS name: ${named.name} (\u0394EOK ${named.deltaEOK.toFixed(3)})`,
|
|
1817
|
+
`OKLCH: L ${lch.L.toFixed(3)}, C ${lch.C.toFixed(3)}, h ${lch.h.toFixed(1)}\xB0`,
|
|
1818
|
+
`relative luminance ${relativeLuminance(hex).toFixed(3)}; contrast ${r2(contrastRatio(hex, "#ffffff"))}:1 on white, ${r2(contrastRatio(hex, "#000000"))}:1 on black`,
|
|
1819
|
+
`readable text on it: ${pickTextOn(hex)}`
|
|
1820
|
+
];
|
|
1821
|
+
if (include.includes("harmony")) {
|
|
1822
|
+
const [aMinus, aPlus] = analogous(hex);
|
|
1823
|
+
const [tMinus, tPlus] = triadic(hex);
|
|
1824
|
+
const [sMinus, sPlus] = splitComplementary(hex);
|
|
1825
|
+
lines.push(
|
|
1826
|
+
`harmony \u2014 complementary ${complementary(hex)}; analogous ${aMinus} ${aPlus}; triadic ${tMinus} ${tPlus}; split ${sMinus} ${sPlus}`
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
if (include.includes("ramp")) lines.push(`ramp \u2014 ${tintShadeRamp(hex).join(" ")}`);
|
|
1830
|
+
return lines;
|
|
1831
|
+
}
|
|
1832
|
+
function paletteTools(ctx) {
|
|
1833
|
+
const analyzeColor = {
|
|
1834
|
+
name: "analyze_color",
|
|
1835
|
+
description: "Analyze one color: nearest CSS name, OKLCH coordinates, luminance, contrast on white/black. Optionally include harmony companions and a tint/shade ramp.",
|
|
1836
|
+
inputSchema: {
|
|
1837
|
+
type: "object",
|
|
1838
|
+
required: ["hex"],
|
|
1839
|
+
properties: {
|
|
1840
|
+
hex: { type: "string", description: "#rgb or #rrggbb" },
|
|
1841
|
+
include: { type: "array", items: { type: "string", enum: [...INCLUDE_SECTIONS] } }
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
handler: ctx.guard(async (input) => {
|
|
1845
|
+
const hex = assertHex(input.hex, "hex");
|
|
1846
|
+
const include = input.include === void 0 ? [] : input.include;
|
|
1847
|
+
if (!Array.isArray(include) || include.some((s) => !INCLUDE_SECTIONS.includes(s))) {
|
|
1848
|
+
throw new BrandInputError(`include entries must be one of: ${INCLUDE_SECTIONS.join(", ")}`);
|
|
1849
|
+
}
|
|
1850
|
+
return { content: analyzeLines(hex, include).join("\n") };
|
|
1851
|
+
})
|
|
1852
|
+
};
|
|
1853
|
+
const evaluateContrast = {
|
|
1854
|
+
name: "evaluate_contrast",
|
|
1855
|
+
description: "WCAG 2.1 contrast: pass pairs ([{fg,bg}]) for specific combinations, or hexes ([\u2026]) for every pairwise ratio. Reports AA/AAA verdicts.",
|
|
1856
|
+
inputSchema: {
|
|
1857
|
+
type: "object",
|
|
1858
|
+
properties: {
|
|
1859
|
+
pairs: {
|
|
1860
|
+
type: "array",
|
|
1861
|
+
maxItems: 20,
|
|
1862
|
+
items: { type: "object", required: ["fg", "bg"], properties: { fg: { type: "string" }, bg: { type: "string" } } }
|
|
1863
|
+
},
|
|
1864
|
+
hexes: { type: "array", minItems: 2, maxItems: 8, items: { type: "string" } }
|
|
1865
|
+
}
|
|
1866
|
+
},
|
|
1867
|
+
handler: ctx.guard(async (input) => {
|
|
1868
|
+
if (Array.isArray(input.pairs) && input.pairs.length > 0) {
|
|
1869
|
+
if (input.pairs.length > 20) throw new BrandInputError("pairs must have at most 20 entries");
|
|
1870
|
+
const lines = input.pairs.map((raw, i) => {
|
|
1871
|
+
const p = raw ?? {};
|
|
1872
|
+
const fg = assertHex(p.fg, `pairs[${i}].fg`);
|
|
1873
|
+
const bg = assertHex(p.bg, `pairs[${i}].bg`);
|
|
1874
|
+
return `${fg} on ${bg}: ${verdict(contrastRatio(fg, bg))}`;
|
|
1875
|
+
});
|
|
1876
|
+
return { content: lines.join("\n") };
|
|
1877
|
+
}
|
|
1878
|
+
if (Array.isArray(input.hexes)) {
|
|
1879
|
+
if (input.hexes.length < 2 || input.hexes.length > 8) {
|
|
1880
|
+
throw new BrandInputError("hexes must have 2 to 8 entries");
|
|
1881
|
+
}
|
|
1882
|
+
const hexes = input.hexes.map((h, i) => assertHex(h, `hexes[${i}]`));
|
|
1883
|
+
const lines = [];
|
|
1884
|
+
for (let i = 0; i < hexes.length; i++) {
|
|
1885
|
+
for (let j = i + 1; j < hexes.length; j++) {
|
|
1886
|
+
lines.push(`${hexes[i]} vs ${hexes[j]}: ${verdict(contrastRatio(hexes[i], hexes[j]))}`);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
return { content: lines.join("\n") };
|
|
1890
|
+
}
|
|
1891
|
+
throw new BrandInputError("provide pairs ([{fg,bg}, \u2026]) or hexes ([#rrggbb, \u2026])");
|
|
1892
|
+
})
|
|
1893
|
+
};
|
|
1894
|
+
const proposePalette = {
|
|
1895
|
+
name: "propose_palette",
|
|
1896
|
+
description: "Park a palette proposal for human review (does NOT change the brand). Pass explicit swatches, or a seedHex (and optional harmony) to derive a full palette. A WCAG contrast report is attached automatically.",
|
|
1897
|
+
inputSchema: {
|
|
1898
|
+
type: "object",
|
|
1899
|
+
required: ["name", "rationale"],
|
|
1900
|
+
properties: {
|
|
1901
|
+
name: { type: "string" },
|
|
1902
|
+
rationale: { type: "string", description: "Why these colors \u2014 cite assets, harmony, contrast." },
|
|
1903
|
+
seedHex: { type: "string" },
|
|
1904
|
+
harmony: { type: "string", enum: Object.keys(HARMONY_COMPANIONS) },
|
|
1905
|
+
swatches: {
|
|
1906
|
+
type: "array",
|
|
1907
|
+
items: {
|
|
1908
|
+
type: "object",
|
|
1909
|
+
required: ["role", "hex"],
|
|
1910
|
+
properties: {
|
|
1911
|
+
role: { type: "string" },
|
|
1912
|
+
hex: { type: "string" },
|
|
1913
|
+
name: { type: "string" },
|
|
1914
|
+
rationale: { type: "string" }
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
},
|
|
1920
|
+
handler: ctx.guard(async (input) => {
|
|
1921
|
+
const book = await ctx.loadBook();
|
|
1922
|
+
const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
|
|
1923
|
+
let swatches;
|
|
1924
|
+
if (input.swatches !== void 0) {
|
|
1925
|
+
swatches = assertSwatches(input.swatches);
|
|
1926
|
+
} else {
|
|
1927
|
+
if (!seedHex) throw new BrandInputError("provide swatches, or a seedHex to derive a palette from");
|
|
1928
|
+
swatches = derivePalette(seedHex);
|
|
1929
|
+
if (input.harmony !== void 0) swatches = applyHarmony(swatches, seedHex, input.harmony);
|
|
1930
|
+
}
|
|
1931
|
+
const report = contrastReport(swatches);
|
|
1932
|
+
const id = ctx.newId();
|
|
1933
|
+
const ops = proposePaletteOps({
|
|
1934
|
+
id,
|
|
1935
|
+
bookId: ctx.bookId,
|
|
1936
|
+
name: String(input.name),
|
|
1937
|
+
rationale: String(input.rationale),
|
|
1938
|
+
swatches,
|
|
1939
|
+
seedHex,
|
|
1940
|
+
contrastReport: report,
|
|
1941
|
+
createdBy: ctx.self.selfId,
|
|
1942
|
+
audience: book.memberIds,
|
|
1943
|
+
now: ctx.now()
|
|
1944
|
+
});
|
|
1945
|
+
await ctx.db.transact(ops, { mutationId: id });
|
|
1946
|
+
const reportText = Object.entries(report).map(([k, v]) => `${k} ${r2(v)}:1`).join(", ");
|
|
1947
|
+
return {
|
|
1948
|
+
content: `Parked palette proposal ${id} ("${String(input.name).trim()}", ${swatches.length} swatches${seedHex ? `, seeded from ${seedHex}` : ""}). Contrast: ${reportText}. Awaiting explicit human approval \u2014 call resolve_proposal only after the human confirms.`
|
|
1949
|
+
};
|
|
1950
|
+
})
|
|
1951
|
+
};
|
|
1952
|
+
const resolveProposal = {
|
|
1953
|
+
name: "resolve_proposal",
|
|
1954
|
+
description: "Apply the human's explicit verdict on an open proposal. ONLY call this after the human has clearly accepted or rejected in the conversation \u2014 never on your own initiative.",
|
|
1955
|
+
inputSchema: {
|
|
1956
|
+
type: "object",
|
|
1957
|
+
required: ["proposalId", "resolution"],
|
|
1958
|
+
properties: {
|
|
1959
|
+
proposalId: { type: "string" },
|
|
1960
|
+
resolution: { type: "string", enum: ["accepted", "rejected"] },
|
|
1961
|
+
note: { type: "string" }
|
|
1962
|
+
}
|
|
1963
|
+
},
|
|
1964
|
+
handler: ctx.guard(async (input) => {
|
|
1965
|
+
const proposalId = String(input.proposalId);
|
|
1966
|
+
const res = await ctx.db.query({
|
|
1967
|
+
[BRAND_NS.proposal]: { $: { where: { id: proposalId, bookId: ctx.bookId } } }
|
|
1968
|
+
});
|
|
1969
|
+
const proposal = (res[BRAND_NS.proposal] ?? [])[0];
|
|
1970
|
+
if (!proposal) throw new BrandNotFoundError(`proposal ${proposalId}`);
|
|
1971
|
+
const note = input.note === void 0 ? void 0 : capString(input.note, "note", 1e3);
|
|
1972
|
+
const now = ctx.now();
|
|
1973
|
+
if (input.resolution === "accepted") {
|
|
1974
|
+
const paletteId = ctx.newId();
|
|
1975
|
+
const ops = acceptProposalOps({ proposal, paletteId, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
|
|
1976
|
+
await ctx.db.transact(ops, { mutationId: paletteId });
|
|
1977
|
+
return { content: `Proposal ${proposalId} accepted \u2014 palette ${paletteId} written and set as the book's active palette.` };
|
|
1978
|
+
}
|
|
1979
|
+
if (input.resolution === "rejected") {
|
|
1980
|
+
const ops = rejectProposalOps({ proposal, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
|
|
1981
|
+
await ctx.db.transact(ops, { mutationId: ctx.newId() });
|
|
1982
|
+
return { content: `Proposal ${proposalId} rejected.` };
|
|
1983
|
+
}
|
|
1984
|
+
throw new BrandInputError('resolution must be "accepted" or "rejected"');
|
|
1985
|
+
})
|
|
1986
|
+
};
|
|
1987
|
+
return [analyzeColor, evaluateContrast, proposePalette, resolveProposal];
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// src/skill/read-tools.ts
|
|
1991
|
+
var iso = (ms) => new Date(ms).toISOString();
|
|
1992
|
+
function bookLines(book) {
|
|
1993
|
+
const lines = [
|
|
1994
|
+
`brand book "${book.name}" (${book.slug}) \u2014 ${book.status}`,
|
|
1995
|
+
`members: ${book.memberIds.join(", ")}`,
|
|
1996
|
+
`active palette: ${book.activePaletteId ?? "(none accepted yet)"}`,
|
|
1997
|
+
book.tokens ? `tokens: compiled ${iso(book.tokens.compiledAt)} with ${book.tokens.warnings.length} warning(s)` : "tokens: (not compiled)"
|
|
1998
|
+
];
|
|
1999
|
+
if (book.summary) lines.push(`summary: ${book.summary}`);
|
|
2000
|
+
return lines;
|
|
2001
|
+
}
|
|
2002
|
+
function childLines(sections, palettes, proposals) {
|
|
2003
|
+
return [
|
|
2004
|
+
"sections:",
|
|
2005
|
+
...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by ${s.updatedBy}`) : ["- (none)"],
|
|
2006
|
+
"palettes:",
|
|
2007
|
+
...palettes.length ? palettes.map((p) => `- ${p.id} "${p.name}" (${p.status}, ${p.source}, ${p.swatches.length} swatches)`) : ["- (none)"],
|
|
2008
|
+
"open proposals:",
|
|
2009
|
+
...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${p.createdBy}: ${p.rationale}`) : ["- (none)"]
|
|
2010
|
+
];
|
|
2011
|
+
}
|
|
2012
|
+
function readTools(ctx) {
|
|
2013
|
+
const readBrandBook = {
|
|
2014
|
+
name: "read_brand_book",
|
|
2015
|
+
description: "Read the current brand book: status, members, active palette, compiled tokens, sections, palettes, and open proposals.",
|
|
2016
|
+
inputSchema: { type: "object", properties: {} },
|
|
2017
|
+
handler: ctx.guard(async () => {
|
|
2018
|
+
const res = await ctx.db.query({
|
|
2019
|
+
[BRAND_NS.book]: { $: { where: { id: ctx.bookId } } },
|
|
2020
|
+
[BRAND_NS.section]: { $: { where: { bookId: ctx.bookId }, order: { updatedAt: "asc" } } },
|
|
2021
|
+
[BRAND_NS.palette]: { $: { where: { bookId: ctx.bookId }, order: { createdAt: "asc" } } },
|
|
2022
|
+
[BRAND_NS.proposal]: { $: { where: { bookId: ctx.bookId, status: "open" }, order: { createdAt: "asc" } } }
|
|
2023
|
+
});
|
|
2024
|
+
const book = (res[BRAND_NS.book] ?? [])[0];
|
|
2025
|
+
if (!book) throw new BrandNotFoundError(`brand book ${ctx.bookId}`);
|
|
2026
|
+
const sections = res[BRAND_NS.section] ?? [];
|
|
2027
|
+
const palettes = res[BRAND_NS.palette] ?? [];
|
|
2028
|
+
const proposals = res[BRAND_NS.proposal] ?? [];
|
|
2029
|
+
return { content: [...bookLines(book), ...childLines(sections, palettes, proposals)].join("\n") };
|
|
2030
|
+
})
|
|
2031
|
+
};
|
|
2032
|
+
const listAssets = {
|
|
2033
|
+
name: "list_assets",
|
|
2034
|
+
description: "List the book's uploaded assets (id, kind, content type, size, title, analysis state). Tombstoned assets are excluded.",
|
|
2035
|
+
inputSchema: {
|
|
2036
|
+
type: "object",
|
|
2037
|
+
properties: {
|
|
2038
|
+
kind: { type: "string", enum: [...ASSET_KINDS], description: "Only assets of this kind." }
|
|
2039
|
+
}
|
|
2040
|
+
},
|
|
2041
|
+
handler: ctx.guard(async (input) => {
|
|
2042
|
+
const where = { bookId: ctx.bookId };
|
|
2043
|
+
if (input.kind !== void 0) {
|
|
2044
|
+
if (typeof input.kind !== "string" || !ASSET_KINDS.includes(input.kind)) {
|
|
2045
|
+
throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
|
|
2046
|
+
}
|
|
2047
|
+
where.kind = input.kind;
|
|
2048
|
+
}
|
|
2049
|
+
const res = await ctx.db.query({ [BRAND_NS.asset]: { $: { where, order: { createdAt: "asc" } } } });
|
|
2050
|
+
const rows = (res[BRAND_NS.asset] ?? []).filter((a2) => !a2.deletedAt);
|
|
2051
|
+
if (rows.length === 0) {
|
|
2052
|
+
return { content: input.kind ? `(no ${String(input.kind)} assets uploaded yet)` : "(no assets uploaded yet)" };
|
|
2053
|
+
}
|
|
2054
|
+
const lines = rows.map(
|
|
2055
|
+
(a2) => `${a2.id} \u2014 ${a2.kind}, ${a2.contentType}, ${a2.size} bytes${a2.title ? `, "${a2.title}"` : ""}${a2.analysis ? " (analyzed)" : " (not analyzed)"}`
|
|
2056
|
+
);
|
|
2057
|
+
return { content: lines.join("\n") };
|
|
2058
|
+
})
|
|
2059
|
+
};
|
|
2060
|
+
return [readBrandBook, listAssets];
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// src/skill/skill.ts
|
|
2064
|
+
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. WAIT for the human to explicitly accept or reject in the conversation before calling resolve_proposal \u2014 never resolve a proposal on your own initiative.\n6. After acceptance, document the rest with update_section (typography, voice, logo, imagery) and run compile_tokens.\n7. Relay compile warnings conversationally \u2014 explain what was adjusted and why, don't just paste them.";
|
|
2065
|
+
function brandSkill(opts) {
|
|
2066
|
+
const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId, fetchBytes: opts.fetchBytes });
|
|
2067
|
+
const ctx = {
|
|
2068
|
+
db: deps.db,
|
|
2069
|
+
bookId: opts.bookId,
|
|
2070
|
+
self: opts.self,
|
|
2071
|
+
fileBaseUrl: opts.fileBaseUrl,
|
|
2072
|
+
fetchBytes: deps.fetchBytes,
|
|
2073
|
+
visionInToolResults: opts.visionInToolResults !== false,
|
|
2074
|
+
now: deps.now,
|
|
2075
|
+
newId: deps.newId,
|
|
2076
|
+
loadBook: async () => {
|
|
2077
|
+
const res = await deps.db.query({ [BRAND_NS.book]: { $: { where: { id: opts.bookId } } } });
|
|
2078
|
+
const row = (res[BRAND_NS.book] ?? [])[0];
|
|
2079
|
+
if (!row) throw new BrandNotFoundError(`brand book ${opts.bookId}`);
|
|
2080
|
+
return row;
|
|
2081
|
+
},
|
|
2082
|
+
guard: (handler) => async (input, toolCtx) => {
|
|
2083
|
+
try {
|
|
2084
|
+
return await handler(input, toolCtx);
|
|
2085
|
+
} catch (error) {
|
|
2086
|
+
if (error instanceof BrandInputError || error instanceof BrandNotFoundError) {
|
|
2087
|
+
return { content: error.message, isError: true };
|
|
2088
|
+
}
|
|
2089
|
+
throw error;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
return {
|
|
2094
|
+
name: "brand",
|
|
2095
|
+
instructions: BRAND_INSTRUCTIONS,
|
|
2096
|
+
tools: [...readTools(ctx), ...assetTools(ctx), ...paletteTools(ctx), ...bookTools(ctx)]
|
|
2097
|
+
};
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// src/skill/persona.ts
|
|
2101
|
+
var DEFAULT_BRAND_SYSTEM = "You are a meticulous brand director for one brand book. You study the real material before forming opinions, you justify every color with math (harmony, \u0394EOK, WCAG contrast) or provenance (an asset's dominant colors), and you present options rather than dictating. Proposals are yours to make; decisions are the human's \u2014 never resolve a proposal without their explicit confirmation in this conversation. When tokens compile with warnings, explain each adjustment in plain language.";
|
|
2102
|
+
function createBrandPersona(opts) {
|
|
2103
|
+
return {
|
|
2104
|
+
name: "brand-director",
|
|
2105
|
+
model: opts.model,
|
|
2106
|
+
system: opts.system ?? DEFAULT_BRAND_SYSTEM,
|
|
2107
|
+
webSearch: opts.webSearch ?? true,
|
|
2108
|
+
maxSteps: opts.maxSteps ?? 10,
|
|
2109
|
+
skills: [brandSkill(opts.brand), ...opts.skills ?? []]
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
function supportsBrandVision(spec) {
|
|
2113
|
+
const caps = spec?.capabilities;
|
|
2114
|
+
return !!(caps?.toolResultBlocks && caps.imageIn && caps.documentIn);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// src/routes/http.ts
|
|
2118
|
+
var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
2119
|
+
status,
|
|
2120
|
+
headers: { "content-type": "application/json", ...headers }
|
|
2121
|
+
});
|
|
2122
|
+
var errorResponse = (error) => {
|
|
2123
|
+
if (error instanceof BrandInputError)
|
|
2124
|
+
return json({ error: error.message, ...error.fields ? { fields: error.fields } : {} }, 400);
|
|
2125
|
+
if (error instanceof BrandNotFoundError) return json({ error: error.message }, 404);
|
|
2126
|
+
return json({ error: "internal error" }, 500);
|
|
2127
|
+
};
|
|
2128
|
+
var methodNotAllowed = () => json({ error: "method not allowed" }, 405);
|
|
2129
|
+
async function readJson(req) {
|
|
2130
|
+
let body;
|
|
2131
|
+
try {
|
|
2132
|
+
body = await req.json();
|
|
2133
|
+
} catch {
|
|
2134
|
+
throw new BrandInputError("invalid JSON body");
|
|
2135
|
+
}
|
|
2136
|
+
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
2137
|
+
throw new BrandInputError("body must be a JSON object");
|
|
2138
|
+
return body;
|
|
2139
|
+
}
|
|
2140
|
+
function str(body, key) {
|
|
2141
|
+
const value = body[key];
|
|
2142
|
+
if (typeof value !== "string" || value === "")
|
|
2143
|
+
throw new BrandInputError(`"${key}" must be a non-empty string`);
|
|
2144
|
+
return value;
|
|
2145
|
+
}
|
|
2146
|
+
function optStr(body, key) {
|
|
2147
|
+
const value = body[key];
|
|
2148
|
+
if (value === void 0 || value === null) return void 0;
|
|
2149
|
+
if (typeof value !== "string") throw new BrandInputError(`"${key}" must be a string`);
|
|
2150
|
+
return value;
|
|
2151
|
+
}
|
|
2152
|
+
function optStrArray(body, key) {
|
|
2153
|
+
const value = body[key];
|
|
2154
|
+
if (value === void 0 || value === null) return void 0;
|
|
2155
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string"))
|
|
2156
|
+
throw new BrandInputError(`"${key}" must be an array of strings`);
|
|
2157
|
+
return value;
|
|
2158
|
+
}
|
|
2159
|
+
function isMember(book, actorId) {
|
|
2160
|
+
return Array.isArray(book.memberIds) && book.memberIds.includes(actorId);
|
|
2161
|
+
}
|
|
2162
|
+
async function loadBook(db, bookId) {
|
|
2163
|
+
const res = await db.query({ [BRAND_NS.book]: { $: { where: { id: bookId } } } });
|
|
2164
|
+
const row = (res[BRAND_NS.book] ?? [])[0];
|
|
2165
|
+
if (!row) throw new BrandNotFoundError(`brand book ${bookId}`);
|
|
2166
|
+
return row;
|
|
2167
|
+
}
|
|
2168
|
+
async function loadMemberBook(db, bookId, actorId) {
|
|
2169
|
+
const book = await loadBook(db, bookId);
|
|
2170
|
+
if (!isMember(book, actorId)) throw new BrandNotFoundError(`brand book ${bookId}`);
|
|
2171
|
+
return book;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// src/routes/assets.ts
|
|
2175
|
+
async function readForm(req) {
|
|
2176
|
+
try {
|
|
2177
|
+
return await req.formData();
|
|
2178
|
+
} catch {
|
|
2179
|
+
throw new BrandInputError('expected a multipart/form-data body with a "file" field');
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
async function uploadAsset(ctx, req, bookId) {
|
|
2183
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
2184
|
+
const form = await readForm(req);
|
|
2185
|
+
const file = form.get("file");
|
|
2186
|
+
if (!(file instanceof File)) throw new BrandInputError('"file" must be an uploaded file field');
|
|
2187
|
+
let contentType;
|
|
2188
|
+
try {
|
|
2189
|
+
contentType = assertAssetContentType(file.type);
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
if (error instanceof BrandInputError) return json({ error: error.message }, 415);
|
|
2192
|
+
throw error;
|
|
2193
|
+
}
|
|
2194
|
+
if (file.size > ctx.maxUploadBytes)
|
|
2195
|
+
return json(
|
|
2196
|
+
{ error: `file is ${file.size} bytes; the upload limit is ${ctx.maxUploadBytes}` },
|
|
2197
|
+
413
|
|
2198
|
+
);
|
|
2199
|
+
const kindRaw = form.get("kind");
|
|
2200
|
+
const kind = typeof kindRaw === "string" && kindRaw !== "" ? kindRaw : "other";
|
|
2201
|
+
if (!ASSET_KINDS.includes(kind))
|
|
2202
|
+
throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
|
|
2203
|
+
const titleRaw = form.get("title");
|
|
2204
|
+
const title = typeof titleRaw === "string" && titleRaw !== "" ? capString(titleRaw, "title", 160) : void 0;
|
|
2205
|
+
const id = ctx.newId();
|
|
2206
|
+
const path = `brand/${book.id}/assets/${id}/${safeFileName(file.name)}`;
|
|
2207
|
+
const record = await ctx.db.storage.upload(path, file, contentType);
|
|
2208
|
+
await ctx.db.transact(
|
|
2209
|
+
createAssetOps({
|
|
2210
|
+
id,
|
|
2211
|
+
bookId: book.id,
|
|
2212
|
+
kind,
|
|
2213
|
+
path: record.path,
|
|
2214
|
+
url: record.url,
|
|
2215
|
+
contentType,
|
|
2216
|
+
size: record.size,
|
|
2217
|
+
uploadedBy: ctx.actor.id,
|
|
2218
|
+
audience: book.memberIds,
|
|
2219
|
+
title,
|
|
2220
|
+
now: ctx.now()
|
|
2221
|
+
}),
|
|
2222
|
+
{ mutationId: id }
|
|
2223
|
+
);
|
|
2224
|
+
const res = await ctx.db.query({ [BRAND_NS.asset]: { $: { where: { id } } } });
|
|
2225
|
+
return json((res[BRAND_NS.asset] ?? [])[0], 201);
|
|
2226
|
+
}
|
|
2227
|
+
async function handleAssetsRoot(ctx, req, bookId) {
|
|
2228
|
+
if (req.method === "GET") {
|
|
2229
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
2230
|
+
const res = await ctx.db.query({
|
|
2231
|
+
[BRAND_NS.asset]: {
|
|
2232
|
+
// `deletedAt: null` matches ONLY rows without the attr — odla-db's
|
|
2233
|
+
// "absence is a missing triple" semantics — i.e. not tombstoned.
|
|
2234
|
+
$: { where: { bookId: book.id, deletedAt: null }, order: { createdAt: "asc" } }
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
return json({ assets: res[BRAND_NS.asset] ?? [] });
|
|
2238
|
+
}
|
|
2239
|
+
if (req.method === "POST") return uploadAsset(ctx, req, bookId);
|
|
2240
|
+
return methodNotAllowed();
|
|
2241
|
+
}
|
|
2242
|
+
async function handleAssetItem(ctx, req, bookId, assetId) {
|
|
2243
|
+
if (req.method !== "DELETE") return methodNotAllowed();
|
|
2244
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
2245
|
+
const res = await ctx.db.query({
|
|
2246
|
+
[BRAND_NS.asset]: { $: { where: { id: assetId, bookId: book.id } } }
|
|
2247
|
+
});
|
|
2248
|
+
const asset = (res[BRAND_NS.asset] ?? [])[0];
|
|
2249
|
+
if (!asset || asset.deletedAt != null) throw new BrandNotFoundError(`asset ${assetId}`);
|
|
2250
|
+
await ctx.db.storage.delete(asset.path);
|
|
2251
|
+
const deletedAt = ctx.now();
|
|
2252
|
+
await ctx.db.transact(tombstoneAssetOps(asset.id, deletedAt));
|
|
2253
|
+
return json({ id: asset.id, deletedAt });
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// src/routes/books.ts
|
|
2257
|
+
async function handleBooksRoot(ctx, req) {
|
|
2258
|
+
if (req.method === "GET") {
|
|
2259
|
+
const res = await ctx.db.query({ [BRAND_NS.book]: { $: { order: { createdAt: "asc" } } } });
|
|
2260
|
+
const books = (res[BRAND_NS.book] ?? []).filter(
|
|
2261
|
+
(b) => isMember(b, ctx.actor.id)
|
|
2262
|
+
);
|
|
2263
|
+
return json({ books });
|
|
2264
|
+
}
|
|
2265
|
+
if (req.method === "POST") {
|
|
2266
|
+
const body = await readJson(req);
|
|
2267
|
+
const id = ctx.newId();
|
|
2268
|
+
const ops = createBookOps({
|
|
2269
|
+
id,
|
|
2270
|
+
slug: str(body, "slug"),
|
|
2271
|
+
name: str(body, "name"),
|
|
2272
|
+
ownerId: ctx.actor.id,
|
|
2273
|
+
memberIds: optStrArray(body, "memberIds") ?? [],
|
|
2274
|
+
channelId: optStr(body, "channelId"),
|
|
2275
|
+
now: ctx.now()
|
|
2276
|
+
});
|
|
2277
|
+
await ctx.db.transact(ops, { mutationId: id });
|
|
2278
|
+
return json(await loadBook(ctx.db, id), 201);
|
|
2279
|
+
}
|
|
2280
|
+
return methodNotAllowed();
|
|
2281
|
+
}
|
|
2282
|
+
async function handleBookItem(ctx, req, bookId) {
|
|
2283
|
+
if (req.method !== "GET") return methodNotAllowed();
|
|
2284
|
+
return json(await loadMemberBook(ctx.db, bookId, ctx.actor.id));
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// src/routes/tokens.ts
|
|
2288
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2289
|
+
async function resolveTokenMaps(db, book) {
|
|
2290
|
+
const cache = book.tokens;
|
|
2291
|
+
if (cache && isRecord3(cache.light) && isRecord3(cache.dark))
|
|
2292
|
+
return { light: cache.light, dark: cache.dark };
|
|
2293
|
+
if (!book.activePaletteId) throw new BrandNotFoundError(`compiled tokens for brand book ${book.id}`);
|
|
2294
|
+
const pres = await db.query({
|
|
2295
|
+
[BRAND_NS.palette]: { $: { where: { id: book.activePaletteId } } }
|
|
2296
|
+
});
|
|
2297
|
+
const palette = (pres[BRAND_NS.palette] ?? [])[0];
|
|
2298
|
+
if (!palette) throw new BrandNotFoundError(`compiled tokens for brand book ${book.id}`);
|
|
2299
|
+
const sres = await db.query({
|
|
2300
|
+
[BRAND_NS.section]: { $: { where: { key: `${book.id}:typography` } } }
|
|
2301
|
+
});
|
|
2302
|
+
const typography = (sres[BRAND_NS.section] ?? [])[0]?.content;
|
|
2303
|
+
const compiled = compileBrandTokens({ swatches: palette.swatches, typography });
|
|
2304
|
+
return { light: compiled.light, dark: compiled.dark };
|
|
2305
|
+
}
|
|
2306
|
+
async function handleTokens(db, req, bookId, which, actor) {
|
|
2307
|
+
if (req.method !== "GET") return methodNotAllowed();
|
|
2308
|
+
const book = actor === null ? await loadBook(db, bookId) : await loadMemberBook(db, bookId, actor.id);
|
|
2309
|
+
const maps = await resolveTokenMaps(db, book);
|
|
2310
|
+
if (which === "tokens.json") return json({ light: maps.light, dark: maps.dark });
|
|
2311
|
+
const etag = `"brand-tokens-${book.updatedAt}"`;
|
|
2312
|
+
const inm = req.headers.get("if-none-match");
|
|
2313
|
+
if (inm && inm.split(",").map((v) => v.trim()).includes(etag))
|
|
2314
|
+
return new Response(null, { status: 304, headers: { etag } });
|
|
2315
|
+
return new Response(renderTokensCss(maps.light, { dark: maps.dark }), {
|
|
2316
|
+
status: 200,
|
|
2317
|
+
headers: { "content-type": "text/css; charset=utf-8", etag }
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// src/routes/index.ts
|
|
2322
|
+
var tokensFile = (seg) => seg.length === 3 && seg[0] === "books" && (seg[2] === "tokens.css" || seg[2] === "tokens.json") ? seg[2] : null;
|
|
2323
|
+
async function route(ctx, req, seg) {
|
|
2324
|
+
const [head, id, sub, subId] = seg;
|
|
2325
|
+
if (head !== "books") return null;
|
|
2326
|
+
if (seg.length === 1) return handleBooksRoot(ctx, req);
|
|
2327
|
+
if (seg.length === 2) return handleBookItem(ctx, req, id);
|
|
2328
|
+
if (sub === "assets") {
|
|
2329
|
+
if (seg.length === 3) return handleAssetsRoot(ctx, req, id);
|
|
2330
|
+
if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
|
|
2331
|
+
return null;
|
|
2332
|
+
}
|
|
2333
|
+
const file = tokensFile(seg);
|
|
2334
|
+
if (file) return handleTokens(ctx.db, req, id, file, ctx.actor);
|
|
2335
|
+
return null;
|
|
2336
|
+
}
|
|
2337
|
+
function createBrandRoutes(options) {
|
|
2338
|
+
const basePath = options.basePath ?? "/api/brand";
|
|
2339
|
+
const publicTokens = options.publicTokens === true;
|
|
2340
|
+
const base = {
|
|
2341
|
+
db: options.db,
|
|
2342
|
+
now: options.now ?? Date.now,
|
|
2343
|
+
newId: options.newId ?? (() => crypto.randomUUID()),
|
|
2344
|
+
maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024
|
|
2345
|
+
};
|
|
2346
|
+
return async (req) => {
|
|
2347
|
+
const url = new URL(req.url);
|
|
2348
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) return null;
|
|
2349
|
+
const seg = url.pathname.slice(basePath.length).split("/").filter(Boolean);
|
|
2350
|
+
try {
|
|
2351
|
+
const file = tokensFile(seg);
|
|
2352
|
+
if (publicTokens && file) return await handleTokens(base.db, req, seg[1], file, null);
|
|
2353
|
+
const actor = await options.authorize(req);
|
|
2354
|
+
if (!actor) return json({ error: "unauthorized" }, 401);
|
|
2355
|
+
return await route({ ...base, actor }, req, seg) ?? json({ error: "not found" }, 404);
|
|
2356
|
+
} catch (error) {
|
|
2357
|
+
return errorResponse(error);
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// src/triggers.ts
|
|
2363
|
+
var CHAT_MESSAGE_NS = "chat_message";
|
|
2364
|
+
var celString = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2365
|
+
function brandBotTrigger(opts) {
|
|
2366
|
+
const clauses = ['data.authorKind == "human"'];
|
|
2367
|
+
if (opts.mention) clauses.push(`data.body.contains("${celString(opts.mention)}")`);
|
|
2368
|
+
if (opts.when) clauses.push(`(${opts.when})`);
|
|
2369
|
+
return {
|
|
2370
|
+
id: opts.id,
|
|
2371
|
+
watch: { ns: CHAT_MESSAGE_NS, on: opts.on ?? "create" },
|
|
2372
|
+
when: clauses.join(" && "),
|
|
2373
|
+
runAs: { agentId: opts.agentId, persona: opts.persona },
|
|
2374
|
+
skill: "brand",
|
|
2375
|
+
maxDepth: 1,
|
|
2376
|
+
...opts.channels ? { channels: opts.channels } : {}
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// src/dispatch.ts
|
|
2381
|
+
function parseBrandDispatch(raw) {
|
|
2382
|
+
if (!raw || typeof raw !== "object") return null;
|
|
2383
|
+
const b = raw;
|
|
2384
|
+
const t = b.trigger;
|
|
2385
|
+
const e = b.event;
|
|
2386
|
+
if (typeof b.appId !== "string" || !b.appId) return null;
|
|
2387
|
+
if (!t || typeof t.runAs?.agentId !== "string" || typeof t.runAs?.persona !== "string") return null;
|
|
2388
|
+
if (!e || typeof e.id !== "string" || !e.row || typeof e.row !== "object") return null;
|
|
2389
|
+
return {
|
|
2390
|
+
v: typeof b.v === "number" ? b.v : 1,
|
|
2391
|
+
appId: b.appId,
|
|
2392
|
+
trigger: {
|
|
2393
|
+
id: typeof t.id === "string" ? t.id : "",
|
|
2394
|
+
skill: typeof t.skill === "string" ? t.skill : "",
|
|
2395
|
+
runAs: { agentId: t.runAs.agentId, persona: t.runAs.persona },
|
|
2396
|
+
...typeof t.maxDepth === "number" ? { maxDepth: t.maxDepth } : {}
|
|
2397
|
+
},
|
|
2398
|
+
event: { ns: typeof e.ns === "string" ? e.ns : CHAT_MESSAGE_NS, id: e.id, row: e.row }
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
async function bookForChannel(db, channelId) {
|
|
2402
|
+
if (!channelId) return null;
|
|
2403
|
+
const res = await db.query({ [BRAND_NS.book]: { $: { where: { channelId } } } });
|
|
2404
|
+
return (res[BRAND_NS.book] ?? [])[0] ?? null;
|
|
2405
|
+
}
|
|
2406
|
+
function brandInputFor(body, attachments) {
|
|
2407
|
+
const row = body.event.row;
|
|
2408
|
+
const prompt = `A new message arrived in this brand book's channel from ${String(row.authorId ?? "someone")}: "${String(row.body ?? "")}". Ground yourself first (read_brand_book, list_assets), then move the brand work forward: study the material, justify colors with the math tools, and park palette ideas as proposals \u2014 a human must explicitly approve before you resolve or compile. If you reply in chat, keep it concise.`;
|
|
2409
|
+
return attachments?.length ? [...attachments, { type: "text", text: prompt }] : prompt;
|
|
2410
|
+
}
|
|
2411
|
+
var PRETURN_IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
2412
|
+
"image/png",
|
|
2413
|
+
"image/jpeg",
|
|
2414
|
+
"image/gif",
|
|
2415
|
+
"image/webp"
|
|
2416
|
+
]);
|
|
2417
|
+
async function assetBlocksFor(db, fileBaseUrl, raw) {
|
|
2418
|
+
if (!Array.isArray(raw)) return [];
|
|
2419
|
+
const ids = raw.filter((v) => typeof v === "string");
|
|
2420
|
+
if (ids.length === 0) return [];
|
|
2421
|
+
const res = await db.query({ [BRAND_NS.asset]: { $: { where: { id: { $in: ids } } } } });
|
|
2422
|
+
const byId = new Map((res[BRAND_NS.asset] ?? []).map((a2) => [a2.id, a2]));
|
|
2423
|
+
const blocks = [];
|
|
2424
|
+
for (const id of ids) {
|
|
2425
|
+
const asset = byId.get(id);
|
|
2426
|
+
if (!asset || asset.deletedAt != null || !PRETURN_IMAGE_TYPES.has(asset.contentType)) continue;
|
|
2427
|
+
const url = asset.url.startsWith("http") ? asset.url : fileBaseUrl + asset.url;
|
|
2428
|
+
blocks.push({ type: "image", source: { type: "url", url } });
|
|
2429
|
+
}
|
|
2430
|
+
return blocks;
|
|
2431
|
+
}
|
|
2432
|
+
async function dispatchBrandTurn(deps, body) {
|
|
2433
|
+
const channelId = String(body.event.row.channelId ?? "");
|
|
2434
|
+
const book = await bookForChannel(deps.db, channelId);
|
|
2435
|
+
if (!book) throw new BrandNotFoundError(`brand book for channel ${channelId || "(missing channelId)"}`);
|
|
2436
|
+
const visionInToolResults = supportsBrandVision(deps.catalog?.[deps.model]);
|
|
2437
|
+
const self = {
|
|
2438
|
+
selfId: body.trigger.runAs.agentId,
|
|
2439
|
+
kind: "bot",
|
|
2440
|
+
displayName: body.trigger.runAs.persona
|
|
2441
|
+
};
|
|
2442
|
+
const persona = createBrandPersona({
|
|
2443
|
+
model: deps.model,
|
|
2444
|
+
brand: { db: deps.db, bookId: book.id, self, fileBaseUrl: deps.fileBaseUrl, visionInToolResults },
|
|
2445
|
+
skills: deps.chatSkillFor ? [deps.chatSkillFor(channelId, self)] : []
|
|
2446
|
+
});
|
|
2447
|
+
const attachments = visionInToolResults ? [] : await assetBlocksFor(deps.db, deps.fileBaseUrl, body.event.row.assetIds);
|
|
2448
|
+
const run = await deps.runAgent(deps.inference, persona, { input: brandInputFor(body, attachments) });
|
|
2449
|
+
return { finalText: run.finalText };
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// src/descriptor.ts
|
|
2453
|
+
var brandIntegration = {
|
|
2454
|
+
id: "brand",
|
|
2455
|
+
title: "Brand books (assets, palettes, proposals, design tokens)",
|
|
2456
|
+
npm: "@odla-ai/brand",
|
|
2457
|
+
settings: [
|
|
2458
|
+
{
|
|
2459
|
+
key: "basePath",
|
|
2460
|
+
description: 'Route mount point the app worker serves brand under. Default "/api/brand".',
|
|
2461
|
+
public: true,
|
|
2462
|
+
perEnv: false,
|
|
2463
|
+
source: "createBrandRoutes({ basePath }) in the app worker"
|
|
2464
|
+
},
|
|
2465
|
+
{
|
|
2466
|
+
key: "publicTokens",
|
|
2467
|
+
description: "Serve GET /books/:id/tokens.css and tokens.json without authorization (compiled tokens only \u2014 books and assets always authorize). Default false.",
|
|
2468
|
+
public: true,
|
|
2469
|
+
perEnv: false,
|
|
2470
|
+
source: "createBrandRoutes({ publicTokens }) in the app worker"
|
|
2471
|
+
}
|
|
2472
|
+
],
|
|
2473
|
+
secrets: [],
|
|
2474
|
+
schema: BRAND_SCHEMA,
|
|
2475
|
+
rules: brandRules(),
|
|
2476
|
+
triggers: [],
|
|
2477
|
+
provision: {
|
|
2478
|
+
human: [
|
|
2479
|
+
'Choose the trigger mode: one global mention-gated bot (brandBotTrigger({ mention: "@brand" }), no channels) or per-book channel-scoped bots (channels: [book.channelId], no mention).',
|
|
2480
|
+
"Pick the bot's model; when supportsBrandVision(catalog[model]) is false, dispatch attaches assets pre-turn instead of inside tool results.",
|
|
2481
|
+
"Decide whether compiled tokens are public (publicTokens serves tokens.css/tokens.json unauthenticated)."
|
|
2482
|
+
],
|
|
2483
|
+
cli: [
|
|
2484
|
+
"POST BRAND_SCHEMA to /app/:id/schema.",
|
|
2485
|
+
"Install brandRules() at /app/:id/admin/rules (merged with the app's existing rules).",
|
|
2486
|
+
"Register a brandBotTrigger(...) per bot at /app/:id/admin/triggers \u2014 global mention-gated, or channel-scoped per book.",
|
|
2487
|
+
"Seed the bot's agent id into each brand_book.memberIds roster (audienceFanoutOps covers existing child rows).",
|
|
2488
|
+
"Provision the bot's AI provider secret (via @odla-ai/ai) and the worker's admin odla-db credential."
|
|
2489
|
+
],
|
|
2490
|
+
doctor: [
|
|
2491
|
+
"All five brand_* namespaces have rules installed (brand_asset fully closed \u2014 worker-mediated only).",
|
|
2492
|
+
"brand_book.id/slug, brand_section.key, and the other mirrored id attrs are unique.",
|
|
2493
|
+
"Each registered trigger's agent id is present in its target books' memberIds rosters.",
|
|
2494
|
+
"Books with channelId set point at real chat channels when @odla-ai/chat is installed."
|
|
2495
|
+
]
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2498
|
+
function createBrandIntegration(options = {}) {
|
|
2499
|
+
const basePath = options.basePath ?? "/api/brand";
|
|
2500
|
+
if (!/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(basePath) || basePath.endsWith("/")) {
|
|
2501
|
+
throw new Error("createBrandIntegration: basePath must be an absolute path without a trailing slash");
|
|
2502
|
+
}
|
|
2503
|
+
const cli = [...brandIntegration.provision.cli];
|
|
2504
|
+
if (options.mention !== void 0)
|
|
2505
|
+
cli.push(`Gate the global bot on mentions: brandBotTrigger({ mention: ${JSON.stringify(options.mention)}, ... }).`);
|
|
2506
|
+
if (options.publicTokens === true)
|
|
2507
|
+
cli.push(`Mount createBrandRoutes({ publicTokens: true }) so ${basePath}/books/:id/tokens.css serves unauthenticated.`);
|
|
2508
|
+
return {
|
|
2509
|
+
...brandIntegration,
|
|
2510
|
+
provision: { ...brandIntegration.provision, cli },
|
|
2511
|
+
probes: [{ path: `${basePath}/books`, expectedStatus: 401 }]
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
export {
|
|
2515
|
+
ASSET_CONTENT_TYPES,
|
|
2516
|
+
ASSET_KINDS,
|
|
2517
|
+
BOOK_STATUSES,
|
|
2518
|
+
BRAND_CHART_TOKENS,
|
|
2519
|
+
BRAND_CHAT_TOKENS,
|
|
2520
|
+
BRAND_DERIVED_TOKENS,
|
|
2521
|
+
BRAND_EMITTED_TOKENS,
|
|
2522
|
+
BRAND_INSTRUCTIONS,
|
|
2523
|
+
BRAND_NS,
|
|
2524
|
+
BRAND_REQUIRED_TOKENS,
|
|
2525
|
+
BRAND_RULES,
|
|
2526
|
+
BRAND_SCHEMA,
|
|
2527
|
+
BrandInputError,
|
|
2528
|
+
BrandNotFoundError,
|
|
2529
|
+
CHART_DELTA_MIN,
|
|
2530
|
+
CHAT_MESSAGE_NS,
|
|
2531
|
+
CSS_NAMED_COLORS,
|
|
2532
|
+
DARK_FLIP_L_MAX,
|
|
2533
|
+
DARK_FLIP_L_MIN,
|
|
2534
|
+
DEFAULT_ACCENT_SEED,
|
|
2535
|
+
DEFAULT_BRAND_SYSTEM,
|
|
2536
|
+
MAX_SWATCHES,
|
|
2537
|
+
MAX_VIEW_BYTES,
|
|
2538
|
+
PALETTE_SOURCES,
|
|
2539
|
+
PALETTE_STATUSES,
|
|
2540
|
+
PICK_TEXT_DEFAULT_CANDIDATES,
|
|
2541
|
+
PROPOSAL_KINDS,
|
|
2542
|
+
PROPOSAL_STATUSES,
|
|
2543
|
+
RAMP_L_MAX,
|
|
2544
|
+
RAMP_L_MIN,
|
|
2545
|
+
SECTION_KINDS,
|
|
2546
|
+
SECTION_STATUSES,
|
|
2547
|
+
SWATCH_ROLES,
|
|
2548
|
+
acceptProposalOps,
|
|
2549
|
+
adjustLightnessUntil,
|
|
2550
|
+
analogous,
|
|
2551
|
+
assertAnalysis,
|
|
2552
|
+
assertAssetContentType,
|
|
2553
|
+
assertHex,
|
|
2554
|
+
assertSectionContent,
|
|
2555
|
+
assertSwatches,
|
|
2556
|
+
assetTools,
|
|
2557
|
+
audienceFanoutOps,
|
|
2558
|
+
base64FromBytes,
|
|
2559
|
+
bookForChannel,
|
|
2560
|
+
bookTools,
|
|
2561
|
+
brandBotTrigger,
|
|
2562
|
+
brandInputFor,
|
|
2563
|
+
brandIntegration,
|
|
2564
|
+
brandRules,
|
|
2565
|
+
brandSkill,
|
|
2566
|
+
capString,
|
|
2567
|
+
capStringArray,
|
|
2568
|
+
clamp01,
|
|
2569
|
+
clampToGamut,
|
|
2570
|
+
compileBrandTokens,
|
|
2571
|
+
complementary,
|
|
2572
|
+
contrastRatio,
|
|
2573
|
+
createAssetOps,
|
|
2574
|
+
createBookOps,
|
|
2575
|
+
createBrandIntegration,
|
|
2576
|
+
createBrandPersona,
|
|
2577
|
+
createBrandRoutes,
|
|
2578
|
+
deltaEOK,
|
|
2579
|
+
deltaEOKLab,
|
|
2580
|
+
deriveChartColors,
|
|
2581
|
+
deriveDarkTokens,
|
|
2582
|
+
derivePalette,
|
|
2583
|
+
dispatchBrandTurn,
|
|
2584
|
+
hexToOklch,
|
|
2585
|
+
hslToRgb,
|
|
2586
|
+
inSrgbGamut,
|
|
2587
|
+
linearToSrgb,
|
|
2588
|
+
mapPaletteToTokens,
|
|
2589
|
+
meetsAA,
|
|
2590
|
+
meetsAAA,
|
|
2591
|
+
monochrome,
|
|
2592
|
+
nearestNamedColor,
|
|
2593
|
+
normalizeHex,
|
|
2594
|
+
oklabToOklch,
|
|
2595
|
+
oklabToRgb,
|
|
2596
|
+
oklchToHex,
|
|
2597
|
+
oklchToOklab,
|
|
2598
|
+
paletteTools,
|
|
2599
|
+
parseBrandDispatch,
|
|
2600
|
+
parseHex,
|
|
2601
|
+
pickTextOn,
|
|
2602
|
+
proposePaletteOps,
|
|
2603
|
+
readTools,
|
|
2604
|
+
recordAnalysisOps,
|
|
2605
|
+
rejectProposalOps,
|
|
2606
|
+
relativeLuminance,
|
|
2607
|
+
renderTokensCss,
|
|
2608
|
+
resolveDeps,
|
|
2609
|
+
rgbToHsl,
|
|
2610
|
+
rgbToOklab,
|
|
2611
|
+
rotateHue,
|
|
2612
|
+
safeFileName,
|
|
2613
|
+
sectionKey,
|
|
2614
|
+
splitComplementary,
|
|
2615
|
+
srgbToLinear,
|
|
2616
|
+
supportsBrandVision,
|
|
2617
|
+
tetradic,
|
|
2618
|
+
tintShadeRamp,
|
|
2619
|
+
toHex,
|
|
2620
|
+
tombstoneAssetOps,
|
|
2621
|
+
triadic,
|
|
2622
|
+
updateBookOps,
|
|
2623
|
+
upsertSectionOps
|
|
2624
|
+
};
|
|
2625
|
+
//# sourceMappingURL=index.js.map
|