@agent-native/core 0.119.0 → 0.119.1

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.
Files changed (64) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/ingestion/figma-node-to-html.ts +20 -99
  5. package/corpus/core/src/ingestion/figma-paint-math.ts +371 -0
  6. package/corpus/core/src/ingestion/index.ts +20 -0
  7. package/corpus/templates/assets/app/routes/library.tsx +17 -9
  8. package/corpus/templates/design/AGENTS.md +18 -5
  9. package/corpus/templates/design/FIGMA_INTEROPERABILITY.md +25 -12
  10. package/corpus/templates/design/actions/hydrate-figma-paste-images.ts +115 -0
  11. package/corpus/templates/design/actions/import-figma-clipboard.ts +83 -18
  12. package/corpus/templates/design/actions/import-figma-frame.ts +34 -21
  13. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +28 -1
  14. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +106 -11
  15. package/corpus/templates/design/app/components/design/FigmaHydrationDialog.tsx +273 -0
  16. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +7 -7
  17. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +57 -9
  18. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +10 -0
  19. package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +8 -1
  20. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
  21. package/corpus/templates/design/app/i18n-data.ts +150 -2
  22. package/corpus/templates/design/app/lib/design-file-upload.ts +85 -0
  23. package/corpus/templates/design/app/lib/design-import.ts +19 -1
  24. package/corpus/templates/design/app/lib/figma-clipboard.ts +110 -10
  25. package/corpus/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md +6 -0
  26. package/corpus/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md +5 -0
  27. package/corpus/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md +6 -0
  28. package/corpus/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md +6 -0
  29. package/corpus/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md +6 -0
  30. package/corpus/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md +6 -0
  31. package/corpus/templates/design/server/handlers/import-design-file.ts +50 -0
  32. package/corpus/templates/design/server/lib/fig-file-decoder.ts +90 -49
  33. package/corpus/templates/design/server/lib/fig-file-import.ts +19 -5
  34. package/corpus/templates/design/server/lib/fig-file-to-html.ts +583 -78
  35. package/corpus/templates/design/server/lib/figma-clipboard-local-decode.ts +238 -0
  36. package/corpus/templates/design/server/lib/figma-image-hydration.ts +389 -0
  37. package/corpus/templates/design/server/lib/figma-node-import.ts +136 -36
  38. package/corpus/templates/design/server/register-secrets.ts +14 -2
  39. package/dist/collab/awareness.d.ts +2 -2
  40. package/dist/collab/awareness.d.ts.map +1 -1
  41. package/dist/collab/struct-routes.d.ts +1 -1
  42. package/dist/ingestion/figma-node-to-html.d.ts.map +1 -1
  43. package/dist/ingestion/figma-node-to-html.js +14 -88
  44. package/dist/ingestion/figma-node-to-html.js.map +1 -1
  45. package/dist/ingestion/figma-paint-math.d.ts +157 -0
  46. package/dist/ingestion/figma-paint-math.d.ts.map +1 -0
  47. package/dist/ingestion/figma-paint-math.js +274 -0
  48. package/dist/ingestion/figma-paint-math.js.map +1 -0
  49. package/dist/ingestion/index.d.ts +1 -0
  50. package/dist/ingestion/index.d.ts.map +1 -1
  51. package/dist/ingestion/index.js +1 -0
  52. package/dist/ingestion/index.js.map +1 -1
  53. package/dist/observability/routes.d.ts +5 -5
  54. package/dist/progress/routes.d.ts +1 -1
  55. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  56. package/dist/provider-api/actions/provider-api.d.ts +7 -7
  57. package/dist/resources/handlers.d.ts +1 -1
  58. package/dist/secrets/routes.d.ts +3 -3
  59. package/dist/server/realtime-token.d.ts +1 -1
  60. package/dist/server/transcribe-voice.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/src/ingestion/figma-node-to-html.ts +20 -99
  63. package/src/ingestion/figma-paint-math.ts +371 -0
  64. package/src/ingestion/index.ts +20 -0
@@ -11,6 +11,13 @@
11
11
 
12
12
  import * as path from "node:path";
13
13
 
