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