@compact-design/html 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -204,6 +204,18 @@ function overflowDeclarations(props) {
204
204
  if (props.overflowDirection === "BOTH") return ["overflow: auto"];
205
205
  return [];
206
206
  }
207
+ function isScrollingOverflow(direction) {
208
+ return direction === "HORIZONTAL" || direction === "VERTICAL" || direction === "BOTH";
209
+ }
210
+ function clampedFixedChildCount(value, childCount) {
211
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || childCount <= 0) return 0;
212
+ return Math.max(0, Math.min(childCount, Math.round(value)));
213
+ }
214
+ function fixedChildStickyDeclarations(overflow) {
215
+ if (overflow === "VERTICAL") return ["position: sticky", "bottom: 0"];
216
+ if (overflow === "HORIZONTAL") return ["position: sticky", "right: 0"];
217
+ return ["position: sticky", "bottom: 0", "right: 0"];
218
+ }
207
219
  function radiusDeclarations(props) {
208
220
  if (Array.isArray(props.cornerRadii) && props.cornerRadii.length === 4) {
209
221
  return [`border-radius: ${props.cornerRadii.map((value) => `${finite(value, 0)}px`).join(" ")}`];
@@ -211,11 +223,11 @@ function radiusDeclarations(props) {
211
223
  if (typeof props.cornerRadius === "number") return [`border-radius: ${props.cornerRadius}px`];
212
224
  return [];
213
225
  }
214
- function strokeDeclarations(props) {
215
- const color = paintColor(props.styles.strokes[0]);
226
+ function strokeDeclarations(props, colorOverride) {
227
+ const color = colorOverride ?? paintColor(props.styles.strokes[0]);
216
228
  if (!color) return [];
217
229
  const style = props.dashPattern?.length ? "dashed" : "solid";
218
- const declarations = [];
230
+ const align = props.strokeAlign;
219
231
  const sides = [
220
232
  ["border-top-width", props.strokeTopWeight],
221
233
  ["border-right-width", props.strokeRightWeight],
@@ -224,14 +236,31 @@ function strokeDeclarations(props) {
224
236
  ];
225
237
  const perSide = sides.some(([, value]) => typeof value === "number");
226
238
  if (perSide) {
227
- declarations.push(`border-style: ${style}`, `border-color: ${color}`, "border-width: 0");
239
+ const declarations = [`border-style: ${style}`, `border-color: ${color}`, "border-width: 0"];
228
240
  for (const [property, value] of sides) if (typeof value === "number") declarations.push(`${property}: ${value}px`);
229
- } else {
230
- const weight = finite(props.strokeWeight, 1);
231
- if (weight <= 0) return [];
232
- declarations.push(`border: ${weight}px ${style} ${color}`);
241
+ return declarations;
233
242
  }
234
- return declarations;
243
+ const weight = finite(props.strokeWeight, 1);
244
+ if (weight <= 0) return [];
245
+ if (align === "OUTSIDE") {
246
+ return [`box-shadow: 0 0 0 ${weight}px ${color}`];
247
+ }
248
+ if (align === "CENTER") {
249
+ const half = weight / 2;
250
+ return [`border: ${half}px ${style} ${color}`, `box-shadow: 0 0 0 ${half}px ${color}`];
251
+ }
252
+ return [`border: ${weight}px ${style} ${color}`];
253
+ }
254
+ function coalesceBoxShadows(declarations) {
255
+ const shadows = [];
256
+ const rest = [];
257
+ for (const declaration of declarations) {
258
+ const match = /^box-shadow:\s*(.+)$/i.exec(declaration);
259
+ if (match) shadows.push(match[1]);
260
+ else rest.push(declaration);
261
+ }
262
+ if (shadows.length) rest.push(`box-shadow: ${shadows.join(", ")}`);
263
+ return rest;
235
264
  }
236
265
  function blendModeCss(value) {
237
266
  if (!value || value === "PASS_THROUGH" || value === "NORMAL") return void 0;
@@ -243,6 +272,11 @@ function textCaseCss(value) {
243
272
  if (value === "TITLE") return "capitalize";
244
273
  return void 0;
245
274
  }
275
+ function textCaseVariantCss(value) {
276
+ if (value === "SMALL_CAPS") return "small-caps";
277
+ if (value === "SMALL_CAPS_FORCED") return "all-small-caps";
278
+ return void 0;
279
+ }
246
280
  function sanitizeSvg(markup) {
247
281
  return markup.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
248
282
  }
@@ -257,6 +291,111 @@ function regularPolygon(cx, cy, radius, points, innerRatio) {
257
291
  }
258
292
  return coords.join(" ");
259
293
  }
294
+ var TURN = Math.PI * 2;
295
+ function round(value) {
296
+ return Math.round(value * 1e3) / 1e3;
297
+ }
298
+ function arcPath(width, height, startingAngle, endingAngle, innerRadiusRatio) {
299
+ const rx = width / 2;
300
+ const ry = height / 2;
301
+ const ratio = Math.min(1, Math.max(0, innerRadiusRatio));
302
+ let sweep = endingAngle - startingAngle;
303
+ if (sweep < 0) sweep = sweep % TURN + TURN;
304
+ const point = (angle, scale) => `${round(rx + rx * scale * Math.cos(angle))} ${round(ry + ry * scale * Math.sin(angle))}`;
305
+ const ellipse = (scale) => {
306
+ const r = `${round(rx * scale)} ${round(ry * scale)}`;
307
+ return `M ${point(startingAngle, scale)} A ${r} 0 1 1 ${point(startingAngle + Math.PI, scale)} A ${r} 0 1 1 ${point(startingAngle, scale)} Z`;
308
+ };
309
+ if (sweep === 0 || sweep >= TURN) return ratio > 0 ? `${ellipse(1)} ${ellipse(ratio)}` : ellipse(1);
310
+ const end = startingAngle + sweep;
311
+ const large = sweep > Math.PI ? 1 : 0;
312
+ const outer = `M ${point(startingAngle, 1)} A ${round(rx)} ${round(ry)} 0 ${large} 1 ${point(end, 1)}`;
313
+ if (ratio === 0) return `${outer} L ${round(rx)} ${round(ry)} Z`;
314
+ return `${outer} L ${point(end, ratio)} A ${round(rx * ratio)} ${round(ry * ratio)} 0 ${large} 0 ${point(startingAngle, ratio)} Z`;
315
+ }
316
+ function layoutGridColor(color) {
317
+ if (isDesignColor(color)) return cssColor(color);
318
+ return cssColor({ r: 0, g: 0, b: 0, a: 0.1 });
319
+ }
320
+ function layoutGridLayers(grids) {
321
+ if (!Array.isArray(grids)) return [];
322
+ const layers = [];
323
+ for (const grid of grids) {
324
+ if (!isRecord(grid) || grid.visible === false) continue;
325
+ const color = layoutGridColor(grid.color);
326
+ if (grid.pattern === "GRID") {
327
+ const size = Math.max(1, finite(grid.sectionSize, 8));
328
+ for (const angle of [0, 90]) {
329
+ layers.push({
330
+ image: `repeating-linear-gradient(${angle}deg, ${color} 0, ${color} 1px, transparent 1px, transparent ${size}px)`,
331
+ size: "auto",
332
+ position: "0 0",
333
+ repeat: "repeat"
334
+ });
335
+ }
336
+ continue;
337
+ }
338
+ if (grid.pattern === "COLUMNS" || grid.pattern === "ROWS") {
339
+ const layer = rowsColsGridLayer(grid.pattern, grid, color);
340
+ if (layer) layers.push(layer);
341
+ }
342
+ }
343
+ return layers;
344
+ }
345
+ function rowsColsGridLayer(pattern, grid, color) {
346
+ const count = Math.max(1, Math.round(finite(grid.count, 12)));
347
+ const gutter = Math.max(0, finite(grid.gutterSize, 20));
348
+ const offset = Math.max(0, finite(grid.offset, 0));
349
+ const alignment = typeof grid.alignment === "string" ? grid.alignment : "STRETCH";
350
+ const horizontal = pattern === "COLUMNS";
351
+ const axis = horizontal ? "to right" : "to bottom";
352
+ if (alignment === "STRETCH") {
353
+ const section = `(100% - ${(count - 1) * gutter}px) / ${count}`;
354
+ return {
355
+ image: `repeating-linear-gradient(${axis}, ${color} 0, ${color} calc(${section}), transparent calc(${section}), transparent calc(${section} + ${gutter}px))`,
356
+ size: horizontal ? `calc(100% - ${2 * offset}px) 100%` : `100% calc(100% - ${2 * offset}px)`,
357
+ position: horizontal ? `${offset}px 0` : `0 ${offset}px`,
358
+ repeat: "no-repeat"
359
+ };
360
+ }
361
+ const sectionSize = Math.max(1, finite(grid.sectionSize, 64));
362
+ const span = count * sectionSize + Math.max(0, count - 1) * gutter;
363
+ const image = `repeating-linear-gradient(${axis}, ${color} 0, ${color} ${sectionSize}px, transparent ${sectionSize}px, transparent ${sectionSize + gutter}px)`;
364
+ const size = horizontal ? `${span}px 100%` : `100% ${span}px`;
365
+ if (alignment === "MAX") {
366
+ return {
367
+ image,
368
+ size,
369
+ position: horizontal ? `calc(100% - ${offset}px - ${span}px) 0` : `0 calc(100% - ${offset}px - ${span}px)`,
370
+ repeat: "no-repeat"
371
+ };
372
+ }
373
+ if (alignment === "CENTER") {
374
+ return { image, size, position: "center", repeat: "no-repeat" };
375
+ }
376
+ return {
377
+ image,
378
+ size,
379
+ position: horizontal ? `${offset}px 0` : `0 ${offset}px`,
380
+ repeat: "no-repeat"
381
+ };
382
+ }
383
+ function layoutGridOverlayDeclarations(grids) {
384
+ const layers = layoutGridLayers(grids);
385
+ if (!layers.length) return [];
386
+ return [
387
+ 'content: ""',
388
+ "position: absolute",
389
+ "inset: 0",
390
+ "pointer-events: none",
391
+ "border-radius: inherit",
392
+ "z-index: 9999",
393
+ `background-image: ${layers.map((layer) => layer.image).join(", ")}`,
394
+ `background-size: ${layers.map((layer) => layer.size).join(", ")}`,
395
+ `background-position: ${layers.map((layer) => layer.position).join(", ")}`,
396
+ `background-repeat: ${layers.map((layer) => layer.repeat).join(", ")}`
397
+ ];
398
+ }
260
399
 
261
400
  // src/render.ts
262
401
  var RenderError = class extends Error {
@@ -275,13 +414,15 @@ function render(input, options = {}) {
275
414
  }
276
415
  function renderDocument(document, options = {}) {
277
416
  const ctx = createContext(document);
278
- const css = [baseCss(options.background || "#d8d4cb")];
279
- const body = document.nodes.map((node) => renderNode(node, ctx, { parent: void 0, parentHasLayout: false, modes: {} }, css)).join("\n");
417
+ const modes = initialBoardModes(document);
418
+ const css = [baseCss(options.background || "#d8d4cb"), themeCss(document)];
419
+ const body = document.nodes.map((node) => renderNode(node, ctx, { parent: void 0, parentHasLayout: false, modes: { ...modes } }, css)).join("\n");
280
420
  const board = boardCss(document.nodes);
281
421
  const fonts = fontLinks(ctx.fonts);
282
422
  const script = runtimeScript(ctx.timeouts);
283
- const stylesheet = [board, ...css].join("\n");
423
+ const stylesheet = [board, ...css].filter(Boolean).join("\n");
284
424
  const title = escapeHtml(options.title || document.nodes[0]?.name || "Compact Design");
425
+ const modeAttrs = Object.entries(modes).map(([collection, mode]) => `data-cd-mode-${cssVar(collection)}="${escapeHtml(mode)}"`).join(" ");
285
426
  const page = `<!doctype html>
286
427
  <html lang="en">
287
428
  <head>
@@ -294,7 +435,7 @@ ${stylesheet}
294
435
  </style>
295
436
  </head>
296
437
  <body>
297
- <main class="cd-board">
438
+ <main class="cd-board"${modeAttrs ? ` ${modeAttrs}` : ""}>
298
439
  ${body}
299
440
  </main>
300
441
  ${script}
@@ -352,15 +493,28 @@ function renderNode(node, ctx, state, css) {
352
493
  const link = prototypeLink(props.prototype, ctx, id);
353
494
  css.push(nodeRule(id, node, props, state, modes, ctx));
354
495
  const childrenSource = instanceOf ? instanceOf.children : node.children;
496
+ const parentVisual = instanceOf || node;
497
+ const stickyOverflow = isScrollingOverflow(parentVisual.properties.overflowDirection) ? parentVisual.properties.overflowDirection : void 0;
498
+ const fixedCount = stickyOverflow ? clampedFixedChildCount(parentVisual.properties.numberOfFixedChildren, childrenSource.length) : 0;
499
+ const fixedFrom = childrenSource.length - fixedCount;
355
500
  const childState = {
356
- parent: instanceOf || node,
357
- parentHasLayout: Boolean((instanceOf || node).properties.layout?.direction),
501
+ parent: parentVisual,
502
+ parentHasLayout: Boolean(parentVisual.properties.layout?.direction),
358
503
  modes
359
504
  };
360
- const inner = [
361
- shapeMarkup(node.type === "INSTANCE" ? visual.type : node.type, props),
362
- ...childrenSource.map((child) => renderNode(applyInstanceText(child, node), ctx, childState, css))
363
- ].join("");
505
+ let activeMask;
506
+ const childMarkup = childrenSource.map((child, index) => {
507
+ const applied = applyInstanceText(child, node);
508
+ const fixedSticky = index >= fixedFrom && stickyOverflow ? stickyOverflow : void 0;
509
+ const maskVisual = maskVisualNode(applied, ctx);
510
+ if (maskVisual) {
511
+ activeMask = maskVisual;
512
+ return renderNode(applied, ctx, { ...childState, mask: void 0, asMask: true, fixedSticky }, css);
513
+ }
514
+ return renderNode(applied, ctx, { ...childState, mask: activeMask, asMask: false, fixedSticky }, css);
515
+ });
516
+ const shape = state.asMask ? "" : shapeMarkup(node.type === "INSTANCE" ? visual.type : node.type, props, ctx);
517
+ const inner = [shape, ...childMarkup].join("");
364
518
  const attrs = [
365
519
  `id="${escapeHtml(id)}"`,
366
520
  `class="cd-node"`,
@@ -369,8 +523,12 @@ function renderNode(node, ctx, state, css) {
369
523
  ];
370
524
  if (link.timeout) attrs.push(`data-cd-timeout="${link.timeout.seconds}"`, `data-cd-navigate="${escapeHtml(htmlId(link.timeout.destination))}"`);
371
525
  if (link.back) attrs.push(`data-cd-back="true"`);
372
- const tag = link.href ? "a" : "div";
526
+ if (link.setMode) {
527
+ attrs.push(`data-cd-set-collection="${escapeHtml(cssVar(link.setMode.collection))}"`, `data-cd-set-mode="${escapeHtml(link.setMode.mode)}"`);
528
+ }
529
+ const tag = link.href || link.back || link.setMode ? "a" : "div";
373
530
  if (link.href) attrs.push(`href="${escapeHtml(link.href)}"`);
531
+ else if (link.back || link.setMode) attrs.push(`href="#"`);
374
532
  if (node.type === "TEXT") return `<${tag} ${attrs.join(" ")}>${textInner(props, ctx)}</${tag}>`;
375
533
  if (node.type === "SVG" && props.svg) return `<${tag} ${attrs.join(" ")}>${sanitizeSvg(props.svg)}</${tag}>`;
376
534
  return `<${tag} ${attrs.join(" ")}>${inner}</${tag}>`;
@@ -399,30 +557,44 @@ function applyInstanceText(child, instance) {
399
557
  return { ...child, properties: { ...child.properties, text: named } };
400
558
  }
401
559
  function nodeRule(id, node, props, state, modes, ctx) {
560
+ const resolved = resolvedProps(props, ctx, modes);
402
561
  const declarations = [
403
562
  ...positionDeclarations(props, state),
404
563
  ...sizeDeclarations(props, state),
405
564
  ...layoutDeclarations(props),
406
565
  ...childSizingDeclarations(props, state.parent?.properties.layout?.direction),
407
566
  ...overflowDeclarations(props),
408
- ...radiusDeclarations(props),
409
- ...strokeDeclarations(resolvedProps(props, ctx, modes)),
410
- ...paintDeclarations(node.type, resolvedProps(props, ctx, modes)),
411
- ...effectDeclarations(props),
412
- ...textDeclarations(node.type, resolvedProps(props, ctx, modes), ctx),
413
- ...variableCustomProperties(props, modes, ctx)
567
+ ...radiusDeclarations(props)
414
568
  ];
415
- if (node.type === "ELLIPSE" || node.type === "ARC") declarations.push("border-radius: 50%");
416
- if (typeof props.opacity === "number") declarations.push(`opacity: ${props.opacity}`);
569
+ if (state.asMask) {
570
+ declarations.push("opacity: 0");
571
+ } else {
572
+ if (node.type !== "ARC" && node.type !== "LINE" && node.type !== "STAR" && node.type !== "POLYGON" && node.type !== "VECTOR") declarations.push(...boundStrokeDeclarations(resolved, ctx));
573
+ declarations.push(
574
+ ...paintDeclarations(node.type, resolved, ctx),
575
+ ...effectDeclarations(node.type, props),
576
+ ...textDeclarations(node.type, resolved, ctx)
577
+ );
578
+ if (node.type === "ELLIPSE") declarations.push("border-radius: 50%");
579
+ if (typeof props.opacity === "number") declarations.push(`opacity: ${props.opacity}`);
580
+ if (state.mask) declarations.push(...siblingMaskDeclarations(state.mask, node));
581
+ }
417
582
  if (typeof props.rotation === "number" && props.rotation !== 0) {
418
583
  declarations.push("transform-origin: center center", `transform: rotate(${props.rotation}deg)`);
419
584
  }
420
585
  const blend = blendModeCss(props.blendMode);
421
586
  if (blend) declarations.push(`mix-blend-mode: ${blend}`);
422
- if (props.layoutPositioning === "ABSOLUTE") declarations.push("position: absolute");
423
- return `#${cssEscape(id)} { ${declarations.filter(Boolean).join("; ")} }`;
587
+ if (props.layoutPositioning === "ABSOLUTE" && !state.fixedSticky) declarations.push("position: absolute");
588
+ const merged = coalesceBoxShadows(declarations.filter(Boolean));
589
+ const rules = [`#${cssEscape(id)} { ${merged.join("; ")} }`];
590
+ if (!state.asMask) {
591
+ const overlay = layoutGridOverlayDeclarations(props.layoutGrids);
592
+ if (overlay.length) rules.push(`#${cssEscape(id)}::after { ${overlay.join("; ")} }`);
593
+ }
594
+ return rules.join("\n");
424
595
  }
425
596
  function positionDeclarations(props, state) {
597
+ if (state.fixedSticky) return fixedChildStickyDeclarations(state.fixedSticky);
426
598
  if (!state.parent) {
427
599
  return [
428
600
  "position: absolute",
@@ -459,50 +631,80 @@ function resolvedProps(props, ctx, modes) {
459
631
  }
460
632
  const next = { ...props, styles: { ...props.styles, fills, strokes } };
461
633
  const fillBinding = props.bindings?.fill;
462
- if (fillBinding) {
634
+ if (fillBinding && !isColorBinding(fillBinding, ctx)) {
463
635
  const color = resolveColor(fillBinding, modes, ctx);
464
636
  if (color) next.styles = { ...next.styles, fills: [{ type: "SOLID", color, opacity: 1 }, ...fills.slice(1)] };
465
637
  }
466
638
  const strokeBinding = props.bindings?.stroke;
467
- if (strokeBinding) {
639
+ if (strokeBinding && !isColorBinding(strokeBinding, ctx)) {
468
640
  const color = resolveColor(strokeBinding, modes, ctx);
469
641
  if (color) next.styles = { ...next.styles, strokes: [{ type: "SOLID", color, opacity: 1 }, ...strokes.slice(1)] };
470
642
  }
471
643
  return next;
472
644
  }
473
- function paintDeclarations(type, props) {
645
+ function paintDeclarations(type, props, ctx) {
646
+ const fillBinding = props.bindings?.fill;
647
+ if (fillBinding && isColorBinding(fillBinding, ctx)) {
648
+ const value = `var(--cd-${cssVar(fillBinding)})`;
649
+ if (type === "TEXT") return [`color: ${value}`];
650
+ if (type === "STAR" || type === "POLYGON" || type === "VECTOR" || type === "SVG" || type === "ARC" || type === "LINE") return [];
651
+ return [`background: ${value}`];
652
+ }
474
653
  if (type === "TEXT") {
475
654
  const color = paintColor(props.styles.fills[0]);
476
655
  return color ? [`color: ${color}`] : [];
477
656
  }
478
- if (type === "STAR" || type === "POLYGON" || type === "VECTOR" || type === "SVG") return [];
657
+ if (type === "STAR" || type === "POLYGON" || type === "VECTOR" || type === "SVG" || type === "ARC" || type === "LINE") return [];
479
658
  const layers = backgroundLayers(props.styles.fills);
480
659
  return layers.length ? [`background: ${layers.join(", ")}`] : [];
481
660
  }
482
- function effectDeclarations(props) {
661
+ function effectDeclarations(type, props) {
483
662
  const declarations = [];
484
- const shadows = effectShadows(props.styles.effects);
485
- if (shadows.length) declarations.push(`box-shadow: ${shadows.join(", ")}`);
663
+ if (type === "TEXT") {
664
+ const shadows = textShadows(props.styles.effects);
665
+ if (shadows.length) declarations.push(`text-shadow: ${shadows.join(", ")}`);
666
+ } else {
667
+ const shadows = effectShadows(props.styles.effects);
668
+ if (shadows.length) declarations.push(`box-shadow: ${shadows.join(", ")}`);
669
+ }
486
670
  const blur = blurFilters(props.styles.effects);
487
671
  if (blur.filter) declarations.push(`filter: ${blur.filter}`);
488
672
  if (blur.backdrop) declarations.push(`backdrop-filter: ${blur.backdrop}`);
489
673
  return declarations;
490
674
  }
675
+ function textShadows(effects) {
676
+ return effects.filter((effect) => effect.visible !== false && effect.type === "DROP_SHADOW").map((effect) => `${finite(effect.offset?.x, 0)}px ${finite(effect.offset?.y, 4)}px ${finite(effect.radius, 8)}px ${cssColor(effect.color)}`);
677
+ }
678
+ function paragraphSpacingPx(props) {
679
+ const value = props.paragraphSpacing;
680
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return void 0;
681
+ return value;
682
+ }
491
683
  function textDeclarations(type, props, ctx) {
492
684
  if (type !== "TEXT") return [];
493
685
  const font = textFont(props, ctx);
494
686
  const declarations = fontDeclarations(font);
495
687
  const lineHeight = lineHeightCss(props.lineHeight);
496
688
  const letterSpacing = letterSpacingCss(props.letterSpacing);
689
+ const spacing = paragraphSpacingPx(props);
497
690
  if (lineHeight) declarations.push(`line-height: ${lineHeight}`);
498
691
  if (letterSpacing) declarations.push(`letter-spacing: ${letterSpacing}`);
499
692
  if (props.alignment) declarations.push(`text-align: ${props.alignment.toLowerCase()}`);
500
- if (props.verticalAlignment === "CENTER") declarations.push("display: flex", "align-items: center");
501
- if (props.verticalAlignment === "BOTTOM") declarations.push("display: flex", "align-items: flex-end");
693
+ if (spacing !== void 0) {
694
+ declarations.push("display: flex", "flex-direction: column", `gap: ${spacing}px`);
695
+ if (props.verticalAlignment === "CENTER") declarations.push("justify-content: center");
696
+ if (props.verticalAlignment === "BOTTOM") declarations.push("justify-content: flex-end");
697
+ } else {
698
+ if (props.verticalAlignment === "CENTER") declarations.push("display: flex", "align-items: center");
699
+ if (props.verticalAlignment === "BOTTOM") declarations.push("display: flex", "align-items: flex-end");
700
+ }
502
701
  if (props.textDecoration === "UNDERLINE") declarations.push("text-decoration: underline");
503
702
  if (props.textDecoration === "STRIKETHROUGH") declarations.push("text-decoration: line-through");
504
703
  const transform = textCaseCss(props.textCase);
505
704
  if (transform) declarations.push(`text-transform: ${transform}`);
705
+ const variant = textCaseVariantCss(props.textCase);
706
+ if (variant) declarations.push(`font-variant-caps: ${variant}`);
707
+ if (props.hangingPunctuation === true) declarations.push("hanging-punctuation: first");
506
708
  if (typeof props.paragraphIndent === "number") declarations.push(`text-indent: ${props.paragraphIndent}px`);
507
709
  if (props.textTruncation === "ENDING" || props.textAutoResize === "TRUNCATE") {
508
710
  declarations.push("overflow: hidden", "text-overflow: ellipsis", "white-space: nowrap");
@@ -521,27 +723,62 @@ function textFont(props, ctx) {
521
723
  return props.font;
522
724
  }
523
725
  function textInner(props, ctx) {
726
+ const spacing = paragraphSpacingPx(props);
727
+ if (spacing === void 0) return textContent(props, ctx);
728
+ return textParagraphs(props, ctx).map((content) => `<p style="margin: 0">${content}</p>`).join("");
729
+ }
730
+ function textContent(props, ctx) {
524
731
  if (Array.isArray(props.runs) && props.runs.length) {
525
732
  return props.runs.map((run) => {
526
733
  if (!isRecord(run)) return "";
527
- const text = typeof run.text === "string" ? run.text : "";
528
- const style = [];
529
- if (isRecord(run.font)) {
530
- const font = {
531
- family: typeof run.font.family === "string" ? run.font.family : "Arial",
532
- style: typeof run.font.style === "string" ? run.font.style : "Regular",
533
- size: finite(run.font.size, 16)
534
- };
535
- ctx.fonts.add(font.family);
536
- style.push(...fontDeclarations(font));
537
- }
538
- const fill = runFill(run.fill);
539
- if (fill) style.push(`color: ${fill}`);
540
- return `<span${style.length ? ` style="${escapeHtml(style.join("; "))}"` : ""}>${escapeHtml(text)}</span>`;
734
+ return renderRunMarkup(run, typeof run.text === "string" ? run.text : "", ctx);
541
735
  }).join("");
542
736
  }
543
737
  return escapeHtml(props.text || "");
544
738
  }
739
+ function textParagraphs(props, ctx) {
740
+ if (Array.isArray(props.runs) && props.runs.length) {
741
+ const paragraphs = [[]];
742
+ for (const run of props.runs) {
743
+ if (!isRecord(run)) continue;
744
+ const parts = (typeof run.text === "string" ? run.text : "").split("\n");
745
+ for (let index = 0; index < parts.length; index++) {
746
+ if (index > 0) paragraphs.push([]);
747
+ const piece = renderRunMarkup(run, parts[index], ctx);
748
+ if (piece) paragraphs[paragraphs.length - 1].push(piece);
749
+ }
750
+ }
751
+ return paragraphs.map((pieces) => pieces.join(""));
752
+ }
753
+ return (props.text || "").split("\n").map((line) => escapeHtml(line));
754
+ }
755
+ function renderRunMarkup(run, text, ctx) {
756
+ if (!text) return "";
757
+ const style = [];
758
+ if (isRecord(run.font)) {
759
+ const font = {
760
+ family: typeof run.font.family === "string" ? run.font.family : "Arial",
761
+ style: typeof run.font.style === "string" ? run.font.style : "Regular",
762
+ size: finite(run.font.size, 16)
763
+ };
764
+ ctx.fonts.add(font.family);
765
+ style.push(...fontDeclarations(font));
766
+ }
767
+ const fill = runFill(run.fill);
768
+ if (fill) style.push(`color: ${fill}`);
769
+ if (run.textDecoration === "UNDERLINE") style.push("text-decoration: underline");
770
+ if (run.textDecoration === "STRIKETHROUGH") style.push("text-decoration: line-through");
771
+ if (isRecord(run.letterSpacing)) {
772
+ const spacing = letterSpacingCss({
773
+ unit: run.letterSpacing.unit === "PERCENT" ? "PERCENT" : "PIXELS",
774
+ value: finite(run.letterSpacing.value, 0)
775
+ });
776
+ if (spacing) style.push(`letter-spacing: ${spacing}`);
777
+ }
778
+ let markup = `<span${style.length ? ` style="${escapeHtml(style.join("; "))}"` : ""}>${escapeHtml(text)}</span>`;
779
+ if (typeof run.link === "string") markup = `<a href="${escapeHtml(run.link)}">${markup}</a>`;
780
+ return markup;
781
+ }
545
782
  function runFill(value) {
546
783
  if (typeof value === "string" && /^#/.test(value)) return value;
547
784
  if (isDesignColor(value)) return cssColor(value);
@@ -551,23 +788,71 @@ function runFill(value) {
551
788
  }
552
789
  return void 0;
553
790
  }
554
- function variableCustomProperties(props, modes, ctx) {
555
- const declarations = [];
556
- for (const [collection, mode] of Object.entries(props.variableModes || {})) {
557
- for (const [key, ref] of ctx.variables) {
558
- if (ref.collection !== collection) continue;
559
- if (ref.item.id !== key) continue;
560
- const value = resolveVariableValue(ref, modes);
561
- if (isDesignColor(value)) declarations.push(`--cd-${cssVar(ref.item.id || ref.item.name)}: ${cssColor(value)}`);
562
- }
791
+ function boundStrokeDeclarations(props, ctx) {
792
+ const strokeBinding = props.bindings?.stroke;
793
+ if (strokeBinding && isColorBinding(strokeBinding, ctx)) {
794
+ return strokeDeclarations(props, `var(--cd-${cssVar(strokeBinding)})`);
563
795
  }
564
- const fill = props.bindings?.fill;
565
- if (fill) declarations.push(`--cd-bound-fill: var(--cd-${cssVar(fill)})`);
566
- return declarations;
796
+ return strokeDeclarations(props);
797
+ }
798
+ function isColorBinding(key, ctx) {
799
+ const ref = ctx.variables.get(key);
800
+ return Boolean(ref && ref.item.type === "COLOR");
567
801
  }
568
802
  function cssVar(name) {
569
803
  return name.replace(/[^a-zA-Z0-9_-]+/g, "-");
570
804
  }
805
+ function initialBoardModes(document) {
806
+ const modes = {};
807
+ for (const collection of document.variables) {
808
+ if (collection.modes?.length) modes[collection.name] = collection.modes[0];
809
+ else {
810
+ for (const item of collection.items) {
811
+ if (item.values) {
812
+ const first = Object.keys(item.values)[0];
813
+ if (first) {
814
+ modes[collection.name] = first;
815
+ break;
816
+ }
817
+ }
818
+ }
819
+ }
820
+ }
821
+ const walk = (nodes) => {
822
+ for (const node of nodes) {
823
+ Object.assign(modes, node.properties.variableModes || {});
824
+ walk(node.children);
825
+ }
826
+ };
827
+ walk(document.nodes);
828
+ return modes;
829
+ }
830
+ function themeCss(document) {
831
+ const values = [];
832
+ const selectors = [];
833
+ for (const collection of document.variables) {
834
+ const modeNames = collection.modes?.length ? collection.modes : [...new Set(collection.items.flatMap((item) => item.values ? Object.keys(item.values) : []))];
835
+ if (!modeNames.length) continue;
836
+ const colorItems = collection.items.filter((item) => item.type === "COLOR");
837
+ if (!colorItems.length) continue;
838
+ for (const item of colorItems) {
839
+ const key = cssVar(item.id || item.name);
840
+ for (const mode of modeNames) {
841
+ const raw = item.values?.[mode] ?? item.value;
842
+ if (isDesignColor(raw)) values.push(`--cd-${key}-${cssVar(mode)}: ${cssColor(raw)}`);
843
+ }
844
+ }
845
+ for (const mode of modeNames) {
846
+ const active = colorItems.map((item) => {
847
+ const key = cssVar(item.id || item.name);
848
+ return `--cd-${key}: var(--cd-${key}-${cssVar(mode)})`;
849
+ });
850
+ selectors.push(`.cd-board[data-cd-mode-${cssVar(collection.name)}="${mode}"] { ${active.join("; ")} }`);
851
+ }
852
+ }
853
+ if (!values.length) return "";
854
+ return [`.cd-board { ${values.join("; ")} }`, ...selectors].join("\n");
855
+ }
571
856
  function resolveColor(key, modes, ctx) {
572
857
  const ref = ctx.variables.get(key);
573
858
  if (!ref) return void 0;
@@ -598,40 +883,166 @@ function prototypeLink(value, ctx, sourceId) {
598
883
  if (!reaction) return {};
599
884
  const trigger = isRecord(reaction.trigger) ? reaction.trigger : {};
600
885
  const actions = Array.isArray(reaction.actions) ? reaction.actions : [];
601
- const action = actions.find(isRecord);
602
- if (!action) return {};
603
- const type = String(action.type || "").toUpperCase();
604
- if (type === "URL" && typeof action.url === "string") return { href: action.url };
605
- if (type === "BACK") return { href: "#", back: true };
606
- if ((type === "NAVIGATE" || type === "SWAP" || type === "OVERLAY") && typeof action.destination === "string") {
607
- if (String(trigger.type || "").toUpperCase() === "AFTER_TIMEOUT") {
608
- const seconds = finite(trigger.timeout, 1);
609
- ctx.timeouts.push({ id: sourceId, seconds, destination: action.destination });
610
- return { timeout: { seconds, destination: action.destination } };
886
+ if (!actions.some(isRecord)) return {};
887
+ const triggerType = String(trigger.type || "ON_CLICK").toUpperCase();
888
+ const link = {};
889
+ for (const candidate of actions.filter(isRecord)) {
890
+ const type = String(candidate.type || "").toUpperCase();
891
+ if (type === "SET_VARIABLE_MODE" && triggerType !== "AFTER_TIMEOUT") {
892
+ const collection = typeof candidate.collection === "string" ? candidate.collection : typeof candidate.variableCollectionId === "string" ? candidate.variableCollectionId : "";
893
+ const mode = typeof candidate.mode === "string" ? candidate.mode : typeof candidate.variableModeId === "string" ? candidate.variableModeId : "";
894
+ if (collection && mode) link.setMode = { collection, mode };
895
+ continue;
896
+ }
897
+ if (type === "URL" && typeof candidate.url === "string" && !link.href) link.href = candidate.url;
898
+ if (type === "BACK") {
899
+ link.href = link.href || "#";
900
+ link.back = true;
901
+ }
902
+ if ((type === "NAVIGATE" || type === "SWAP" || type === "OVERLAY") && typeof candidate.destination === "string") {
903
+ if (triggerType === "AFTER_TIMEOUT") {
904
+ const seconds = finite(trigger.timeout, 1);
905
+ ctx.timeouts.push({ id: sourceId, seconds, destination: candidate.destination });
906
+ link.timeout = { seconds, destination: candidate.destination };
907
+ } else if (!link.href) {
908
+ link.href = `#${htmlId(candidate.destination)}`;
909
+ }
611
910
  }
612
- return { href: `#${htmlId(action.destination)}` };
613
911
  }
614
- return {};
912
+ return link;
913
+ }
914
+ function maskVisualNode(node, ctx) {
915
+ const instanceOf = node.type === "INSTANCE" && node.properties.componentId ? ctx.components.get(node.properties.componentId) : void 0;
916
+ const props = mergeInstance(node, instanceOf);
917
+ if (props.isMask !== true) return void 0;
918
+ if (instanceOf) return { ...node, type: instanceOf.type, properties: props };
919
+ return { ...node, properties: props };
920
+ }
921
+ function siblingMaskDeclarations(mask, sibling) {
922
+ const mw = finite(mask.properties.size.width, 0);
923
+ const mh = finite(mask.properties.size.height, 0);
924
+ if (mw <= 0 || mh <= 0) return [];
925
+ const x = finite(mask.properties.position.x, 0) - finite(sibling.properties.position.x, 0);
926
+ const y = finite(mask.properties.position.y, 0) - finite(sibling.properties.position.y, 0);
927
+ if (mask.type === "ELLIPSE") {
928
+ return [`clip-path: ellipse(${mw / 2}px ${mh / 2}px at ${x + mw / 2}px ${y + mh / 2}px)`];
929
+ }
930
+ if (mask.type !== "RECTANGLE") return [];
931
+ const round2 = rectangleMaskRound(mask.properties);
932
+ if (round2) {
933
+ return [`clip-path: xywh(${x}px ${y}px ${mw}px ${mh}px${round2})`];
934
+ }
935
+ return [
936
+ "-webkit-mask-image: linear-gradient(#000 0 0)",
937
+ "mask-image: linear-gradient(#000 0 0)",
938
+ `-webkit-mask-size: ${mw}px ${mh}px`,
939
+ `mask-size: ${mw}px ${mh}px`,
940
+ `-webkit-mask-position: ${x}px ${y}px`,
941
+ `mask-position: ${x}px ${y}px`,
942
+ "-webkit-mask-repeat: no-repeat",
943
+ "mask-repeat: no-repeat"
944
+ ];
945
+ }
946
+ function rectangleMaskRound(props) {
947
+ if (Array.isArray(props.cornerRadii) && props.cornerRadii.length === 4) {
948
+ const radii = props.cornerRadii.map((value) => finite(value, 0));
949
+ if (radii.some((value) => value > 0)) return ` round ${radii.map((value) => `${value}px`).join(" ")}`;
950
+ return "";
951
+ }
952
+ if (typeof props.cornerRadius === "number" && props.cornerRadius > 0) {
953
+ return ` round ${props.cornerRadius}px`;
954
+ }
955
+ return "";
956
+ }
957
+ function shapeFill(props, ctx) {
958
+ const fillBinding = props.bindings?.fill;
959
+ if (fillBinding && isColorBinding(fillBinding, ctx)) return `var(--cd-${cssVar(fillBinding)})`;
960
+ return paintColor(props.styles.fills[0]) || "currentColor";
615
961
  }
616
- function shapeMarkup(type, props) {
617
- const fill = paintColor(props.styles.fills[0]) || "currentColor";
618
- if (type === "ELLIPSE" || type === "ARC") {
962
+ function shapeStroke(props, ctx) {
963
+ const strokeBinding = props.bindings?.stroke;
964
+ if (strokeBinding && isColorBinding(strokeBinding, ctx)) return `var(--cd-${cssVar(strokeBinding)})`;
965
+ return paintColor(props.styles.strokes[0]);
966
+ }
967
+ function shapeMarkup(type, props, ctx) {
968
+ const fill = shapeFill(props, ctx);
969
+ if (type === "ELLIPSE") {
619
970
  return "";
620
971
  }
972
+ if (type === "ARC") {
973
+ const width = Math.max(1, finite(props.size.width, 1));
974
+ const height = Math.max(1, finite(props.size.height, 1));
975
+ const d = arcPath(width, height, finite(props.startingAngle, 0), finite(props.endingAngle, Math.PI * 1.5), finite(props.innerRadiusRatio, 0));
976
+ const strokeAttrs = svgPaintStrokeAttrs(props, ctx);
977
+ return `<svg class="cd-shape" viewBox="0 0 ${width} ${height}" overflow="visible" aria-hidden="true"><path d="${d}" fill-rule="evenodd" fill="${escapeHtml(fill)}"${strokeAttrs}/></svg>`;
978
+ }
621
979
  if (type === "POLYGON" || type === "STAR") {
622
980
  const width = Math.max(1, finite(props.size.width, 1));
623
981
  const height = Math.max(1, finite(props.size.height, 1));
624
982
  const points = regularPolygon(width / 2, height / 2, Math.min(width, height) / 2, finite(props.pointCount, type === "STAR" ? 5 : 6), type === "STAR" ? finite(props.innerRadius, 0.5) : void 0);
625
- return `<svg class="cd-shape" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polygon points="${points}" fill="${escapeHtml(fill)}"/></svg>`;
983
+ const strokeAttrs = svgPaintStrokeAttrs(props, ctx);
984
+ return `<svg class="cd-shape" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polygon points="${points}" fill="${escapeHtml(fill)}"${strokeAttrs}/></svg>`;
626
985
  }
627
986
  if (type === "VECTOR" && props.vectorPaths?.length) {
628
987
  const width = Math.max(1, finite(props.size.width, 1));
629
988
  const height = Math.max(1, finite(props.size.height, 1));
630
- const paths = props.vectorPaths.map((path) => `<path d="${escapeHtml(path.data)}" fill-rule="${path.windingRule === "EVENODD" ? "evenodd" : "nonzero"}" fill="${escapeHtml(fill)}"/>`).join("");
989
+ const strokeAttrs = svgPaintStrokeAttrs(props, ctx);
990
+ const paths = props.vectorPaths.map((path) => {
991
+ const pathFill = path.windingRule === "NONE" ? "none" : fill;
992
+ const fillRule = path.windingRule === "EVENODD" ? "evenodd" : "nonzero";
993
+ return `<path d="${escapeHtml(path.data)}" fill-rule="${fillRule}" fill="${escapeHtml(pathFill)}"${strokeAttrs}/>`;
994
+ }).join("");
631
995
  return `<svg class="cd-shape" viewBox="0 0 ${width} ${height}" aria-hidden="true">${paths}</svg>`;
632
996
  }
997
+ if (type === "LINE") {
998
+ const width = Math.max(1, finite(props.size.width, 1));
999
+ const height = Math.max(1, finite(props.size.height, 1));
1000
+ const horizontal = width >= height;
1001
+ const x1 = horizontal ? 0 : width / 2;
1002
+ const y1 = horizontal ? height / 2 : 0;
1003
+ const x2 = horizontal ? width : width / 2;
1004
+ const y2 = horizontal ? height / 2 : height;
1005
+ const stroke = shapeStroke(props, ctx) || fill;
1006
+ const attrs = [
1007
+ `x1="${x1}"`,
1008
+ `y1="${y1}"`,
1009
+ `x2="${x2}"`,
1010
+ `y2="${y2}"`,
1011
+ ...svgStrokeAttrList(props, stroke)
1012
+ ];
1013
+ return `<svg class="cd-shape" viewBox="0 0 ${width} ${height}" overflow="visible" aria-hidden="true"><line ${attrs.join(" ")}/></svg>`;
1014
+ }
633
1015
  return "";
634
1016
  }
1017
+ function svgPaintStrokeAttrs(props, ctx) {
1018
+ const stroke = shapeStroke(props, ctx);
1019
+ if (!stroke) return "";
1020
+ return ` ${svgStrokeAttrList(props, stroke).join(" ")}`;
1021
+ }
1022
+ function svgStrokeAttrList(props, stroke) {
1023
+ const attrs = [
1024
+ `stroke="${escapeHtml(stroke)}"`,
1025
+ `stroke-width="${finite(props.strokeWeight, 1)}"`
1026
+ ];
1027
+ if (props.dashPattern?.length) attrs.push(`stroke-dasharray="${props.dashPattern.map((n) => finite(n, 0)).join(" ")}"`);
1028
+ const linecap = strokeLinecapCss(props.strokeCap);
1029
+ if (linecap) attrs.push(`stroke-linecap="${linecap}"`);
1030
+ const linejoin = strokeLinejoinCss(props.strokeJoin);
1031
+ if (linejoin) attrs.push(`stroke-linejoin="${linejoin}"`);
1032
+ return attrs;
1033
+ }
1034
+ function strokeLinecapCss(cap) {
1035
+ if (cap === "ROUND") return "round";
1036
+ if (cap === "SQUARE") return "square";
1037
+ if (cap === "NONE") return "butt";
1038
+ return void 0;
1039
+ }
1040
+ function strokeLinejoinCss(join) {
1041
+ if (join === "MITER") return "miter";
1042
+ if (join === "BEVEL") return "bevel";
1043
+ if (join === "ROUND") return "round";
1044
+ return void 0;
1045
+ }
635
1046
  function fontLinks(families) {
636
1047
  const custom = [...families].filter((family) => !isWebSafeFont(family));
637
1048
  if (!custom.length) return "";
@@ -640,23 +1051,19 @@ function fontLinks(families) {
640
1051
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
641
1052
  <link href="https://fonts.googleapis.com/css2?${query}&display=swap" rel="stylesheet">`;
642
1053
  }
643
- function runtimeScript(timeouts) {
644
- if (!timeouts.length) {
645
- return `<script>
1054
+ function runtimeScript(_timeouts) {
1055
+ return `<script>
646
1056
  (() => {
647
- const stack = [];
648
- addEventListener("hashchange", () => stack.push(location.hash));
649
- document.querySelectorAll("[data-cd-back]").forEach((node) => {
1057
+ document.querySelectorAll("[data-cd-set-mode]").forEach((node) => {
650
1058
  node.addEventListener("click", (event) => {
651
1059
  event.preventDefault();
652
- history.back();
1060
+ const collection = (node.getAttribute("data-cd-set-collection") || "").replace(/[^a-zA-Z0-9_-]+/g, "-");
1061
+ const mode = node.getAttribute("data-cd-set-mode");
1062
+ const board = node.closest(".cd-board");
1063
+ if (!board || !collection || !mode) return;
1064
+ board.setAttribute("data-cd-mode-" + collection, mode);
653
1065
  });
654
1066
  });
655
- })();
656
- </script>`;
657
- }
658
- return `<script>
659
- (() => {
660
1067
  document.querySelectorAll("[data-cd-timeout]").forEach((node) => {
661
1068
  const seconds = Number(node.getAttribute("data-cd-timeout"));
662
1069
  const destination = node.getAttribute("data-cd-navigate");