@brianmsm/quarto-bento 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/qbento-npm.mjs +62 -0
- package/distribution/LICENSE +21 -0
- package/distribution/PACKAGING.md +59 -0
- package/distribution/bin/qbento +5 -0
- package/distribution/bin/qbento.cmd +4 -0
- package/distribution/manifest.json +720 -0
- package/distribution/runtime/LICENSE +21 -0
- package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
- package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
- package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
- package/distribution/runtime/_extensions/qbento/post.ts +2 -0
- package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
- package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
- package/distribution/runtime/src/bridge/browser.js +168 -0
- package/distribution/runtime/src/bridge/isolation.ts +55 -0
- package/distribution/runtime/src/browser/chromium.ts +218 -0
- package/distribution/runtime/src/cli.ts +219 -0
- package/distribution/runtime/src/core/agent.ts +565 -0
- package/distribution/runtime/src/core/annotations.ts +384 -0
- package/distribution/runtime/src/core/capture.ts +203 -0
- package/distribution/runtime/src/core/component-actions.ts +550 -0
- package/distribution/runtime/src/core/component-frames.ts +59 -0
- package/distribution/runtime/src/core/component-sync.ts +290 -0
- package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
- package/distribution/runtime/src/core/design-registry.ts +287 -0
- package/distribution/runtime/src/core/graph-relations.ts +354 -0
- package/distribution/runtime/src/core/graph-scope.ts +63 -0
- package/distribution/runtime/src/core/graph-sync.ts +228 -0
- package/distribution/runtime/src/core/io.ts +27 -0
- package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
- package/distribution/runtime/src/core/layouts.ts +245 -0
- package/distribution/runtime/src/core/project.ts +759 -0
- package/distribution/runtime/src/core/reconcile.ts +384 -0
- package/distribution/runtime/src/core/recovery.ts +1255 -0
- package/distribution/runtime/src/core/slide-actions.ts +158 -0
- package/distribution/runtime/src/core/source-scope.ts +50 -0
- package/distribution/runtime/src/core/sync.ts +2423 -0
- package/distribution/runtime/src/core/widgets.ts +300 -0
- package/distribution/runtime/src/integration/install.ts +98 -0
- package/distribution/runtime/src/integration/invocation.ts +100 -0
- package/distribution/runtime/src/integration/quarto.ts +457 -0
- package/distribution/runtime/src/render/assets.ts +66 -0
- package/distribution/runtime/src/render/capture.lua +8 -0
- package/distribution/runtime/src/render/chart.ts +165 -0
- package/distribution/runtime/src/render/components.ts +199 -0
- package/distribution/runtime/src/render/design.ts +820 -0
- package/distribution/runtime/src/render/diagnostics.ts +36 -0
- package/distribution/runtime/src/render/graphs.ts +747 -0
- package/distribution/runtime/src/render/html.ts +82 -0
- package/distribution/runtime/src/render/images.ts +268 -0
- package/distribution/runtime/src/render/inline.ts +259 -0
- package/distribution/runtime/src/render/kpi.ts +385 -0
- package/distribution/runtime/src/render/lists.ts +183 -0
- package/distribution/runtime/src/render/math.ts +413 -0
- package/distribution/runtime/src/render/measure.ts +185 -0
- package/distribution/runtime/src/render/native-layout.ts +174 -0
- package/distribution/runtime/src/render/producer-capture.ts +319 -0
- package/distribution/runtime/src/render/producer-environment.R +49 -0
- package/distribution/runtime/src/render/producer-environment.ts +96 -0
- package/distribution/runtime/src/render/producer-profile.R +91 -0
- package/distribution/runtime/src/render/producer-site-profile.R +18 -0
- package/distribution/runtime/src/render/project.ts +1806 -0
- package/distribution/runtime/src/render/quarto.ts +127 -0
- package/distribution/runtime/src/render/registry.ts +233 -0
- package/distribution/runtime/src/render/resources.ts +292 -0
- package/distribution/runtime/src/render/shell.ts +110 -0
- package/distribution/runtime/src/render/source-inline.ts +169 -0
- package/distribution/runtime/src/render/table.ts +220 -0
- package/distribution/runtime/src/render/text-overflow.ts +208 -0
- package/distribution/runtime/src/render/types.ts +71 -0
- package/distribution/runtime/src/render/validate.ts +572 -0
- package/distribution/runtime/src/rich/project.ts +117 -0
- package/distribution/runtime/src/rich/table.ts +374 -0
- package/distribution/runtime/src/source/annotations.ts +490 -0
- package/distribution/runtime/src/source/attributes.ts +415 -0
- package/distribution/runtime/src/source/chart.ts +165 -0
- package/distribution/runtime/src/source/code-language.ts +277 -0
- package/distribution/runtime/src/source/component-insertion.ts +160 -0
- package/distribution/runtime/src/source/deletion.ts +278 -0
- package/distribution/runtime/src/source/graphs.ts +517 -0
- package/distribution/runtime/src/source/identities.ts +256 -0
- package/distribution/runtime/src/source/identity-id.ts +6 -0
- package/distribution/runtime/src/source/image.ts +50 -0
- package/distribution/runtime/src/source/kpi.ts +196 -0
- package/distribution/runtime/src/source/mod.ts +876 -0
- package/distribution/runtime/src/source/notes.ts +308 -0
- package/distribution/runtime/src/source/structure.ts +647 -0
- package/distribution/runtime/src/source/table.ts +290 -0
- package/distribution/runtime/src/source/text-conversion.ts +146 -0
- package/distribution/runtime/src/state/components.ts +281 -0
- package/distribution/runtime/src/state/graphs.ts +200 -0
- package/distribution/runtime/src/state/json.ts +162 -0
- package/distribution/runtime/src/state/merge.ts +1034 -0
- package/distribution/runtime/src/state/mod.ts +5 -0
- package/distribution/runtime/src/state/store.ts +226 -0
- package/distribution/runtime/src/state/types.ts +151 -0
- package/distribution/runtime/src/studio/client.js +790 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +278 -0
- package/distribution/runtime/src/studio/layouts.js +170 -0
- package/distribution/runtime/src/studio/registry.js +283 -0
- package/distribution/runtime/src/studio/relations.js +169 -0
- package/distribution/runtime/src/studio/server.ts +433 -0
- package/distribution/runtime/src/widgets/deck.ts +39 -0
- package/distribution/runtime/src/widgets/plotly.ts +354 -0
- package/distribution/runtime/src/widgets/presenter.ts +367 -0
- package/distribution/runtime/src/widgets/snapshot.ts +116 -0
- package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
- package/distribution/runtime/vendor/bento/LICENSE +21 -0
- package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
- package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/pin.json +69 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
- package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
- package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
- package/distribution/runtime/vendor/bento/shell.html +269 -0
- package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
- package/package.json +40 -4
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import type { Element, Json } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export type PandocAttr = [string, string[], [string, string][]];
|
|
4
|
+
export type DesignProperties = Record<string, string | number>;
|
|
5
|
+
export interface DesignDeclaration {
|
|
6
|
+
sourceId: string;
|
|
7
|
+
component?:
|
|
8
|
+
| "card"
|
|
9
|
+
| "layout"
|
|
10
|
+
| "code"
|
|
11
|
+
| "chart"
|
|
12
|
+
| "kpi"
|
|
13
|
+
| "diagram"
|
|
14
|
+
| "timeline"
|
|
15
|
+
| "node"
|
|
16
|
+
| "edge"
|
|
17
|
+
| "milestone";
|
|
18
|
+
chartKind?: "bar" | "line";
|
|
19
|
+
layout?: string;
|
|
20
|
+
properties: DesignProperties;
|
|
21
|
+
/** Actual native Bento field -> the exact original QMD attribute key. */
|
|
22
|
+
bindings: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
export interface Frame {
|
|
25
|
+
x: number;
|
|
26
|
+
y: number;
|
|
27
|
+
w: number;
|
|
28
|
+
h: number;
|
|
29
|
+
}
|
|
30
|
+
export interface DesignTheme {
|
|
31
|
+
background: string;
|
|
32
|
+
color: string;
|
|
33
|
+
accent: string;
|
|
34
|
+
fontFamily: string;
|
|
35
|
+
}
|
|
36
|
+
export interface LayoutDefinition {
|
|
37
|
+
columns: number;
|
|
38
|
+
gap: number;
|
|
39
|
+
padding: number;
|
|
40
|
+
}
|
|
41
|
+
export interface DesignDefaults {
|
|
42
|
+
frame?: Frame;
|
|
43
|
+
theme?: DesignTheme;
|
|
44
|
+
layouts?: Record<string, LayoutDefinition>;
|
|
45
|
+
}
|
|
46
|
+
export class DesignError extends Error {
|
|
47
|
+
constructor(public code: string, message: string) {
|
|
48
|
+
super(`${code}: ${message}`);
|
|
49
|
+
this.name = "DesignError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const numbers: Record<string, [number, number]> = {
|
|
53
|
+
x: [-1e6, 1e6],
|
|
54
|
+
y: [-1e6, 1e6],
|
|
55
|
+
w: [0.01, 1e6],
|
|
56
|
+
h: [0.01, 1e6],
|
|
57
|
+
rotation: [-3600, 3600],
|
|
58
|
+
opacity: [0, 1],
|
|
59
|
+
fontSize: [0.01, 4000],
|
|
60
|
+
fontWeight: [1, 1000],
|
|
61
|
+
lineHeight: [0.01, 100],
|
|
62
|
+
letterSpacing: [-1000, 1000],
|
|
63
|
+
radius: [0, 100000],
|
|
64
|
+
strokeWidth: [0, 10000],
|
|
65
|
+
};
|
|
66
|
+
const aliases: Record<string, string> = {
|
|
67
|
+
"font-size": "fontSize",
|
|
68
|
+
"font-family": "fontFamily",
|
|
69
|
+
"font-weight": "fontWeight",
|
|
70
|
+
"line-height": "lineHeight",
|
|
71
|
+
"letter-spacing": "letterSpacing",
|
|
72
|
+
"stroke-width": "strokeWidth",
|
|
73
|
+
};
|
|
74
|
+
const textFields = new Set([
|
|
75
|
+
"fontSize",
|
|
76
|
+
"fontFamily",
|
|
77
|
+
"fontWeight",
|
|
78
|
+
"lineHeight",
|
|
79
|
+
"letterSpacing",
|
|
80
|
+
"color",
|
|
81
|
+
"align",
|
|
82
|
+
"valign",
|
|
83
|
+
]);
|
|
84
|
+
const shapeFields = new Set(["fill", "stroke", "strokeWidth", "radius"]);
|
|
85
|
+
const genericFields = new Set(["x", "y", "w", "h", "rotation", "opacity"]);
|
|
86
|
+
export function nativeDesignField(key: string): string | undefined {
|
|
87
|
+
const field = aliases[key] ?? key;
|
|
88
|
+
return genericFields.has(field) || textFields.has(field) ||
|
|
89
|
+
shapeFields.has(field)
|
|
90
|
+
? field
|
|
91
|
+
: undefined;
|
|
92
|
+
}
|
|
93
|
+
/** Deliberately bounded CSS vocabulary; no urls, variables, declarations or code. */
|
|
94
|
+
export function validateDesignValue(
|
|
95
|
+
key: string,
|
|
96
|
+
raw: string | number,
|
|
97
|
+
): string | number {
|
|
98
|
+
const field = nativeDesignField(key);
|
|
99
|
+
if (!field) throw new DesignError("UNSUPPORTED_DESIGN_ATTRIBUTE", key);
|
|
100
|
+
const value = String(raw);
|
|
101
|
+
if (numbers[field]) {
|
|
102
|
+
const n = Number(value), [min, max] = numbers[field];
|
|
103
|
+
if (
|
|
104
|
+
!/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value) || !Number.isFinite(n) ||
|
|
105
|
+
n < min || n > max
|
|
106
|
+
) {
|
|
107
|
+
throw new DesignError(
|
|
108
|
+
"INVALID_DESIGN_VALUE",
|
|
109
|
+
`${key} requires a finite number in [${min}, ${max}]`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return n;
|
|
113
|
+
}
|
|
114
|
+
if (["color", "fill", "stroke"].includes(field)) {
|
|
115
|
+
if (
|
|
116
|
+
!/^(?:#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|transparent|black|white|red|green|blue|gray|grey|navy|teal|orange|purple|yellow|none)$/i
|
|
117
|
+
.test(value)
|
|
118
|
+
) {
|
|
119
|
+
throw new DesignError(
|
|
120
|
+
"INVALID_DESIGN_VALUE",
|
|
121
|
+
`${key} requires a hex or supported named color`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (field === "color" && value === "none") {
|
|
125
|
+
throw new DesignError(
|
|
126
|
+
"INVALID_DESIGN_VALUE",
|
|
127
|
+
"text color cannot be none",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
if (field === "fontFamily") {
|
|
133
|
+
if (
|
|
134
|
+
value.length > 300 || !/^[\p{L}\p{N} ,._-]+$/u.test(value) ||
|
|
135
|
+
!value.trim()
|
|
136
|
+
) {
|
|
137
|
+
throw new DesignError(
|
|
138
|
+
"INVALID_DESIGN_VALUE",
|
|
139
|
+
"font-family requires a plain family/stack without CSS syntax",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
const allowed = field === "align"
|
|
145
|
+
? ["left", "center", "right"]
|
|
146
|
+
: ["top", "middle", "bottom"];
|
|
147
|
+
if (!allowed.includes(value)) {
|
|
148
|
+
throw new DesignError("INVALID_DESIGN_VALUE", `${key}: ${value}`);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
export function parseDesignAttributes(attr: PandocAttr): DesignDeclaration {
|
|
153
|
+
const result: DesignDeclaration = {
|
|
154
|
+
sourceId: attr[0],
|
|
155
|
+
properties: {},
|
|
156
|
+
bindings: {},
|
|
157
|
+
};
|
|
158
|
+
const components = attr[1].filter((c) => c.startsWith("bento-"));
|
|
159
|
+
if (components.length > 1) {
|
|
160
|
+
throw new DesignError("AMBIGUOUS_COMPONENT", components.join(", "));
|
|
161
|
+
}
|
|
162
|
+
if (components.length) {
|
|
163
|
+
if (attr[0].length > 160 || /["'<>{};]/.test(attr[0])) {
|
|
164
|
+
throw new DesignError(
|
|
165
|
+
"INVALID_COMPONENT_ID",
|
|
166
|
+
"component ID must fit native Bento identity constraints",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (!attr[0]) {
|
|
170
|
+
throw new DesignError(
|
|
171
|
+
"COMPONENT_ID_REQUIRED",
|
|
172
|
+
"give the component a stable explicit #id",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (components[0] === "bento-card") result.component = "card";
|
|
176
|
+
else if (components[0] === "bento-layout") result.component = "layout";
|
|
177
|
+
else if (components[0] === "bento-code") result.component = "code";
|
|
178
|
+
else if (components[0] === "bento-chart") result.component = "chart";
|
|
179
|
+
else if (components[0] === "bento-kpi") result.component = "kpi";
|
|
180
|
+
else if (components[0] === "bento-diagram") result.component = "diagram";
|
|
181
|
+
else if (components[0] === "bento-timeline") result.component = "timeline";
|
|
182
|
+
else if (components[0] === "bento-node") result.component = "node";
|
|
183
|
+
else if (components[0] === "bento-edge") result.component = "edge";
|
|
184
|
+
else if (components[0] === "bento-milestone") {
|
|
185
|
+
result.component = "milestone";
|
|
186
|
+
} else throw new DesignError("UNSUPPORTED_COMPONENT", components[0]);
|
|
187
|
+
}
|
|
188
|
+
const seen = new Set<string>();
|
|
189
|
+
for (const [key, value] of attr[2]) {
|
|
190
|
+
if (seen.has(key)) throw new DesignError("DUPLICATE_DESIGN_ATTRIBUTE", key);
|
|
191
|
+
seen.add(key);
|
|
192
|
+
if (key === "bento-role") {
|
|
193
|
+
if (result.component || !["label", "value", "detail"].includes(value)) {
|
|
194
|
+
throw new DesignError(
|
|
195
|
+
"KPI_SLOT_ROLE",
|
|
196
|
+
"bento-role belongs to a plain KPI child and must be label, value or detail.",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if ((key === "from" || key === "to") && result.component === "edge") {
|
|
202
|
+
if (!/^[A-Za-z][\w:.-]{0,159}$/.test(value)) {
|
|
203
|
+
throw new DesignError(
|
|
204
|
+
"INVALID_GRAPH_ENDPOINT",
|
|
205
|
+
"Endpoint values require literal source node identities.",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (key === "bento-chart-kind") {
|
|
211
|
+
if (result.component !== "chart" || !["bar", "line"].includes(value)) {
|
|
212
|
+
throw new DesignError(
|
|
213
|
+
"INVALID_CHART_KIND",
|
|
214
|
+
"bento-chart-kind belongs to an identified .bento-chart and must be bar or line",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
result.chartKind = value as "bar" | "line";
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (key === "layout" || key === "bento-layout-name") {
|
|
221
|
+
if (result.layout) {
|
|
222
|
+
throw new DesignError(
|
|
223
|
+
"DUPLICATE_DESIGN_ATTRIBUTE",
|
|
224
|
+
"Several layout names were declared.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
result.component !== "layout" || !/^[a-z][a-z0-9-]{0,63}$/.test(value)
|
|
229
|
+
) {
|
|
230
|
+
throw new DesignError(
|
|
231
|
+
"INVALID_LAYOUT",
|
|
232
|
+
"layout belongs to an identified .bento-layout",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
result.layout = value;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const native = nativeDesignField(key);
|
|
239
|
+
if (native) {
|
|
240
|
+
if (result.bindings[native]) {
|
|
241
|
+
throw new DesignError(
|
|
242
|
+
"DUPLICATE_DESIGN_ATTRIBUTE",
|
|
243
|
+
`${key} aliases ${result.bindings[native]}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
result.properties[native] = validateDesignValue(key, value);
|
|
247
|
+
result.bindings[native] = key;
|
|
248
|
+
} else if (
|
|
249
|
+
(key.startsWith("bento-") || result.component) &&
|
|
250
|
+
!["bento-title-id", "bento-element-id", "bento-image-id"].includes(key)
|
|
251
|
+
) {
|
|
252
|
+
throw new DesignError("UNSUPPORTED_DESIGN_ATTRIBUTE", key);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (result.component === "layout" && !result.layout) {
|
|
256
|
+
throw new DesignError(
|
|
257
|
+
"LAYOUT_REQUIRED",
|
|
258
|
+
'declare bento-layout-name="two-column" or a registered reusable layout',
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (result.component === "chart" && !result.chartKind) {
|
|
262
|
+
throw new DesignError(
|
|
263
|
+
"CHART_KIND_REQUIRED",
|
|
264
|
+
"declare bento-chart-kind=bar or line",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
function checkedDeclaration(value: DesignDeclaration): DesignDeclaration {
|
|
270
|
+
if (
|
|
271
|
+
!value || typeof value.sourceId !== "string" || !value.properties ||
|
|
272
|
+
!value.bindings ||
|
|
273
|
+
value.component &&
|
|
274
|
+
![
|
|
275
|
+
"card",
|
|
276
|
+
"layout",
|
|
277
|
+
"code",
|
|
278
|
+
"chart",
|
|
279
|
+
"kpi",
|
|
280
|
+
"diagram",
|
|
281
|
+
"timeline",
|
|
282
|
+
"node",
|
|
283
|
+
"edge",
|
|
284
|
+
"milestone",
|
|
285
|
+
].includes(value.component)
|
|
286
|
+
) {
|
|
287
|
+
throw new DesignError(
|
|
288
|
+
"INVALID_DECLARATION",
|
|
289
|
+
"malformed design declaration",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const attrs: [string, string][] = [];
|
|
293
|
+
for (const [field, property] of Object.entries(value.properties)) {
|
|
294
|
+
const key = value.bindings[field];
|
|
295
|
+
if (
|
|
296
|
+
!key || nativeDesignField(key) !== field ||
|
|
297
|
+
!["string", "number"].includes(typeof property)
|
|
298
|
+
) {
|
|
299
|
+
throw new DesignError(
|
|
300
|
+
"INVALID_DECLARATION",
|
|
301
|
+
"visual properties require matching validated bindings",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
attrs.push([key, String(property)]);
|
|
305
|
+
}
|
|
306
|
+
if (value.layout) attrs.push(["layout", value.layout]);
|
|
307
|
+
if (value.chartKind) attrs.push(["bento-chart-kind", value.chartKind]);
|
|
308
|
+
return parseDesignAttributes([
|
|
309
|
+
value.sourceId,
|
|
310
|
+
value.component ? [`bento-${value.component}`] : [],
|
|
311
|
+
attrs,
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
export function applyElementDesign(
|
|
315
|
+
element: Element,
|
|
316
|
+
declaration: DesignDeclaration,
|
|
317
|
+
): {
|
|
318
|
+
element: Element;
|
|
319
|
+
ownedPaths: string[];
|
|
320
|
+
bindings: Record<string, string>;
|
|
321
|
+
} {
|
|
322
|
+
declaration = checkedDeclaration(declaration);
|
|
323
|
+
if (declaration.component) {
|
|
324
|
+
throw new DesignError(
|
|
325
|
+
"COMPONENT_REQUIRES_GROUP",
|
|
326
|
+
"use applyComponentDesign for components",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const copy = structuredClone(element);
|
|
330
|
+
for (const [field, value] of Object.entries(declaration.properties)) {
|
|
331
|
+
if (
|
|
332
|
+
textFields.has(field) && element.type !== "text" &&
|
|
333
|
+
!(element.type === "code" &&
|
|
334
|
+
!["fontWeight", "letterSpacing"].includes(field)) ||
|
|
335
|
+
shapeFields.has(field) && element.type !== "shape"
|
|
336
|
+
) {
|
|
337
|
+
throw new DesignError(
|
|
338
|
+
"DESIGN_TARGET_MISMATCH",
|
|
339
|
+
`${field} does not apply to native ${element.type}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
copy[field] = value;
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
element: copy,
|
|
346
|
+
ownedPaths: Object.keys(declaration.properties),
|
|
347
|
+
bindings: { ...declaration.bindings },
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const defaultTheme: DesignTheme = {
|
|
351
|
+
background: "#ffffff",
|
|
352
|
+
color: "#172a35",
|
|
353
|
+
accent: "#156f65",
|
|
354
|
+
fontFamily: "Arial, sans-serif",
|
|
355
|
+
};
|
|
356
|
+
export const builtinLayouts: Readonly<Record<string, LayoutDefinition>> = {
|
|
357
|
+
"two-column": { columns: 2, gap: 32, padding: 0 },
|
|
358
|
+
};
|
|
359
|
+
export function validateDesignDefaults(
|
|
360
|
+
defaults: DesignDefaults,
|
|
361
|
+
): DesignDefaults {
|
|
362
|
+
const clean = structuredClone(defaults);
|
|
363
|
+
const keys = (value: unknown, allowed: string[], required: string[] = []) => {
|
|
364
|
+
if (
|
|
365
|
+
!value || typeof value !== "object" || Array.isArray(value) ||
|
|
366
|
+
Object.keys(value).some((key) => !allowed.includes(key)) ||
|
|
367
|
+
required.some((key) => !(key in value))
|
|
368
|
+
) {
|
|
369
|
+
throw new DesignError(
|
|
370
|
+
"INVALID_DESIGN_DEFAULTS",
|
|
371
|
+
"unexpected or missing field",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
keys(clean, ["frame", "theme", "layouts"]);
|
|
376
|
+
if (clean.frame) {
|
|
377
|
+
keys(clean.frame, ["x", "y", "w", "h"], ["x", "y", "w", "h"]);
|
|
378
|
+
for (const key of ["x", "y", "w", "h"] as const) {
|
|
379
|
+
if (typeof clean.frame[key] !== "number") {
|
|
380
|
+
throw new DesignError(
|
|
381
|
+
"INVALID_DESIGN_DEFAULTS",
|
|
382
|
+
"frame fields must be numbers",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
validateDesignValue(key, clean.frame[key]);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (clean.theme) {
|
|
389
|
+
keys(clean.theme, ["background", "color", "accent", "fontFamily"], [
|
|
390
|
+
"background",
|
|
391
|
+
"color",
|
|
392
|
+
"accent",
|
|
393
|
+
"fontFamily",
|
|
394
|
+
]);
|
|
395
|
+
if (Object.values(clean.theme).some((value) => typeof value !== "string")) {
|
|
396
|
+
throw new DesignError(
|
|
397
|
+
"INVALID_DESIGN_DEFAULTS",
|
|
398
|
+
"theme fields must be strings",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
validateDesignValue("fill", clean.theme.background);
|
|
402
|
+
validateDesignValue("color", clean.theme.color);
|
|
403
|
+
validateDesignValue("color", clean.theme.accent);
|
|
404
|
+
validateDesignValue("fontFamily", clean.theme.fontFamily);
|
|
405
|
+
}
|
|
406
|
+
if (
|
|
407
|
+
clean.layouts &&
|
|
408
|
+
(Array.isArray(clean.layouts) || typeof clean.layouts !== "object" ||
|
|
409
|
+
Object.keys(clean.layouts).length > 64)
|
|
410
|
+
) {
|
|
411
|
+
throw new DesignError(
|
|
412
|
+
"INVALID_DESIGN_DEFAULTS",
|
|
413
|
+
"layouts must be a bounded registry",
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
for (const [name, layout] of Object.entries(clean.layouts ?? {})) {
|
|
417
|
+
keys(layout, ["columns", "gap", "padding"], ["columns", "gap", "padding"]);
|
|
418
|
+
if (
|
|
419
|
+
!/^[a-z][a-z0-9-]{0,63}$/.test(name) ||
|
|
420
|
+
!Number.isInteger(layout.columns) || layout.columns < 1 ||
|
|
421
|
+
layout.columns > 6 ||
|
|
422
|
+
!Number.isFinite(layout.gap) || layout.gap < 0 || layout.gap > 1000 ||
|
|
423
|
+
!Number.isFinite(layout.padding) || layout.padding < 0 ||
|
|
424
|
+
layout.padding > 1000
|
|
425
|
+
) {
|
|
426
|
+
throw new DesignError("INVALID_LAYOUT", name);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return clean;
|
|
430
|
+
}
|
|
431
|
+
export interface MeasuredComponents {
|
|
432
|
+
/** Source-verified native objects whose metrics must not change after measuring. */
|
|
433
|
+
fixed: ReadonlySet<string>;
|
|
434
|
+
/** Rescued default text has no painted bounds until saved overlays are composed. */
|
|
435
|
+
omitted: ReadonlySet<string>;
|
|
436
|
+
}
|
|
437
|
+
function bounds(elements: Element[], measured?: MeasuredComponents): Element[] {
|
|
438
|
+
return elements.filter((element) => !measured?.omitted.has(element.id));
|
|
439
|
+
}
|
|
440
|
+
function placed(
|
|
441
|
+
elements: Element[],
|
|
442
|
+
frame: Frame,
|
|
443
|
+
measured?: MeasuredComponents,
|
|
444
|
+
): Element[] {
|
|
445
|
+
if (!elements.length) return [];
|
|
446
|
+
const original = elements.map((e) => {
|
|
447
|
+
if (
|
|
448
|
+
![e.x, e.y, e.w, e.h].every((v) =>
|
|
449
|
+
typeof v === "number" && Number.isFinite(v)
|
|
450
|
+
)
|
|
451
|
+
) throw new DesignError("INVALID_ELEMENT_FRAME", e.id);
|
|
452
|
+
return e as Element & Frame;
|
|
453
|
+
});
|
|
454
|
+
const sizing = bounds(original, measured) as (Element & Frame)[];
|
|
455
|
+
const left = Math.min(...sizing.map((e) => e.x)),
|
|
456
|
+
top = Math.min(...sizing.map((e) => e.y));
|
|
457
|
+
const width = Math.max(...sizing.map((e) => e.x + e.w)) - left;
|
|
458
|
+
const height = Math.max(...sizing.map((e) => e.y + e.h)) - top;
|
|
459
|
+
if (width <= 0 || height <= 0) {
|
|
460
|
+
throw new DesignError("INVALID_ELEMENT_FRAME", "empty group bounds");
|
|
461
|
+
}
|
|
462
|
+
// Uniform fit retains aspect ratio and relative positioning within a semantic child.
|
|
463
|
+
let scale = Math.min(1, frame.w / width, frame.h / height);
|
|
464
|
+
if (
|
|
465
|
+
original.some((element) =>
|
|
466
|
+
element.type === "table" || element.type === "chart" ||
|
|
467
|
+
measured?.fixed.has(element.id)
|
|
468
|
+
)
|
|
469
|
+
) {
|
|
470
|
+
if (width > frame.w + .01 || height > frame.h + .01) {
|
|
471
|
+
throw new DesignError(
|
|
472
|
+
"COMPONENT_CONTENT_OVERFLOW",
|
|
473
|
+
"Measured native content must fit its final frame without scaling its box; use a wider or taller component.",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
// Coordinate subtraction can exceed an exact slot by floating-point dust.
|
|
477
|
+
// Accept that tolerance as translation, never as a near-one scale.
|
|
478
|
+
scale = 1;
|
|
479
|
+
}
|
|
480
|
+
return original.map((e) => {
|
|
481
|
+
const fitted: Element = {
|
|
482
|
+
...structuredClone(e),
|
|
483
|
+
x: frame.x + (e.x - left) * scale,
|
|
484
|
+
y: frame.y + (e.y - top) * scale,
|
|
485
|
+
w: e.w * scale,
|
|
486
|
+
h: e.h * scale,
|
|
487
|
+
};
|
|
488
|
+
// Scale pixel metrics with their boxes; lineHeight is a unitless ratio.
|
|
489
|
+
// Keeping the original font size inside a fitted code box clips content.
|
|
490
|
+
for (
|
|
491
|
+
const field of ["fontSize", "letterSpacing", "strokeWidth", "radius"]
|
|
492
|
+
) {
|
|
493
|
+
if (typeof e[field] === "number") fitted[field] = e[field] * scale;
|
|
494
|
+
}
|
|
495
|
+
return fitted;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
/** Resolve final slots before projecting children, so nested cards do not fit twice. */
|
|
499
|
+
export function componentFrames(
|
|
500
|
+
declaration: DesignDeclaration,
|
|
501
|
+
base: Frame,
|
|
502
|
+
count: number,
|
|
503
|
+
defaults: DesignDefaults = {},
|
|
504
|
+
heights?: number[],
|
|
505
|
+
): { frame: Frame; children: Frame[] } {
|
|
506
|
+
const clean = validateDesignDefaults(defaults);
|
|
507
|
+
declaration = checkedDeclaration(declaration);
|
|
508
|
+
const frame = { ...base };
|
|
509
|
+
for (const key of ["x", "y", "w", "h"] as const) {
|
|
510
|
+
if (declaration.properties[key] !== undefined) {
|
|
511
|
+
frame[key] = Number(declaration.properties[key]);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (count < 1) throw new DesignError("EMPTY_COMPONENT", declaration.sourceId);
|
|
515
|
+
if (declaration.component === "layout") {
|
|
516
|
+
const layout = { ...builtinLayouts, ...clean.layouts }[declaration.layout!];
|
|
517
|
+
if (!layout) throw new DesignError("UNKNOWN_LAYOUT", declaration.layout!);
|
|
518
|
+
if (count !== layout.columns) {
|
|
519
|
+
throw new DesignError(
|
|
520
|
+
"LAYOUT_ARITY",
|
|
521
|
+
`${declaration.layout} expects ${layout.columns} semantic children`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const w = (frame.w - 2 * layout.padding - (count - 1) * layout.gap) / count;
|
|
525
|
+
const h = frame.h - 2 * layout.padding;
|
|
526
|
+
if (w <= 0 || h <= 0) {
|
|
527
|
+
throw new DesignError(
|
|
528
|
+
"LAYOUT_OVERFLOW",
|
|
529
|
+
"gap/padding exceed layout dimensions",
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
frame,
|
|
534
|
+
children: Array.from({ length: count }, (_, i) => ({
|
|
535
|
+
x: frame.x + layout.padding + i * (w + layout.gap),
|
|
536
|
+
y: frame.y + layout.padding,
|
|
537
|
+
w,
|
|
538
|
+
h,
|
|
539
|
+
})),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const padding = 24, gap = 16;
|
|
543
|
+
const h = (frame.h - 2 * padding - gap * (count - 1)) / count;
|
|
544
|
+
if (h <= 0 || frame.w <= 2 * padding) {
|
|
545
|
+
throw new DesignError("CARD_OVERFLOW", "card too small for its children");
|
|
546
|
+
}
|
|
547
|
+
if (heights) {
|
|
548
|
+
if (
|
|
549
|
+
heights.length !== count ||
|
|
550
|
+
heights.some((v) => !Number.isFinite(v) || v === 0 || v < -1)
|
|
551
|
+
) throw new DesignError("CARD_CONTENT_MEASUREMENT", declaration.sourceId);
|
|
552
|
+
const available = frame.h - 2 * padding - gap * (count - 1);
|
|
553
|
+
const fixed = heights.reduce((sum, v) => sum + Math.max(0, v), 0);
|
|
554
|
+
const flexible = heights.filter((v) => v === -1).length;
|
|
555
|
+
if (fixed >= available && flexible || fixed > available) {
|
|
556
|
+
throw new DesignError(
|
|
557
|
+
"CARD_CONTENT_OVERFLOW",
|
|
558
|
+
"Content needs a taller card; refusing unreadable automatic shrink.",
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
let y = frame.y + padding;
|
|
562
|
+
return {
|
|
563
|
+
frame,
|
|
564
|
+
children: heights.map((preferred) => {
|
|
565
|
+
const height = preferred === -1
|
|
566
|
+
? (available - fixed) / flexible
|
|
567
|
+
: preferred;
|
|
568
|
+
const child = {
|
|
569
|
+
x: frame.x + padding,
|
|
570
|
+
y,
|
|
571
|
+
w: frame.w - 2 * padding,
|
|
572
|
+
h: height,
|
|
573
|
+
};
|
|
574
|
+
y += height + gap;
|
|
575
|
+
return child;
|
|
576
|
+
}),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
frame,
|
|
581
|
+
children: Array.from({ length: count }, (_, i) => ({
|
|
582
|
+
x: frame.x + padding,
|
|
583
|
+
y: frame.y + padding + i * (h + gap),
|
|
584
|
+
w: frame.w - 2 * padding,
|
|
585
|
+
h,
|
|
586
|
+
})),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
export interface ComponentProjection {
|
|
590
|
+
elements: Element[];
|
|
591
|
+
ownershipByElement: Record<string, string[]>;
|
|
592
|
+
bindingsByElement: Record<string, Record<string, string>>;
|
|
593
|
+
decorativeIds: string[];
|
|
594
|
+
}
|
|
595
|
+
/** Each child is a semantic subtree already projected to native elements. */
|
|
596
|
+
export function applyComponentDesign(
|
|
597
|
+
children: Element[][],
|
|
598
|
+
declaration: DesignDeclaration,
|
|
599
|
+
defaults: DesignDefaults = {},
|
|
600
|
+
measuredComponents?: MeasuredComponents,
|
|
601
|
+
): ComponentProjection {
|
|
602
|
+
const supported = new Set([
|
|
603
|
+
"text",
|
|
604
|
+
"code",
|
|
605
|
+
"image",
|
|
606
|
+
"shape",
|
|
607
|
+
"table",
|
|
608
|
+
"chart",
|
|
609
|
+
]);
|
|
610
|
+
const unsupported = children.flat().find((element) =>
|
|
611
|
+
!supported.has(element.type)
|
|
612
|
+
);
|
|
613
|
+
if (unsupported) {
|
|
614
|
+
throw new DesignError(
|
|
615
|
+
"COMPONENT_CONTENT_LAYOUT_UNSUPPORTED",
|
|
616
|
+
`${unsupported.type} needs a measured component-layout adapter; refusing to rescale only its box.`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const clean = validateDesignDefaults(defaults);
|
|
620
|
+
declaration = checkedDeclaration(declaration);
|
|
621
|
+
if ("rotation" in declaration.properties) {
|
|
622
|
+
throw new DesignError(
|
|
623
|
+
"COMPONENT_ROTATION_UNSUPPORTED",
|
|
624
|
+
"rotate individual elements; a faithful coupled group rotation is not implemented",
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
if (
|
|
628
|
+
!declaration.component ||
|
|
629
|
+
!["card", "layout"].includes(declaration.component) || !declaration.sourceId
|
|
630
|
+
) {
|
|
631
|
+
throw new DesignError(
|
|
632
|
+
"COMPONENT_REQUIRED",
|
|
633
|
+
"identified card/layout required",
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (!children.length || children.some((c) => !c.length)) {
|
|
637
|
+
throw new DesignError("EMPTY_COMPONENT", declaration.sourceId);
|
|
638
|
+
}
|
|
639
|
+
const theme = clean.theme ?? defaultTheme;
|
|
640
|
+
const frame = { ...(clean.frame ?? { x: 80, y: 166, w: 1120, h: 470 }) };
|
|
641
|
+
for (const key of ["x", "y", "w", "h"] as const) {
|
|
642
|
+
if (declaration.properties[key] !== undefined) {
|
|
643
|
+
frame[key] = Number(declaration.properties[key]);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const result: ComponentProjection = {
|
|
647
|
+
elements: [],
|
|
648
|
+
ownershipByElement: {},
|
|
649
|
+
bindingsByElement: {},
|
|
650
|
+
decorativeIds: [],
|
|
651
|
+
};
|
|
652
|
+
const geometryOwned = ["x", "y", "w", "h"].some((k) =>
|
|
653
|
+
k in declaration.properties
|
|
654
|
+
);
|
|
655
|
+
const bind = (e: Element, field: string, attr: string) => {
|
|
656
|
+
(result.ownershipByElement[e.id] ??= []).push(field);
|
|
657
|
+
(result.bindingsByElement[e.id] ??= {})[field] = attr;
|
|
658
|
+
};
|
|
659
|
+
if (declaration.component === "layout") {
|
|
660
|
+
const layout = { ...builtinLayouts, ...clean.layouts }[declaration.layout!];
|
|
661
|
+
if (!layout) throw new DesignError("UNKNOWN_LAYOUT", declaration.layout!);
|
|
662
|
+
if (children.length !== layout.columns) {
|
|
663
|
+
throw new DesignError(
|
|
664
|
+
"LAYOUT_ARITY",
|
|
665
|
+
`${declaration.layout} expects ${layout.columns} semantic children`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (
|
|
669
|
+
Object.keys(declaration.properties).some((k) => !genericFields.has(k))
|
|
670
|
+
) {
|
|
671
|
+
throw new DesignError(
|
|
672
|
+
"LAYOUT_STYLE_UNSUPPORTED",
|
|
673
|
+
"declare typography/fill on child components",
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
const width =
|
|
677
|
+
(frame.w - 2 * layout.padding - (layout.columns - 1) * layout.gap) /
|
|
678
|
+
layout.columns;
|
|
679
|
+
const height = frame.h - 2 * layout.padding;
|
|
680
|
+
if (width <= 0 || height <= 0) {
|
|
681
|
+
throw new DesignError(
|
|
682
|
+
"LAYOUT_OVERFLOW",
|
|
683
|
+
"gap/padding exceed layout dimensions",
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
children.forEach((child, i) =>
|
|
687
|
+
result.elements.push(
|
|
688
|
+
...placed(child, {
|
|
689
|
+
x: frame.x + layout.padding + i * (width + layout.gap),
|
|
690
|
+
y: frame.y + layout.padding,
|
|
691
|
+
w: width,
|
|
692
|
+
h: height,
|
|
693
|
+
}, measuredComponents),
|
|
694
|
+
)
|
|
695
|
+
);
|
|
696
|
+
} else {
|
|
697
|
+
const padding = 24, gap = 16;
|
|
698
|
+
const height = (frame.h - 2 * padding - gap * (children.length - 1)) /
|
|
699
|
+
children.length;
|
|
700
|
+
if (height <= 0 || frame.w <= 2 * padding) {
|
|
701
|
+
throw new DesignError("CARD_OVERFLOW", "card too small for its children");
|
|
702
|
+
}
|
|
703
|
+
const background: Element = {
|
|
704
|
+
id: `${declaration.sourceId}/qb-card-background`,
|
|
705
|
+
type: "shape",
|
|
706
|
+
shape: "rect",
|
|
707
|
+
x: frame.x,
|
|
708
|
+
y: frame.y,
|
|
709
|
+
w: frame.w,
|
|
710
|
+
h: frame.h,
|
|
711
|
+
rotation: 0,
|
|
712
|
+
opacity: 1,
|
|
713
|
+
fill: theme.background,
|
|
714
|
+
stroke: theme.accent,
|
|
715
|
+
strokeWidth: 1,
|
|
716
|
+
radius: 16,
|
|
717
|
+
groupId: declaration.sourceId,
|
|
718
|
+
};
|
|
719
|
+
for (const [field, value] of Object.entries(declaration.properties)) {
|
|
720
|
+
if (shapeFields.has(field)) {
|
|
721
|
+
background[field] = value;
|
|
722
|
+
bind(background, field, declaration.bindings[field]);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
result.elements.push(background);
|
|
726
|
+
result.decorativeIds.push(background.id);
|
|
727
|
+
const measured = children.map((child) => {
|
|
728
|
+
const sized = bounds(child, measuredComponents);
|
|
729
|
+
return Math.max(...sized.map((e) => Number(e.y) + Number(e.h))) -
|
|
730
|
+
Math.min(...sized.map((e) => Number(e.y)));
|
|
731
|
+
});
|
|
732
|
+
const total = measured.reduce((sum, h) => sum + h, 0);
|
|
733
|
+
const available = frame.h - 2 * padding - gap * (children.length - 1);
|
|
734
|
+
if (total > available + .01) {
|
|
735
|
+
throw new DesignError(
|
|
736
|
+
"CARD_CONTENT_OVERFLOW",
|
|
737
|
+
"Measured child groups exceed the card height; use a taller frame or less content.",
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
const fit = 1;
|
|
741
|
+
let childY = frame.y + padding;
|
|
742
|
+
children.forEach((child, i) => {
|
|
743
|
+
result.elements.push(
|
|
744
|
+
...placed(child, {
|
|
745
|
+
x: frame.x + padding,
|
|
746
|
+
y: childY,
|
|
747
|
+
w: frame.w - 2 * padding,
|
|
748
|
+
h: measured[i] * fit,
|
|
749
|
+
}, measuredComponents),
|
|
750
|
+
);
|
|
751
|
+
childY += measured[i] * fit + gap;
|
|
752
|
+
});
|
|
753
|
+
if (
|
|
754
|
+
Object.keys(declaration.properties).some((field) =>
|
|
755
|
+
textFields.has(field)
|
|
756
|
+
) &&
|
|
757
|
+
!result.elements.some((e) => e.type === "text" || e.type === "code")
|
|
758
|
+
) {
|
|
759
|
+
throw new DesignError(
|
|
760
|
+
"COMPONENT_STYLE_TARGET",
|
|
761
|
+
"Card typography requires a text/code child; no declaration is silently ignored.",
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
for (const e of result.elements) {
|
|
765
|
+
if (e.type === "text" || e.type === "code") {
|
|
766
|
+
e.color = theme.color;
|
|
767
|
+
if (e.type === "text") e.fontFamily = theme.fontFamily;
|
|
768
|
+
for (const [field, value] of Object.entries(declaration.properties)) {
|
|
769
|
+
if (textFields.has(field)) {
|
|
770
|
+
if (
|
|
771
|
+
e.type === "code" &&
|
|
772
|
+
["fontWeight", "letterSpacing"].includes(field)
|
|
773
|
+
) {
|
|
774
|
+
throw new DesignError(
|
|
775
|
+
"DESIGN_TARGET_MISMATCH",
|
|
776
|
+
`${field} does not apply to native code in this card.`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
e[field] = value;
|
|
780
|
+
bind(e, field, declaration.bindings[field]);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
for (const element of result.elements) {
|
|
787
|
+
element.groupId = declaration.sourceId;
|
|
788
|
+
// Group transforms are coupled: reverse editing a child cannot safely infer a
|
|
789
|
+
// parent frame attribute. Mark them source-owned but deliberately unbound.
|
|
790
|
+
if (geometryOwned) {
|
|
791
|
+
result.ownershipByElement[element.id] = [
|
|
792
|
+
...new Set([
|
|
793
|
+
...(result.ownershipByElement[element.id] ?? []),
|
|
794
|
+
"x",
|
|
795
|
+
"y",
|
|
796
|
+
"w",
|
|
797
|
+
"h",
|
|
798
|
+
]),
|
|
799
|
+
];
|
|
800
|
+
}
|
|
801
|
+
for (const field of ["opacity", "rotation"]) {
|
|
802
|
+
if (field in declaration.properties) {
|
|
803
|
+
element[field] = declaration.properties[field];
|
|
804
|
+
bind(element, field, declaration.bindings[field]);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const ids = new Set<string>();
|
|
809
|
+
for (const e of result.elements) {
|
|
810
|
+
if (ids.has(e.id)) throw new DesignError("DUPLICATE_DESIGN_ID", e.id);
|
|
811
|
+
ids.add(e.id);
|
|
812
|
+
}
|
|
813
|
+
return result;
|
|
814
|
+
}
|
|
815
|
+
/** Theme values are pure JSON defaults; native edits remain ordinary sidecar fields. */
|
|
816
|
+
export function designTheme(
|
|
817
|
+
defaults: DesignDefaults = {},
|
|
818
|
+
): DesignTheme & Record<string, Json> {
|
|
819
|
+
return { ...(validateDesignDefaults(defaults).theme ?? defaultTheme) };
|
|
820
|
+
}
|