14
+ import {
15
+ cssBlendMode,
16
+ gradientAngleDegreesFromHandles,
17
+ gradientGeometryFromTransform,
18
+ remapLinearStopPosition,
19
+ } from "./figma-node-to-html.js";
20
+
14
21
  export interface Guid {
15
22
  sessionID: number;
16
23
  localID: number;
@@ -140,7 +147,17 @@ export interface FigNode {
140
147
  lineHeight?: { value: number; units?: string };
141
148
  textAlignHorizontal?: string;
142
149
  textAlignVertical?: string;
143
- textData?: { characters?: string };
150
+ textData?: {
151
+ characters?: string;
152
+ // Per-character style index (one entry per UTF-16 code unit); the index
153
+ // keys into `styleOverrideTable`. Absent/0 means the node's base style.
154
+ characterStyleIDs?: number[];
155
+ styleOverrideTable?: Array<{
156
+ styleID?: number;
157
+ fillPaints?: Paint[];
158
+ fontSize?: number;
159
+ }>;
160
+ };
144
161
  textAutoResize?: string;
145
162
  symbolData?: {
146
163
  symbolID?: Guid;
@@ -609,6 +626,60 @@ function num(n: number | null | undefined): number | null {
609
626
  return Math.round(n * 100) / 100;
610
627
  }
611
628
 
629
+ interface TextRun {
630
+ text: string;
631
+ /** CSS color when a per-character override differs from the base fill. */
632
+ color?: string;
633
+ }
634
+
635
+ /**
636
+ * Split TEXT into color runs from `characterStyleIDs` + `styleOverrideTable`
637
+ * (how one node holds two colors). Overridden runs carry an explicit color;
638
+ * base-fill runs inherit the element's `color`. One plain run when unstyled.
639
+ */
640
+ function textStyleRuns(node: FigNode): TextRun[] {
641
+ const chars = node.textData?.characters ?? "";
642
+ const ids = node.textData?.characterStyleIDs;
643
+ const table = node.textData?.styleOverrideTable;
644
+ if (!chars) return [];
645
+ if (!ids || ids.length === 0 || !table || table.length === 0) {
646
+ return [{ text: chars }];
647
+ }
648
+ const colorByStyle = new Map<number, string | undefined>();
649
+ for (const entry of table) {
650
+ if (entry?.styleID == null) continue;
651
+ // Topmost visible solid in the override's fill list.
652
+ let solid: Paint | undefined;
653
+ for (const p of entry.fillPaints ?? []) {
654
+ if (p.visible !== false && p.type === "SOLID") solid = p;
655
+ }
656
+ colorByStyle.set(
657
+ entry.styleID,
658
+ solid
659
+ ? (colorToCss(solid.color, solid.opacity ?? 1) ?? undefined)
660
+ : undefined,
661
+ );
662
+ }
663
+ const runs: TextRun[] = [];
664
+ let curText = "";
665
+ let curColor: string | undefined;
666
+ let started = false;
667
+ for (let i = 0; i < chars.length; i++) {
668
+ const color = colorByStyle.get(ids[i] ?? 0);
669
+ if (!started) {
670
+ curColor = color;
671
+ started = true;
672
+ } else if (color !== curColor) {
673
+ runs.push({ text: curText, color: curColor });
674
+ curText = "";
675
+ curColor = color;
676
+ }
677
+ curText += chars[i];
678
+ }
679
+ if (curText) runs.push({ text: curText, color: curColor });
680
+ return runs;
681
+ }
682
+
612
683
  function tagFor(type: string | undefined): string {
613
684
  // Everything renders as a real DOM tag rather than a synthetic component.
614
685
  // TEXT becomes <span> so it inlines nicely; everything else is <div>.
@@ -683,7 +754,7 @@ function imageUrl(hashHex: string, ctx: Ctx): string {
683
754
  const resolved = ctx.imageMap.get(hashHex);
684
755
  if (!resolved && ctx.missingImageUrl) return ctx.missingImageUrl;
685
756
  const filename = resolved ?? hashHex;
686
- if (/^(?:https?:|blob:|about:)/i.test(filename)) return filename;
757
+ if (/^(?:https?:|blob:|about:|data:|file:)/i.test(filename)) return filename;
687
758
  const base = ctx.imageRefBase ?? "images";
688
759
  return `${base}/${filename}`;
689
760
  }
@@ -737,24 +808,72 @@ function effectiveStrokePaints(node: FigNode, ctx: Ctx): Paint[] | undefined {
737
808
  return node.strokePaints;
738
809
  }
739
810
 
740
- function paintToBackground(p: Paint, _node: FigNode, ctx: Ctx): string | null {
811
+ function paintToBackground(p: Paint, node: FigNode, ctx: Ctx): string | null {
741
812
  if (p.visible === false) return null;
742
- if (p.type === "SOLID") return colorToCss(p.color, p.opacity ?? 1);
813
+ if (p.type === "SOLID") {
814
+ const color = colorToCss(p.color, p.opacity ?? 1);
815
+ return color ? `linear-gradient(${color}, ${color})` : null;
816
+ }
743
817
  if (p.type?.startsWith("GRADIENT") && Array.isArray(p.stops)) {
818
+ const box = node.size ? { width: node.size.x, height: node.size.y } : null;
819
+ const kind = p.type.slice("GRADIENT_".length) as
820
+ | "LINEAR"
821
+ | "RADIAL"
822
+ | "ANGULAR"
823
+ | "DIAMOND";
824
+ const geometry =
825
+ p.transform && box
826
+ ? gradientGeometryFromTransform(kind, p.transform, box)
827
+ : null;
828
+ const stopPosition =
829
+ geometry && kind === "LINEAR" && box
830
+ ? remapLinearStopPosition(
831
+ geometry.handles,
832
+ box,
833
+ gradientAngleDegreesFromHandles(geometry.handles, box),
834
+ )
835
+ : (position: number) => position;
744
836
  const stops = p.stops
745
837
  .map(
746
838
  (s) =>
747
- `${colorToCss(s.color, p.opacity ?? 1)} ${num(s.position * 100)}%`,
839
+ `${colorToCss(s.color, p.opacity ?? 1)} ${num(stopPosition(s.position) * 100)}%`,
748
840
  )
749
841
  .join(", ");
750
- if (p.type === "GRADIENT_LINEAR") return `linear-gradient(${stops})`;
751
- if (p.type === "GRADIENT_RADIAL") return `radial-gradient(${stops})`;
752
- if (p.type === "GRADIENT_ANGULAR") return `conic-gradient(${stops})`;
753
- if (p.type === "GRADIENT_DIAMOND") return `radial-gradient(${stops})`;
842
+ if (p.type === "GRADIENT_LINEAR") {
843
+ if (geometry && box) {
844
+ const angle = gradientAngleDegreesFromHandles(geometry.handles, box);
845
+ return `linear-gradient(${num(angle)}deg, ${stops})`;
846
+ }
847
+ return `linear-gradient(${stops})`;
848
+ }
849
+ if (p.type === "GRADIENT_RADIAL") {
850
+ if (geometry) {
851
+ return `radial-gradient(ellipse ${num(geometry.rx)}px ${num(geometry.ry)}px at ${num(geometry.center.x)}px ${num(geometry.center.y)}px, ${stops})`;
852
+ }
853
+ return `radial-gradient(${stops})`;
854
+ }
855
+ if (p.type === "GRADIENT_ANGULAR") {
856
+ if (geometry) {
857
+ return `conic-gradient(from ${num(geometry.fromDeg)}deg at ${num(geometry.center.x)}px ${num(geometry.center.y)}px, ${stops})`;
858
+ }
859
+ return `conic-gradient(${stops})`;
860
+ }
861
+ if (p.type === "GRADIENT_DIAMOND") {
862
+ ctx.approximatedNodes.push({
863
+ nodeId: guidKey(node.guid),
864
+ nodeName: node.name,
865
+ nodeType: node.type,
866
+ notes: ["GRADIENT_DIAMOND approximated as radial-gradient"],
867
+ });
868
+ return `radial-gradient(${stops})`;
869
+ }
754
870
  }
755
871
  if (p.type === "IMAGE") {
756
872
  const hex = hashToHex(p.image?.hash);
757
- if (hex) return `url("${imageUrl(hex, ctx)}")`;
873
+ if (hex) {
874
+ const u = imageUrl(hex, ctx);
875
+ return `url('${u.replace(/'/g, "%27")}')`;
876
+ }
758
877
  }
759
878
  return null;
760
879
  }
@@ -768,6 +887,7 @@ function backgroundShorthand(
768
887
  backgroundSize?: string;
769
888
  backgroundPosition?: string;
770
889
  backgroundRepeat?: string;
890
+ backgroundBlendMode?: string;
771
891
  } {
772
892
  const fills = (effectiveFillPaints(node, ctx) ?? []).filter(
773
893
  (f) => f.visible !== false,
@@ -779,31 +899,55 @@ function backgroundShorthand(
779
899
  backgroundSize?: string;
780
900
  backgroundPosition?: string;
781
901
  backgroundRepeat?: string;
902
+ backgroundBlendMode?: string;
782
903
  } = {};
904
+ // Optimization: when there is exactly one fill and it's a plain SOLID at the
905
+ // bottom, emit it as `background-color` (cheaper CSS, same visual) and skip
906
+ // adding it to the bgImages layer list so we don't double-render it.
907
+ const isSingleSolidOnly = fills.length === 1 && fills[0]?.type === "SOLID";
908
+ if (isSingleSolidOnly) {
909
+ const color = colorToCss(fills[0]!.color, fills[0]!.opacity ?? 1);
910
+ if (color) result.backgroundColor = color;
911
+ return result;
912
+ }
783
913
  const bgImages: string[] = [];
914
+ const bgSizes: string[] = [];
915
+ const bgPositions: string[] = [];
916
+ const bgRepeats: string[] = [];
917
+ const bgBlends: string[] = [];
784
918
  for (const f of fills) {
785
- if (f.type === "SOLID" && !result.backgroundColor) {
786
- const c = colorToCss(f.color, f.opacity ?? 1);
787
- if (c) result.backgroundColor = c;
919
+ const image = paintToBackground(f, node, ctx);
920
+ if (!image) continue;
921
+ bgImages.push(image);
922
+ bgBlends.push(blendModeCss(f.blendMode) ?? "normal");
923
+ if (f.type !== "IMAGE") {
924
+ bgSizes.push("auto");
925
+ bgPositions.push("0% 0%");
926
+ bgRepeats.push("repeat");
788
927
  continue;
789
928
  }
790
- const v = paintToBackground(f, node, ctx);
791
- if (v) bgImages.push(v);
792
- if (f.type === "IMAGE") {
793
- if (f.imageScaleMode === "FILL") result.backgroundSize = "cover";
794
- else if (f.imageScaleMode === "FIT") result.backgroundSize = "contain";
795
- else if (f.imageScaleMode === "TILE") {
796
- result.backgroundSize = "auto";
797
- result.backgroundRepeat = "repeat";
798
- } else if (f.imageScaleMode === "STRETCH")
799
- result.backgroundSize = "100% 100%";
800
- // Figma centers image fills by default (CSS defaults to top-left, which
801
- // crops the wrong edges for FILL/FIT). TILE keeps the default origin so
802
- // the tile pattern starts at top-left.
803
- if (f.imageScaleMode !== "TILE") result.backgroundPosition = "center";
929
+ const mode = f.imageScaleMode ?? "FILL";
930
+ if (mode === "FILL") bgSizes.push("cover");
931
+ else if (mode === "FIT") bgSizes.push("contain");
932
+ else if (mode === "STRETCH") bgSizes.push("100% 100%");
933
+ else bgSizes.push("auto");
934
+ bgPositions.push(mode === "TILE" ? "0% 0%" : "center");
935
+ bgRepeats.push(mode === "TILE" ? "repeat" : "no-repeat");
936
+ }
937
+ bgImages.reverse();
938
+ bgSizes.reverse();
939
+ bgPositions.reverse();
940
+ bgRepeats.reverse();
941
+ bgBlends.reverse();
942
+ if (bgImages.length > 0) {
943
+ result.backgroundImage = bgImages.join(", ");
944
+ result.backgroundSize = bgSizes.join(", ");
945
+ result.backgroundPosition = bgPositions.join(", ");
946
+ result.backgroundRepeat = bgRepeats.join(", ");
947
+ if (bgBlends.some((blend) => blend !== "normal")) {
948
+ result.backgroundBlendMode = bgBlends.join(", ");
804
949
  }
805
950
  }
806
- if (bgImages.length > 0) result.backgroundImage = bgImages.join(", ");
807
951
  return result;
808
952
  }
809
953
 
@@ -813,7 +957,8 @@ function borderShorthand(node: FigNode, ctx: Ctx): Record<string, string> {
813
957
  );
814
958
  if (strokes.length === 0) return {};
815
959
  const first = strokes[0]!;
816
- const color = colorToCss(first.color, first.opacity ?? 1) ?? "rgb(0, 0, 0)";
960
+ const color = colorToCss(first.color, first.opacity ?? 1);
961
+ if (!color) return {};
817
962
 
818
963
  const hasPerSide =
819
964
  node.strokeTopWeight !== undefined ||
@@ -940,9 +1085,9 @@ function effectStyles(
940
1085
  `inset ${num(e.offset?.x ?? 0)}px ${num(e.offset?.y ?? 0)}px ${num(e.radius ?? 0)}px ${num(e.spread ?? 0)}px ${c}`,
941
1086
  );
942
1087
  } else if (e.type === "FOREGROUND_BLUR" || e.type === "LAYER_BLUR") {
943
- filters.push(`blur(${num(e.radius ?? 0)}px)`);
1088
+ filters.push(`blur(${num((e.radius ?? 0) / 2)}px)`);
944
1089
  } else if (e.type === "BACKGROUND_BLUR") {
945
- backdropBlur = `blur(${num(e.radius ?? 0)}px)`;
1090
+ backdropBlur = `blur(${num((e.radius ?? 0) / 2)}px)`;
946
1091
  }
947
1092
  }
948
1093
  const out: Record<string, string> = {};
@@ -958,8 +1103,22 @@ function transformStyle(node: FigNode): {
958
1103
  } {
959
1104
  const t = node.transform;
960
1105
  if (!t) return {};
961
- // Decompose: rotation = atan2(m10, m00), scale assumed 1.
1106
+ const determinant = t.m00 * t.m11 - t.m01 * t.m10;
1107
+ const hasNonTrivialScale = Math.abs(Math.abs(determinant) - 1) > 0.01;
962
1108
  const angle = Math.atan2(t.m10, t.m00);
1109
+ const isPureRotation =
1110
+ Math.abs(t.m00 - Math.cos(angle)) < 0.01 &&
1111
+ Math.abs(t.m01 + Math.sin(angle)) < 0.01 &&
1112
+ Math.abs(t.m10 - Math.sin(angle)) < 0.01 &&
1113
+ Math.abs(t.m11 - Math.cos(angle)) < 0.01;
1114
+ const hasSkew =
1115
+ (Math.abs(t.m01) > 0.0001 || Math.abs(t.m10) > 0.0001) && !isPureRotation;
1116
+ if (hasNonTrivialScale || hasSkew) {
1117
+ return {
1118
+ transform: `matrix(${num(t.m00)}, ${num(t.m10)}, ${num(t.m01)}, ${num(t.m11)}, 0, 0)`,
1119
+ transformOrigin: "0 0",
1120
+ };
1121
+ }
963
1122
  const deg = (angle * 180) / Math.PI;
964
1123
  if (Math.abs(deg) < 0.01) return {};
965
1124
  return { transform: `rotate(${num(deg)}deg)`, transformOrigin: "top left" };
@@ -1011,9 +1170,26 @@ function textStyles(node: FigNode, ctx?: Ctx): Record<string, string | number> {
1011
1170
  styleNode?.textAlignHorizontal ?? node.textAlignHorizontal;
1012
1171
 
1013
1172
  if (fontName?.family) {
1014
- // Quote families with spaces so the inline style stays valid.
1015
1173
  const fam = fontName.family;
1016
- out.fontFamily = /\s/.test(fam) ? `"${fam}"` : fam;
1174
+ const quoted = /\s/.test(fam) ? `"${fam}"` : fam;
1175
+ // Append a metric-compatible fallback stack by classifying the family.
1176
+ // This prevents UA serif from appearing when a Google/system font is missing.
1177
+ const famLower = fam.toLowerCase();
1178
+ let fallback: string;
1179
+ if (
1180
+ /mono|courier|code|consol|menlo|fira code|source code/i.test(famLower)
1181
+ ) {
1182
+ fallback =
1183
+ "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace";
1184
+ } else if (
1185
+ /serif|georgia|garamond|didot|baskerville|palatino|times/i.test(famLower)
1186
+ ) {
1187
+ fallback = "'Times New Roman', Georgia, Garamond, serif";
1188
+ } else {
1189
+ fallback =
1190
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
1191
+ }
1192
+ out.fontFamily = `${quoted}, ${fallback}`;
1017
1193
  }
1018
1194
  const weight = fontWeightFromStyle(fontName?.style);
1019
1195
  if (weight !== null) out.fontWeight = weight;
@@ -1033,24 +1209,36 @@ function textStyles(node: FigNode, ctx?: Ctx): Record<string, string | number> {
1033
1209
  if (ls !== null && ls !== undefined) out.letterSpacing = ls;
1034
1210
  if (textAlignHorizontal)
1035
1211
  out.textAlign = TEXT_ALIGN[textAlignHorizontal] ?? "left";
1036
- // Text color comes from the first SOLID fill paint, dereferenced through
1037
- // any `styleIdForFill` shared style (NOT `styleIdForText`, which is
1038
- // typography-only see `effectiveFillPaints`).
1039
- if (ctx) {
1040
- const solidFill = effectiveFillPaints(node, ctx)?.find(
1041
- (f) => f.visible !== false && f.type === "SOLID",
1042
- );
1043
- if (solidFill) {
1044
- const c = colorToCss(solidFill.color, solidFill.opacity ?? 1);
1045
- if (c) out.color = c;
1212
+ const fills = (
1213
+ ctx ? effectiveFillPaints(node, ctx) : node.fillPaints
1214
+ )?.filter((fill) => fill.visible !== false);
1215
+ if (!fills?.length) {
1216
+ out.visibility = "hidden";
1217
+ return out;
1218
+ }
1219
+ const firstFill = fills[0]!;
1220
+ if (firstFill.type === "SOLID") {
1221
+ const color = colorToCss(firstFill.color, firstFill.opacity ?? 1);
1222
+ if (color) out.color = color;
1223
+ } else if (
1224
+ ctx &&
1225
+ (firstFill.type?.startsWith("GRADIENT_") || firstFill.type === "IMAGE")
1226
+ ) {
1227
+ const background = paintToBackground(firstFill, node, ctx);
1228
+ if (background) {
1229
+ out.background = background;
1230
+ out.WebkitBackgroundClip = "text";
1231
+ out.backgroundClip = "text";
1232
+ out.color = "transparent";
1046
1233
  }
1047
1234
  }
1048
1235
  return out;
1049
1236
  }
1050
1237
 
1051
1238
  function blendModeCss(mode: string | undefined): string | null {
1052
- if (!mode || mode === "NORMAL" || mode === "PASS_THROUGH") return null;
1053
- return mode.toLowerCase().replace(/_/g, "-");
1239
+ if (!mode) return null;
1240
+ const result = cssBlendMode(mode);
1241
+ return result?.cssMode ?? null;
1054
1242
  }
1055
1243
 
1056
1244
  function isAutolayout(parent: FigNode | null): boolean {
@@ -1138,13 +1326,11 @@ function layoutSizing(
1138
1326
  }
1139
1327
  }
1140
1328
 
1141
- // 2) Non-autolayout frames can still hug content via `resizeToFit`.
1142
- if (!node.stackMode || node.stackMode === "NONE") {
1143
- if (node.resizeToFit) {
1144
- horizontal = "HUG";
1145
- vertical = "HUG";
1146
- }
1147
- }
1329
+ // 2) Non-autolayout frames (e.g. Figma groups) position their children
1330
+ // absolutely in this renderer, and CSS `width/height: auto` cannot hug
1331
+ // out-of-flow children — a hugged group would collapse to 0×0 and clip
1332
+ // everything inside it. The baked `node.size` already equals the group's
1333
+ // content bounds, so keep it FIXED rather than honoring `resizeToFit`.
1148
1334
 
1149
1335
  // 3) TEXT auto-resize hugs along the indicated axis/axes.
1150
1336
  if (node.type === "TEXT" && node.textAutoResize) {
@@ -1215,6 +1401,22 @@ function applyAxisConstraint(
1215
1401
  return false;
1216
1402
  }
1217
1403
 
1404
+ function positionRelativeToParent(
1405
+ node: FigNode,
1406
+ parent: FigNode | null,
1407
+ ctx: Ctx,
1408
+ ): { x: number | null; y: number | null } {
1409
+ const transform = node.transform;
1410
+ if (!transform) return { x: null, y: null };
1411
+ // Figma/Kiwi node transforms are already expressed relative to the parent
1412
+ // (the node's `relativeTransform`), so the translation is the parent-local
1413
+ // offset at every depth — including direct children of a top-level frame,
1414
+ // whose own canvas position is dropped when the frame becomes a screen root.
1415
+ // Subtracting the parent's canvas translation here double-counts the frame
1416
+ // offset and flings direct children off-canvas.
1417
+ return { x: num(transform.m02), y: num(transform.m12) };
1418
+ }
1419
+
1218
1420
  function buildCss(
1219
1421
  node: FigNode,
1220
1422
  parent: FigNode | null,
@@ -1239,8 +1441,7 @@ function buildCss(
1239
1441
  let suppressHeight = false;
1240
1442
  if (isPositioned) {
1241
1443
  css.position = "absolute";
1242
- const x = node.transform ? num(node.transform.m02) : null;
1243
- const y = node.transform ? num(node.transform.m12) : null;
1444
+ const { x, y } = positionRelativeToParent(node, parent, ctx);
1244
1445
  const nodeW = node.size ? num(node.size.x) : null;
1245
1446
  const nodeH = node.size ? num(node.size.y) : null;
1246
1447
  const parentW = parent?.size ? num(parent.size.x) : null;
@@ -1288,6 +1489,16 @@ function buildCss(
1288
1489
  if (h !== null && emitHeight && !suppressHeight) css.height = `${h}px`;
1289
1490
  }
1290
1491
 
1492
+ // Line vectors (horizontal/vertical strokes) have a 0-size axis. A 0-size
1493
+ // <svg> viewport is dropped by the browser even with `overflow: visible`, so
1494
+ // give the degenerate axis a minimal non-zero size; the stroke, arrowheads,
1495
+ // and caps still paint at native coords because the SVG has no viewBox here.
1496
+ if (vectorLike) {
1497
+ const minAxis = Math.max(1, num(node.strokeWeight) ?? 1);
1498
+ if (css.width === "0px") css.width = `${minAxis}px`;
1499
+ if (css.height === "0px") css.height = `${minAxis}px`;
1500
+ }
1501
+
1291
1502
  // Auto-layout min/max size constraints. Each axis is independent and may be
1292
1503
  // unconstrained (null). Only emit positive values — a zero constraint is a
1293
1504
  // no-op and a min-width keeps a hugging container (e.g. a badge) circular.
@@ -1315,15 +1526,24 @@ function buildCss(
1315
1526
  }
1316
1527
  }
1317
1528
 
1529
+ // A vector with no decodable geometry must not paint its bounding box as a
1530
+ // solid fill (that renders the shape as a block); render nothing instead.
1531
+ const geometrylessVector =
1532
+ !!node.type &&
1533
+ VECTOR_LIKE_TYPES.has(node.type) &&
1534
+ !vectorLike &&
1535
+ !node.fillPaints?.some((p) => p.visible !== false && p.type === "IMAGE");
1536
+
1318
1537
  // Background (TEXT uses fillPaints for color, not background; vector
1319
1538
  // nodes paint via <path fill> inside the <svg>).
1320
- if (node.type !== "TEXT" && !vectorLike) {
1539
+ if (node.type !== "TEXT" && !vectorLike && !geometrylessVector) {
1321
1540
  Object.assign(css, backgroundShorthand(node, ctx));
1322
1541
  }
1323
1542
 
1324
1543
  // Border / outline (skipped for vector nodes — strokes go on <path>).
1325
1544
  // Merge box-shadows from border (e.g. INSIDE strokes) and effects so neither overwrites the other.
1326
- const borderStyle = !vectorLike ? borderShorthand(node, ctx) : {};
1545
+ const borderStyle =
1546
+ !vectorLike && !geometrylessVector ? borderShorthand(node, ctx) : {};
1327
1547
  const { boxShadow: borderBoxShadow, ...restBorderStyle } = borderStyle;
1328
1548
  Object.assign(css, restBorderStyle);
1329
1549
  // Radius
@@ -1346,14 +1566,39 @@ function buildCss(
1346
1566
  // Opacity / blend mode / overflow / visibility
1347
1567
  if (typeof node.opacity === "number" && node.opacity < 0.999)
1348
1568
  css.opacity = node.opacity;
1349
- const bm = blendModeCss(node.blendMode);
1350
- if (bm) css.mixBlendMode = bm;
1569
+ if (node.blendMode) {
1570
+ const bmResult = cssBlendMode(node.blendMode);
1571
+ if (bmResult) {
1572
+ css.mixBlendMode = bmResult.cssMode;
1573
+ if (bmResult.verdict === "approximated") {
1574
+ ctx.approximatedNodes.push({
1575
+ nodeId: guidKey(node.guid),
1576
+ nodeName: node.name,
1577
+ nodeType: node.type,
1578
+ notes: [
1579
+ `blend mode ${node.blendMode} approximated as ${bmResult.cssMode}`,
1580
+ ],
1581
+ });
1582
+ }
1583
+ }
1584
+ }
1351
1585
  if (
1352
1586
  (node.type === "FRAME" || node.type === "INSTANCE") &&
1353
- node.frameMaskDisabled !== true
1587
+ node.frameMaskDisabled !== true &&
1588
+ // `resizeToFit` marks a group/hug container that sizes to its children and
1589
+ // never clips in Figma — its children legitimately overflow the baked box,
1590
+ // so clipping here would crop content that should be visible.
1591
+ node.resizeToFit !== true
1354
1592
  ) {
1355
1593
  css.overflow = "hidden";
1356
1594
  }
1595
+ // Vector <svg> elements default to clipping content to their viewport. Figma
1596
+ // vector bounds are the path's *fill* box, but strokes, arrowheads, and line
1597
+ // caps extend beyond it (and horizontal/vertical lines have a 0-size box), so
1598
+ // clipping erases them. `overflow: visible` lets the full geometry paint.
1599
+ if (vectorLike) {
1600
+ css.overflow = "visible";
1601
+ }
1357
1602
  // (Hidden nodes are dropped entirely in emitNode; no display:none needed.)
1358
1603
 
1359
1604
  return css;
@@ -1381,6 +1626,10 @@ interface Ctx {
1381
1626
  /** Hex hash -> on-disk filename (e.g. `<hash>` or `<hash>.png`). */
1382
1627
  imageMap: Map<string, string>;
1383
1628
  missingImageUrl?: string;
1629
+ /** When true, per-node IMAGE fills with no imageMap entry emit data-figma-image-ref. */
1630
+ trackUnresolvedImageRefs?: boolean;
1631
+ /** Populated by buildAttrs() when trackUnresolvedImageRefs is true. */
1632
+ unresolvedImageRefs?: Set<string>;
1384
1633
  /**
1385
1634
  * Set of `family|weight|italic` triples seen while emitting the current
1386
1635
  * frame. We use it to build a Google Fonts <link> in <head> so the custom
@@ -1395,6 +1644,13 @@ interface Ctx {
1395
1644
  maxFrameOutputBytes: number;
1396
1645
  maxTotalOutputBytes: number;
1397
1646
  totalOutputBytes: number;
1647
+ /** Collect fidelity verdicts for approximated nodes. */
1648
+ approximatedNodes: Array<{
1649
+ nodeId: string;
1650
+ nodeName?: string;
1651
+ nodeType?: string;
1652
+ notes: string[];
1653
+ }>;
1398
1654
  }
1399
1655
 
1400
1656
  /**
@@ -1451,11 +1707,129 @@ function decodePathCommands(bytes: Buffer | undefined): string {
1451
1707
  return out.join(" ");
1452
1708
  }
1453
1709
 
1454
- /** SVG paint attribute (fill / stroke) for the first visible solid paint. */
1710
+ /**
1711
+ * Decode a Figma vector-network blob into an SVG path. The clipboard ships this
1712
+ * editable `vectorData.vectorNetworkBlob` instead of a flattened `commandsBlob`,
1713
+ * so it's the only vector geometry a no-token paste has. Format (little-endian,
1714
+ * reverse-engineered from real `.fig` data):
1715
+ * header : u32 vertexCount, segmentCount, regionCount, _reserved
1716
+ * vertices: vertexCount × { f32 x, f32 y, u32 styleID } (12 B)
1717
+ * segments: segmentCount × { u32 startVtx, f32 tanStart{x,y},
1718
+ * u32 endVtx, f32 tanEnd{x,y}, u32 _ } (24 B / 28 stride)
1719
+ * Each segment is the cubic P0=vtx[start], P1=P0+tanStart, P2=vtx[end]+tanEnd,
1720
+ * P3=vtx[end] (zero tangents → a line); segments chain end→start into subpaths.
1721
+ */
1722
+ interface DecodedVectorNetwork {
1723
+ d: string;
1724
+ /** End stroke-cap is an arrow/marker type (cap enum ≥ 3; 0/1/2 = none/round/square). */
1725
+ arrowEnd: boolean;
1726
+ }
1727
+
1728
+ function decodeVectorNetwork(bytes: Buffer | undefined): DecodedVectorNetwork {
1729
+ const empty: DecodedVectorNetwork = { d: "", arrowEnd: false };
1730
+ if (!bytes || bytes.length < 16) return empty;
1731
+ const vertexCount = bytes.readUInt32LE(0);
1732
+ const segmentCount = bytes.readUInt32LE(4);
1733
+ if (vertexCount > 200_000 || segmentCount > 200_000) return empty;
1734
+ const arrowEnd = bytes.readUInt32LE(12) >= 3;
1735
+
1736
+ const verts: Array<{ x: number; y: number }> = [];
1737
+ for (let i = 0; i < vertexCount; i++) {
1738
+ const o = 16 + i * 12;
1739
+ if (o + 8 > bytes.length) break;
1740
+ verts.push({ x: bytes.readFloatLE(o), y: bytes.readFloatLE(o + 4) });
1741
+ }
1742
+
1743
+ interface Seg {
1744
+ s: number;
1745
+ sx: number;
1746
+ sy: number;
1747
+ e: number;
1748
+ ex: number;
1749
+ ey: number;
1750
+ }
1751
+ const segStart = 16 + vertexCount * 12;
1752
+ const segs: Seg[] = [];
1753
+ for (let i = 0; i < segmentCount; i++) {
1754
+ const o = segStart + i * 28;
1755
+ if (o + 24 > bytes.length) break;
1756
+ segs.push({
1757
+ s: bytes.readUInt32LE(o),
1758
+ sx: bytes.readFloatLE(o + 4),
1759
+ sy: bytes.readFloatLE(o + 8),
1760
+ e: bytes.readUInt32LE(o + 12),
1761
+ ex: bytes.readFloatLE(o + 16),
1762
+ ey: bytes.readFloatLE(o + 20),
1763
+ });
1764
+ }
1765
+ if (segs.length === 0) return empty;
1766
+
1767
+ const fmt = (n: number) => {
1768
+ if (!Number.isFinite(n)) return "0";
1769
+ const r = Math.round(n * 1000) / 1000;
1770
+ return Object.is(r, -0) ? "0" : String(r);
1771
+ };
1772
+ const out: string[] = [];
1773
+ const used = new Array<boolean>(segs.length).fill(false);
1774
+
1775
+ for (let start = 0; start < segs.length; start++) {
1776
+ if (used[start]) continue;
1777
+ // Trace a connected chain: each segment's end vertex feeds the next
1778
+ // segment's start vertex.
1779
+ const chain: Seg[] = [];
1780
+ let cur: number | null = start;
1781
+ while (cur !== null && !used[cur]) {
1782
+ used[cur] = true;
1783
+ chain.push(segs[cur]!);
1784
+ const endV = segs[cur]!.e;
1785
+ let next: number | null = null;
1786
+ for (let j = 0; j < segs.length; j++) {
1787
+ if (!used[j] && segs[j]!.s === endV) {
1788
+ next = j;
1789
+ break;
1790
+ }
1791
+ }
1792
+ cur = next;
1793
+ }
1794
+ const first = chain[0]!;
1795
+ const p0 = verts[first.s];
1796
+ if (!p0) continue;
1797
+ out.push(`M${fmt(p0.x)} ${fmt(p0.y)}`);
1798
+ for (const seg of chain) {
1799
+ const a = verts[seg.s];
1800
+ const b = verts[seg.e];
1801
+ if (!a || !b) continue;
1802
+ const straight =
1803
+ seg.sx === 0 && seg.sy === 0 && seg.ex === 0 && seg.ey === 0;
1804
+ if (straight) {
1805
+ out.push(`L${fmt(b.x)} ${fmt(b.y)}`);
1806
+ } else {
1807
+ out.push(
1808
+ `C${fmt(a.x + seg.sx)} ${fmt(a.y + seg.sy)} ${fmt(b.x + seg.ex)} ${fmt(b.y + seg.ey)} ${fmt(b.x)} ${fmt(b.y)}`,
1809
+ );
1810
+ }
1811
+ }
1812
+ if (chain.length > 0 && chain[chain.length - 1]!.e === first.s) {
1813
+ out.push("Z");
1814
+ }
1815
+ }
1816
+ return { d: out.join(" "), arrowEnd };
1817
+ }
1818
+
1819
+ /**
1820
+ * SVG paint attribute (fill / stroke) for the visible solid paint. Figma
1821
+ * composites a node's paint list bottom-to-top, so the LAST opaque solid is the
1822
+ * one actually seen — e.g. a stroke stacked `[cyan, pink]` renders pink. Pick
1823
+ * the topmost visible solid rather than the first.
1824
+ */
1455
1825
  function paintToSvgFill(
1456
1826
  paints: Paint[] | undefined,
1457
1827
  ): { color: string; opacity?: number } | null {
1458
- const p = paints?.find((x) => x.visible !== false && x.type === "SOLID");
1828
+ let p: Paint | undefined;
1829
+ for (const candidate of paints ?? []) {
1830
+ if (candidate.visible !== false && candidate.type === "SOLID")
1831
+ p = candidate;
1832
+ }
1459
1833
  if (!p || !p.color) return null;
1460
1834
  const c = p.color;
1461
1835
  const r = Math.round(c.r * 255);
@@ -1481,10 +1855,13 @@ const VECTOR_LIKE_TYPES = new Set([
1481
1855
 
1482
1856
  function isVectorLike(node: FigNode): boolean {
1483
1857
  if (!node.type || !VECTOR_LIKE_TYPES.has(node.type)) return false;
1484
- if (
1485
- (node.fillGeometry?.length ?? 0) === 0 &&
1486
- (node.strokeGeometry?.length ?? 0) === 0
1487
- ) {
1858
+ // Flattened geometry (saved .fig / REST) OR an editable vector network
1859
+ // (clipboard paste) either lets us draw the real shape as <svg>.
1860
+ const hasFlatGeometry =
1861
+ (node.fillGeometry?.length ?? 0) > 0 ||
1862
+ (node.strokeGeometry?.length ?? 0) > 0;
1863
+ const hasNetwork = typeof node.vectorData?.vectorNetworkBlob === "number";
1864
+ if (!hasFlatGeometry && !hasNetwork) {
1488
1865
  return false;
1489
1866
  }
1490
1867
  // Nodes with an IMAGE fill render better as a regular <div> with
@@ -1517,11 +1894,14 @@ function emitSvgBody(
1517
1894
  const strokePaint = paintToSvgFill(effectiveStrokePaints(node, ctx));
1518
1895
  const strokeWeight = node.strokeWeight ?? 0;
1519
1896
 
1897
+ let emittedFlat = false;
1898
+
1520
1899
  // Fill paths
1521
1900
  for (const g of node.fillGeometry ?? []) {
1522
1901
  if (typeof g.commandsBlob !== "number") continue;
1523
1902
  const d = decodePathCommands(ctx.blobs[g.commandsBlob]);
1524
1903
  if (!d) continue;
1904
+ emittedFlat = true;
1525
1905
  const attrs = [`d="${d}"`, `fill-rule="${fillRule}"`];
1526
1906
  if (fillPaint) {
1527
1907
  attrs.push(`fill="${fillPaint.color}"`);
@@ -1538,6 +1918,7 @@ function emitSvgBody(
1538
1918
  if (typeof g.commandsBlob !== "number") continue;
1539
1919
  const d = decodePathCommands(ctx.blobs[g.commandsBlob]);
1540
1920
  if (!d) continue;
1921
+ emittedFlat = true;
1541
1922
  const attrs = [
1542
1923
  `d="${d}"`,
1543
1924
  `fill="none"`,
@@ -1553,10 +1934,61 @@ function emitSvgBody(
1553
1934
  lines.push(`${indent} <path ${attrs.join(" ")} />`);
1554
1935
  }
1555
1936
  }
1556
- // Best-effort viewBox so the geometry scales with the element box.
1557
- if (w > 0 && h > 0) {
1558
- // The path coordinates are already in the node's local pixel space, so
1559
- // viewBox = 0 0 w h works without further transforms.
1937
+
1938
+ // Vector-network fallback (clipboard paste ships only the editable network,
1939
+ // not flattened geometry). Decode it to a path and paint it with the node's
1940
+ // fill/stroke. Network coords are in `normalizedSize` space, so scale into
1941
+ // the node's box (the SVG viewBox is 0 0 w h).
1942
+ if (!emittedFlat && typeof node.vectorData?.vectorNetworkBlob === "number") {
1943
+ const net = decodeVectorNetwork(
1944
+ ctx.blobs[node.vectorData.vectorNetworkBlob],
1945
+ );
1946
+ if (net.d) {
1947
+ const d = net.d;
1948
+ const ns = node.vectorData.normalizedSize;
1949
+ const sx = ns && ns.x ? (w || ns.x) / ns.x : 1;
1950
+ const sy = ns && ns.y ? (h || ns.y) / ns.y : 1;
1951
+ const scaled = Math.abs(sx - 1) > 1e-6 || Math.abs(sy - 1) > 1e-6;
1952
+ const inner = scaled ? `${indent} ` : indent;
1953
+ // Arrow end-cap → SVG `<marker>` (SVG strokes have no arrow linecap). It
1954
+ // scales with stroke width and auto-orients to the path's end direction.
1955
+ const arrowId =
1956
+ net.arrowEnd && strokePaint && strokeWeight > 0
1957
+ ? `ah-${guidKey(node.guid).replace(/[^a-z0-9]/gi, "")}`
1958
+ : null;
1959
+ if (arrowId) {
1960
+ lines.push(
1961
+ `${inner} <marker id="${arrowId}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse" markerUnits="strokeWidth"><path d="M0 0 L10 5 L0 10 z" fill="${strokePaint!.color}" /></marker>`,
1962
+ );
1963
+ }
1964
+ if (scaled)
1965
+ lines.push(`${indent} <g transform="scale(${num(sx)} ${num(sy)})">`);
1966
+ if (fillPaint) {
1967
+ const a = [
1968
+ `d="${d}"`,
1969
+ `fill-rule="${fillRule}"`,
1970
+ `fill="${fillPaint.color}"`,
1971
+ ];
1972
+ if (fillPaint.opacity !== undefined)
1973
+ a.push(`fill-opacity="${fillPaint.opacity}"`);
1974
+ lines.push(`${inner} <path ${a.join(" ")} />`);
1975
+ }
1976
+ if (strokePaint && strokeWeight > 0) {
1977
+ const a = [
1978
+ `d="${d}"`,
1979
+ `fill="none"`,
1980
+ `stroke="${strokePaint.color}"`,
1981
+ `stroke-width="${num(strokeWeight)}"`,
1982
+ `stroke-linejoin="${(node.strokeJoin ?? "ROUND").toLowerCase()}"`,
1983
+ `stroke-linecap="${(node.strokeCap ?? "ROUND").toLowerCase()}"`,
1984
+ ];
1985
+ if (arrowId) a.push(`marker-start="url(#${arrowId})"`);
1986
+ if (strokePaint.opacity !== undefined)
1987
+ a.push(`stroke-opacity="${strokePaint.opacity}"`);
1988
+ lines.push(`${inner} <path ${a.join(" ")} />`);
1989
+ }
1990
+ if (scaled) lines.push(`${indent} </g>`);
1991
+ }
1560
1992
  }
1561
1993
  }
1562
1994
 
@@ -1665,6 +2097,23 @@ function buildAttrs(
1665
2097
  if (Object.keys(css).length > 0) {
1666
2098
  attrs.push(`style="${escapeHtmlAttr(formatStyleString(css))}"`);
1667
2099
  }
2100
+
2101
+ // Stamp data-figma-image-ref on elements whose IMAGE fills have no imageMap
2102
+ // entry. The sentinel placeholder URLs in the style (written by imageUrl())
2103
+ // can then be swapped for real URLs by hydrate-figma-paste-images without a
2104
+ // full re-render. Only active when the caller opts in via trackUnresolvedImageRefs.
2105
+ if (ctx.trackUnresolvedImageRefs) {
2106
+ const unresolvedHashes = (effectiveFillPaints(node, ctx) ?? [])
2107
+ .filter((p) => p.visible !== false && p.type === "IMAGE")
2108
+ .map((p) => hashToHex(p.image?.hash))
2109
+ .filter((h): h is string => h !== null && !ctx.imageMap.has(h));
2110
+ if (unresolvedHashes.length > 0) {
2111
+ const joined = unresolvedHashes.join(" ");
2112
+ attrs.push(`data-figma-image-ref="${escapeHtmlAttr(joined)}"`);
2113
+ for (const h of unresolvedHashes) ctx.unresolvedImageRefs?.add(h);
2114
+ }
2115
+ }
2116
+
1668
2117
  return attrs;
1669
2118
  }
1670
2119
 
@@ -1925,9 +2374,18 @@ function emitNode(
1925
2374
  // relative to it. Check the actually-rendered children (the inlined
1926
2375
  // master's, for an INSTANCE).
1927
2376
  const childSource = inlinedSymbol ?? node;
1928
- const hasAbsoluteChild = (
1929
- ctx.childrenOf.get(guidKey(childSource.guid)) ?? []
1930
- ).some((c) => c.stackPositioning === "ABSOLUTE");
2377
+ const childrenOfSource = ctx.childrenOf.get(guidKey(childSource.guid)) ?? [];
2378
+ const hasAbsoluteChild =
2379
+ childrenOfSource.some((c) => c.stackPositioning === "ABSOLUTE") ||
2380
+ // Non-autolayout frames/instances that are NOT themselves absolutely
2381
+ // positioned need position:relative so their absolutely-positioned children
2382
+ // (all children in non-flex mode) are offset relative to THIS element, not
2383
+ // the nearest positioned ancestor further up the tree (e.g. <html> for
2384
+ // top-level frames). Without this, children near the bottom of a frame
2385
+ // escape the frame's overflow:hidden box and appear at wrong viewport coords.
2386
+ (!isPositioned &&
2387
+ (!layoutNode.stackMode || layoutNode.stackMode === "NONE") &&
2388
+ childrenOfSource.length > 0);
1931
2389
  // A container whose drop shadow must wrap an overflowing absolute child
1932
2390
  // (e.g. a tooltip caret) needs `filter: drop-shadow()` rather than
1933
2391
  // `box-shadow`, which would only trace the body's box. Children render from
@@ -1951,7 +2409,12 @@ function emitNode(
1951
2409
  // the master.
1952
2410
  const vw = vectorSourceNode.size?.x ?? node.size?.x ?? 0;
1953
2411
  const vh = vectorSourceNode.size?.y ?? node.size?.y ?? 0;
1954
- if (vw > 0 && vh > 0) {
2412
+ // A viewBox with a 0 (or sub-pixel, rounds-to-0) dimension is degenerate —
2413
+ // the browser can't map coordinates and drops the whole SVG. Horizontal /
2414
+ // vertical line vectors have exactly this shape (one dimension ~0), so emit
2415
+ // a viewBox only when both dimensions survive rounding; otherwise the SVG
2416
+ // paints its geometry at native 1:1 coords under `overflow: visible`.
2417
+ if (num(vw)! > 0 && num(vh)! > 0) {
1955
2418
  attrs.push(`viewBox="0 0 ${num(vw)} ${num(vh)}"`);
1956
2419
  }
1957
2420
  attrs.push(`xmlns="http://www.w3.org/2000/svg"`);
@@ -1994,8 +2457,22 @@ function emitNode(
1994
2457
  emitOpenWithChildren(tag, attrs, indent, lines);
1995
2458
  // Preserve newlines in the source by splitting into <br>-separated lines
1996
2459
  // (HTML otherwise collapses whitespace).
1997
- const escaped = escapeHtmlText(chars).replace(/\n/g, "<br>");
1998
- lines.push(`${indent} ${escaped}`);
2460
+ const runs = textStyleRuns(node);
2461
+ const toHtml = (s: string) => escapeHtmlText(s).replace(/\n/g, "<br>");
2462
+ if (runs.length <= 1) {
2463
+ lines.push(`${indent} ${toHtml(chars)}`);
2464
+ } else {
2465
+ // Per-character color runs → one <span> per run; base-color runs inherit
2466
+ // the element's `color`, overridden runs carry their own.
2467
+ const html = runs
2468
+ .map((r) =>
2469
+ r.color
2470
+ ? `<span style="color: ${r.color}">${toHtml(r.text)}</span>`
2471
+ : toHtml(r.text),
2472
+ )
2473
+ .join("");
2474
+ lines.push(`${indent} ${html}`);
2475
+ }
1999
2476
  lines.push(`${indent}</${tag}>`);
2000
2477
  return;
2001
2478
  }
@@ -2127,7 +2604,7 @@ function emitFrameTemplate(frame: FigNode, ctx: Ctx, pageName: string): string {
2127
2604
  // Default CSS content-box would inflate every explicit width/height/min-size
2128
2605
  // by the padding + border, so normalize to border-box.
2129
2606
  lines.push(
2130
- " <style>*, *::before, *::after { box-sizing: border-box; }</style>",
2607
+ " <style>*, *::before, *::after { box-sizing: border-box; } body { margin: 0; padding: 0; }</style>",
2131
2608
  );
2132
2609
  // Custom font families used by the frame -> request them from Google
2133
2610
  // Fonts. (Smart-export does the same for design hand-off so the layout
@@ -2180,10 +2657,21 @@ export interface RenderedFrame {
2180
2657
  height?: number;
2181
2658
  }
2182
2659
 
2660
+ export interface RenderHtmlFidelityEntry {
2661
+ nodeId: string;
2662
+ nodeName?: string;
2663
+ nodeType?: string;
2664
+ notes: string[];
2665
+ }
2666
+
2183
2667
  export interface RenderHtmlResult {
2184
2668
  pageCount: number;
2185
2669
  frameCount: number;
2186
2670
  frames: RenderedFrame[];
2671
+ /** Populated when `trackUnresolvedImageRefs` is true in RenderHtmlOptions. */
2672
+ unresolvedImageRefs?: Set<string>;
2673
+ /** Approximated nodes collected during rendering. */
2674
+ approximatedNodes: RenderHtmlFidelityEntry[];
2187
2675
  }
2188
2676
 
2189
2677
  export interface RenderHtmlOptions {
@@ -2193,6 +2681,13 @@ export interface RenderHtmlOptions {
2193
2681
  imageMap?: Map<string, string>;
2194
2682
  /** Safe URL used when an embedded image was omitted (for example no storage provider). */
2195
2683
  missingImageUrl?: string;
2684
+ /**
2685
+ * When true, any node whose IMAGE fill hash is absent from `imageMap` gets a
2686
+ * `data-figma-image-ref="<hexHash>"` attribute stamped on its rendered element.
2687
+ * The collected set is returned as `unresolvedImageRefs` in the result so
2688
+ * callers know which hashes need retroactive resolution.
2689
+ */
2690
+ trackUnresolvedImageRefs?: boolean;
2196
2691
  /*** Optional set of page (CANVAS) and/or frame GUID keys */
2197
2692
  selection?: Set<string>;
2198
2693
  maxFrames?: number;
@@ -2485,8 +2980,13 @@ export function renderHtmlTemplates(
2485
2980
  blobs,
2486
2981
  imageMap: options.imageMap ?? new Map<string, string>(),
2487
2982
  missingImageUrl: options.missingImageUrl,
2983
+ trackUnresolvedImageRefs: options.trackUnresolvedImageRefs,
2984
+ unresolvedImageRefs: options.trackUnresolvedImageRefs
2985
+ ? new Set<string>()
2986
+ : undefined,
2488
2987
  fontUsage: new Set(),
2489
2988
  inliningStack: new Set(),
2989
+ approximatedNodes: [],
2490
2990
  renderedNodeCount: 0,
2491
2991
  maxRenderedNodes: options.maxRenderedNodes ?? DEFAULT_MAX_RENDERED_NODES,
2492
2992
  maxTreeDepth: options.maxTreeDepth ?? DEFAULT_MAX_TREE_DEPTH,
@@ -2498,7 +2998,8 @@ export function renderHtmlTemplates(
2498
2998
  };
2499
2999
 
2500
3000
  const documentNode = nodes.find((n) => n.type === "DOCUMENT");
2501
- if (!documentNode) return { pageCount: 0, frameCount: 0, frames: [] };
3001
+ if (!documentNode)
3002
+ return { pageCount: 0, frameCount: 0, frames: [], approximatedNodes: [] };
2502
3003
 
2503
3004
  const allPages = (childrenOf.get(guidKey(documentNode.guid)) ?? []).filter(
2504
3005
  (n) => n.type === "CANVAS" && !n.internalOnly,
@@ -2564,5 +3065,9 @@ export function renderHtmlTemplates(
2564
3065
  pageCount: pages.length,
2565
3066
  frameCount: frames.length,
2566
3067
  frames,
3068
+ approximatedNodes: ctx.approximatedNodes,
3069
+ ...(ctx.unresolvedImageRefs !== undefined
3070
+ ? { unresolvedImageRefs: ctx.unresolvedImageRefs }
3071
+ : {}),
2567
3072
  };
2568
3073
  }