@gemboss/gem-framework 0.5.2 → 0.9.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.
Files changed (57) hide show
  1. package/README.md +63 -1
  2. package/dist/SOURCE.json +2 -2
  3. package/dist/gem-framework.css +206 -64
  4. package/dist/gem-runtime.js +158 -12
  5. package/dist/index.js +747 -112
  6. package/dist/plugins/before_after.d.ts +9 -0
  7. package/dist/plugins/before_after.js +37 -0
  8. package/dist/plugins/carousel.d.ts +9 -0
  9. package/dist/plugins/carousel.js +49 -0
  10. package/dist/plugins/cart-drawer.d.ts +9 -0
  11. package/dist/plugins/cart-drawer.js +69 -0
  12. package/dist/plugins/context-adapter.d.ts +9 -0
  13. package/dist/plugins/context-adapter.js +17 -0
  14. package/dist/plugins/countdown.d.ts +9 -0
  15. package/dist/plugins/countdown.js +164 -0
  16. package/dist/plugins/disclosure.d.ts +9 -0
  17. package/dist/plugins/disclosure.js +40 -0
  18. package/dist/plugins/hero-slideshow.d.ts +9 -0
  19. package/dist/plugins/hero-slideshow.js +57 -0
  20. package/dist/plugins/localization.d.ts +9 -0
  21. package/dist/plugins/localization.js +37 -0
  22. package/dist/plugins/media-gallery.d.ts +9 -0
  23. package/dist/plugins/media-gallery.js +57 -0
  24. package/dist/plugins/mobile-menu.d.ts +9 -0
  25. package/dist/plugins/mobile-menu.js +51 -0
  26. package/dist/plugins/offer-engine.d.ts +9 -0
  27. package/dist/plugins/offer-engine.js +57 -0
  28. package/dist/plugins/predictive-search.d.ts +9 -0
  29. package/dist/plugins/predictive-search.js +77 -0
  30. package/dist/plugins/promo-popup.d.ts +9 -0
  31. package/dist/plugins/promo-popup.js +117 -0
  32. package/dist/plugins/quantity-input.d.ts +9 -0
  33. package/dist/plugins/quantity-input.js +44 -0
  34. package/dist/plugins/quick-view.d.ts +9 -0
  35. package/dist/plugins/quick-view.js +69 -0
  36. package/dist/plugins/recently-viewed.d.ts +9 -0
  37. package/dist/plugins/recently-viewed.js +71 -0
  38. package/dist/plugins/reveal-on-scroll.d.ts +9 -0
  39. package/dist/plugins/reveal-on-scroll.js +37 -0
  40. package/dist/plugins/runtime-slot-loader.d.ts +9 -0
  41. package/dist/plugins/runtime-slot-loader.js +29 -0
  42. package/dist/plugins/seasonal-effects.d.ts +9 -0
  43. package/dist/plugins/seasonal-effects.js +17 -0
  44. package/dist/plugins/sticky-atc.d.ts +9 -0
  45. package/dist/plugins/sticky-atc.js +43 -0
  46. package/dist/plugins/tabs.d.ts +9 -0
  47. package/dist/plugins/tabs.js +50 -0
  48. package/dist/plugins/variant-selects.d.ts +9 -0
  49. package/dist/plugins/variant-selects.js +70 -0
  50. package/dist/popup-shell.css +91 -0
  51. package/dist/primitives.css +1430 -0
  52. package/dist/runtime-core.d.ts +51 -0
  53. package/dist/runtime-core.js +176 -0
  54. package/dist/section-gate.js +432 -21
  55. package/dist/sections.css +1116 -0
  56. package/dist/tokens.css +67 -0
  57. package/package.json +21 -1
package/dist/index.js CHANGED
@@ -19,6 +19,118 @@ function gemPartClass(part) {
19
19
  return GEM_PART_CLASS[part] ?? null;
20
20
  }
21
21
 
22
+ // ../../packages/gem-framework/src/form-set.ts
23
+ var GEM_FORM_TOKEN_KEYS = [
24
+ "radius",
25
+ "borderWidth",
26
+ "borderColor",
27
+ "fill",
28
+ "height",
29
+ "paddingInline",
30
+ "fontSize",
31
+ "focusRing",
32
+ "checkboxAccent",
33
+ "checkboxSize",
34
+ "shape",
35
+ "join"
36
+ ];
37
+ var FORM_CSS_VAR = {
38
+ radius: "--gem-input-radius",
39
+ borderWidth: "--gem-input-border-width",
40
+ borderColor: "--gem-input-border-color",
41
+ fill: "--gem-input-bg",
42
+ height: "--gem-input-height",
43
+ paddingInline: "--gem-input-padding-inline",
44
+ fontSize: "--gem-input-font-size",
45
+ focusRing: "--gem-input-focus-ring",
46
+ checkboxAccent: "--gem-checkbox-accent",
47
+ checkboxSize: "--gem-checkbox-size"
48
+ };
49
+ var GEM_FORM_CSS_VARIABLES = Object.values(FORM_CSS_VAR);
50
+ var GEM_FIELD_SHAPES = ["outline", "filled", "underline"];
51
+ var GEM_FIELD_JOINS = ["separate", "joined"];
52
+ var safe = (v) => {
53
+ if (typeof v !== "string" || v.trim() === "" || v.length > 200 || /[;{}<>\\"'`]|\/\*/.test(v)) return false;
54
+ const open = [];
55
+ for (const ch of v) {
56
+ if (ch === "(" || ch === "[") open.push(ch);
57
+ else if (ch === ")" || ch === "]") {
58
+ if (open.pop() !== (ch === ")" ? "(" : "[")) return false;
59
+ }
60
+ }
61
+ return open.length === 0;
62
+ };
63
+ var FIELD_SHAPE_SET = /* @__PURE__ */ new Set(["outline", "filled", "underline"]);
64
+ var FIELD_JOIN_SET = /* @__PURE__ */ new Set(["separate", "joined"]);
65
+ var hasForm = (form) => !!form && typeof form === "object" && Object.keys(form).some((k) => GEM_FORM_TOKEN_KEYS.includes(k));
66
+ var GEM_FORM_DEFAULTS = {
67
+ shape: "outline",
68
+ join: "separate",
69
+ radius: "4px",
70
+ borderWidth: "1px",
71
+ borderColor: "color-mix(in srgb, currentColor 70%, transparent)",
72
+ fill: "var(--gem-color-surface)",
73
+ height: "44px",
74
+ paddingInline: "12px",
75
+ fontSize: "16px",
76
+ checkboxAccent: "var(--gem-color-accent)",
77
+ checkboxSize: "24px"
78
+ };
79
+ var GEM_FORM_VAR_SCOPE = "[data-gem-form-set], [data-gem-form-set] :is(input, select, textarea, button, form)";
80
+ function gemFormCssBlock(form) {
81
+ const vars = gemFormCssVars(form);
82
+ return vars.length ? `${GEM_FORM_VAR_SCOPE} {
83
+ ${vars.map((d) => ` ${d}`).join("\n")}
84
+ }` : "";
85
+ }
86
+ function gemFormCssVars(form) {
87
+ if (!hasForm(form)) return [];
88
+ const out = [];
89
+ for (const [key, cssVar] of Object.entries(FORM_CSS_VAR)) {
90
+ const v = form[key];
91
+ const fallback = GEM_FORM_DEFAULTS[key];
92
+ if (safe(v)) out.push(`${cssVar}: ${v.trim()};`);
93
+ else if (fallback) out.push(`${cssVar}: ${fallback};`);
94
+ }
95
+ return out;
96
+ }
97
+ function gemFormAttributes(form) {
98
+ if (!hasForm(form)) return {};
99
+ const id = typeof form.id === "string" && /^[a-z0-9-]{1,40}$/.test(form.id) ? form.id : "custom";
100
+ return {
101
+ "data-gem-form-set": id,
102
+ "data-gem-field-shape": typeof form.shape === "string" && FIELD_SHAPE_SET.has(form.shape) ? form.shape : GEM_FORM_DEFAULTS.shape,
103
+ "data-gem-field-join": typeof form.join === "string" && FIELD_JOIN_SET.has(form.join) ? form.join : GEM_FORM_DEFAULTS.join
104
+ };
105
+ }
106
+ var GEM_FORM_SETS = [
107
+ {
108
+ id: "crisp-outline",
109
+ label: "Crisp outline",
110
+ postures: ["clinical", "technical", "utility"],
111
+ form: { id: "crisp-outline", shape: "outline", join: "separate", radius: "4px", borderWidth: "1px", borderColor: "color-mix(in srgb, currentColor 70%, transparent)", fill: "var(--gem-color-surface)", height: "44px", paddingInline: "12px", fontSize: "16px", checkboxSize: "24px" }
112
+ },
113
+ {
114
+ id: "hairline-underline",
115
+ label: "Hairline underline",
116
+ postures: ["premium", "editorial"],
117
+ form: { id: "hairline-underline", shape: "underline", join: "separate", radius: "0px", borderWidth: "1px", borderColor: "color-mix(in srgb, currentColor 70%, transparent)", fill: "transparent", height: "48px", paddingInline: "2px", fontSize: "16px", checkboxSize: "24px" }
118
+ },
119
+ {
120
+ id: "soft-filled",
121
+ label: "Soft filled",
122
+ postures: ["natural"],
123
+ form: { id: "soft-filled", shape: "filled", join: "separate", radius: "10px", borderWidth: "2px", borderColor: "color-mix(in srgb, currentColor 55%, transparent)", fill: "color-mix(in srgb, var(--gem-color-text) 6%, var(--gem-color-surface))", height: "48px", paddingInline: "14px", fontSize: "16px", checkboxSize: "24px" }
124
+ },
125
+ {
126
+ id: "pill-joined",
127
+ label: "Pill joined",
128
+ postures: ["playful"],
129
+ form: { id: "pill-joined", shape: "outline", join: "joined", radius: "999px", borderWidth: "2px", borderColor: "currentColor", fill: "var(--gem-color-surface)", height: "48px", paddingInline: "20px", fontSize: "16px", checkboxSize: "24px" }
130
+ }
131
+ ];
132
+ var gemFormSetsFor = (posture) => GEM_FORM_SETS.filter((s) => s.postures.includes(posture));
133
+
22
134
  // ../../packages/gem-framework/src/plan-shape.ts
23
135
  function gemStyleSpaceFor(plan) {
24
136
  switch (plan.design_system?.spacing_density) {
@@ -130,7 +242,10 @@ var defaultGemTokens = {
130
242
  background: "#f6f6f3",
131
243
  surface: "#ffffff",
132
244
  text: "#202223",
133
- muted: "#6b7280",
245
+ // AA on THIS background (#f6f6f3), not only on white: #6b7280 measures 4.47:1 there, under 4.5, and muted styles
246
+ // every secondary line. #656c79 measures 4.88:1 (5.28 on white). Upstreamed from gemsite app/globals.css (#212,
247
+ // found by axe) in gem-framework 0.8.0.
248
+ muted: "#656c79",
134
249
  accent: "#008060",
135
250
  accentText: "#ffffff",
136
251
  border: "#d2d5d8",
@@ -270,12 +385,14 @@ function createGemTokenCss(tokens, selector = ":root") {
270
385
  GEM_TAP_MIN_CSS_VAR,
271
386
  GEM_FOCUS_OFFSET_CSS_VAR
272
387
  ];
388
+ const formBlock = gemFormCssBlock(tokens.form);
273
389
  const baseMobile = typeRampValue(tokens.typography, "baseMobile");
274
390
  return [
275
391
  `${selector} {`,
276
392
  ...declarations.map((line) => ` ${line}`),
277
393
  "}",
278
- ...baseMobile ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ` --gem-type-base: ${baseMobile};`, " }", "}"] : []
394
+ ...baseMobile ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ` --gem-type-base: ${baseMobile};`, " }", "}"] : [],
395
+ ...formBlock ? [formBlock] : []
279
396
  ].join("\n");
280
397
  }
281
398
  var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
@@ -522,6 +639,22 @@ var gemFrameworkPrimitiveGroups = [
522
639
  "gem-cart-line-item__price"
523
640
  ]
524
641
  },
642
+ {
643
+ // The popup shell: the dialog frame popup sections draw their content in. Styled by popup-shell.ts
644
+ // (GEM_POPUP_SHELL_CSS), which ships as its own popup-shell.css since 0.8.3, not in primitives.css: a page that
645
+ // draws one of these loads that file (gem-framework.css, the whole file, carries it too).
646
+ group: "popup-shell",
647
+ classes: [
648
+ "gem-popup-shell",
649
+ "gem-popup-shell__dialog",
650
+ "gem-popup-shell__frame",
651
+ "gem-popup-shell__media",
652
+ "gem-popup-shell__image",
653
+ "gem-popup-shell__panel",
654
+ "gem-popup-shell__close",
655
+ "gem-popup-shell__teaser"
656
+ ]
657
+ },
525
658
  {
526
659
  group: "sections",
527
660
  classes: [
@@ -763,6 +896,122 @@ function createGemFrameworkManifest() {
763
896
  };
764
897
  }
765
898
 
899
+ // ../../packages/gem-framework/src/popup-shell.ts
900
+ var GEM_POPUP_SHELL_CSS_LINES = [
901
+ ".gem-popup-shell { padding: 0; }",
902
+ ".gem-popup-shell__dialog {",
903
+ " padding: 0; border: 0; margin: auto; color: var(--gem-color-text); background: var(--gem-color-background);",
904
+ " width: min(880px, 92vw); max-width: none; max-height: min(640px, 92vh); overflow: hidden;",
905
+ " border-radius: var(--gem-radius-lg); box-shadow: var(--gem-shadow-md);",
906
+ "}",
907
+ ".gem-popup-shell__dialog::backdrop { background: rgb(0 0 0 / 0.55); }",
908
+ ".gem-popup-shell__frame { position: relative; display: grid; grid-template-columns: 1fr 1fr; min-height: 520px; height: 100%; }",
909
+ ".gem-popup-shell__media { position: relative; min-width: 0; overflow: hidden; background: var(--gem-color-surface); }",
910
+ ".gem-popup-shell__image { display: block; width: 100%; height: 100%; object-fit: cover; }",
911
+ ".gem-popup-shell__panel {",
912
+ " position: relative; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: var(--gem-space-md);",
913
+ " padding: var(--gem-space-2xl) var(--gem-space-xl); overflow-y: auto; align-items: center; text-align: center;",
914
+ "}",
915
+ ".gem-popup-shell[data-gem-popup-align='start'] .gem-popup-shell__panel { align-items: stretch; text-align: start; }",
916
+ ".gem-popup-shell[data-gem-popup-desktop='split-right'] .gem-popup-shell__media { order: 2; }",
917
+ ".gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__frame,",
918
+ ".gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__frame { grid-template-columns: 1fr; }",
919
+ // Thẻ hẹp chỉ là bố cục DESKTOP. Viết ngoài media query thì nó (độ ưu tiên 0,3,0) thắng rule takeover 100vw của mobile
920
+ // (0,1,0): đo 04/10/2026, popup chọn desktop "card" hay không ảnh chỉ rộng 92vw trên điện thoại, không phủ kín.
921
+ "@media (min-width: 750px) {",
922
+ " .gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__dialog,",
923
+ " .gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__dialog { width: min(480px, 92vw); }",
924
+ "}",
925
+ ".gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__media { display: none; }",
926
+ ".gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__media { max-height: 200px; }",
927
+ ".gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__frame { grid-template-columns: 1fr; place-items: center; }",
928
+ ".gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__media { position: absolute; inset: 0; }",
929
+ ".gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__panel {",
930
+ " width: min(420px, 86%); margin: var(--gem-space-xl) 0; background: var(--gem-color-background); border-radius: var(--gem-radius-lg); box-shadow: var(--gem-shadow-sm);",
931
+ "}",
932
+ // Image behind the text: light text over a dark wash, every size. A card over the image has its own
933
+ // background, so on desktop it keeps the preset's text colour.
934
+ ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media::after { content: ''; position: absolute; inset: 0; background: linear-gradient(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.6)); }",
935
+ ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__panel,",
936
+ ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__close { color: #fff; }",
937
+ ".gem-popup-shell__close {",
938
+ " position: absolute; inset-block-start: var(--gem-space-xs); inset-inline-end: var(--gem-space-xs); z-index: 2;",
939
+ " display: inline-grid; place-items: center; width: var(--gem-tap-min); height: var(--gem-tap-min); padding: 0;",
940
+ " border: 0; border-radius: var(--gem-radius-pill); color: inherit; background: transparent; cursor: pointer;",
941
+ "}",
942
+ ".gem-popup-shell__close:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); }",
943
+ ".gem-popup-shell__teaser {",
944
+ " position: fixed; inset-block-end: var(--gem-space-md); inset-inline-end: var(--gem-space-md); z-index: 40;",
945
+ " min-height: var(--gem-tap-min); padding: 0 var(--gem-space-md); font: inherit; font-weight: 600; cursor: pointer;",
946
+ " color: var(--gem-color-accent-text); background: var(--gem-color-accent); border: 0; border-radius: var(--gem-radius-pill); box-shadow: var(--gem-shadow-md);",
947
+ "}",
948
+ ".gem-popup-shell__teaser[data-position='left'] { inset-inline-end: auto; inset-inline-start: var(--gem-space-md); }",
949
+ ".gem-popup-shell__teaser[hidden] { display: none; }",
950
+ "@media (min-width: 750px) {",
951
+ // Fullscreen: the dialog covers the viewport. The image goes right; behind the text when media=background,
952
+ // and then text aligned to the start keeps the left half so it never sits half on the image.
953
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'] .gem-popup-shell__dialog { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; box-shadow: none; }",
954
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'] .gem-popup-shell__frame { min-height: 100%; }",
955
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'] .gem-popup-shell__media { order: 2; }",
956
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'] .gem-popup-shell__panel { padding: var(--gem-space-3xl); }",
957
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-align='start'] .gem-popup-shell__panel { padding-inline-start: max(var(--gem-space-3xl), 8vw); }",
958
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'] .gem-popup-shell__frame { grid-template-columns: 1fr; }",
959
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'][data-gem-popup-align='start'] .gem-popup-shell__frame { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }",
960
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'] .gem-popup-shell__media { position: absolute; inset: 0; }",
961
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'] .gem-popup-shell__panel { position: relative; z-index: 1; }",
962
+ " .gem-popup-shell[data-gem-popup-desktop='card-over-image'][data-gem-popup-media='background'] .gem-popup-shell__panel { color: var(--gem-color-text); }",
963
+ // Ở split-left / split-right / card ảnh nằm CẠNH chữ trên desktop, không nằm sau (chỉ mobile, fullscreen, card-over-image
964
+ // đặt ảnh ra sau): chữ sáng và lớp tối của ảnh nền ở đây là chữ trắng trên nền sáng. Quét tương phản 629 bản ghép
965
+ // 04/10/2026: 1,04:1 ở mọi bản ghép như vậy.
966
+ " .gem-popup-shell[data-gem-popup-media='background']:is([data-gem-popup-desktop='split-left'], [data-gem-popup-desktop='split-right'], [data-gem-popup-desktop='card']) .gem-popup-shell__panel,",
967
+ " .gem-popup-shell[data-gem-popup-media='background']:is([data-gem-popup-desktop='split-left'], [data-gem-popup-desktop='split-right'], [data-gem-popup-desktop='card']) .gem-popup-shell__close { color: var(--gem-color-text); }",
968
+ " .gem-popup-shell[data-gem-popup-media='background']:is([data-gem-popup-desktop='split-left'], [data-gem-popup-desktop='split-right'], [data-gem-popup-desktop='card']) .gem-popup-shell__media::after { content: none; }",
969
+ "}",
970
+ "@media (max-width: 749px) {",
971
+ // Mobile: takeover is the whole screen; a sheet rises from the bottom to at most 88% of it. The image
972
+ // sits under the text (top when media=top), capped at a third of the screen; with no image or the image
973
+ // behind, the text is centred vertically like the measured takeovers.
974
+ " .gem-popup-shell__dialog { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; }",
975
+ " .gem-popup-shell[data-gem-popup-mobile='sheet'] .gem-popup-shell__dialog { height: auto; max-height: 88dvh; margin: auto 0 0; border-radius: var(--gem-radius-lg) var(--gem-radius-lg) 0 0; }",
976
+ " .gem-popup-shell__frame { grid-template-columns: 1fr; grid-template-rows: auto 1fr; min-height: 100%; }",
977
+ " .gem-popup-shell__panel { justify-content: flex-start; padding: var(--gem-space-3xl) var(--gem-space-lg) var(--gem-space-xl); }",
978
+ " .gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__panel,",
979
+ " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__panel { justify-content: center; }",
980
+ // Mobile không có thẻ nổi: panel thôi nền riêng. Giữ nền thẻ (--gem-color-background) mà ảnh làm nền thì chữ sáng của
981
+ // ảnh nền nằm trên nền nhạt của thẻ: đo 04/10/2026, preset soft-pastel ra chữ trắng trên hồng nhạt, không đọc được.
982
+ " .gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__panel { width: 100%; margin: 0; border-radius: 0; box-shadow: none; background: transparent; }",
983
+ " .gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__media { position: relative; }",
984
+ " .gem-popup-shell__media { order: 2; max-height: 34dvh; }",
985
+ " .gem-popup-shell[data-gem-popup-media='top'] .gem-popup-shell__media { order: 0; }",
986
+ " .gem-popup-shell[data-gem-popup-media='top'] .gem-popup-shell__panel { padding-top: var(--gem-space-lg); }",
987
+ " .gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__frame,",
988
+ " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__frame { grid-template-rows: 1fr; }",
989
+ " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media { position: absolute; inset: 0; max-height: none; }",
990
+ "}",
991
+ // Bản xem trước (thẻ thư viện, trang ghép, lab). Ngoài canvas editor nó PHỦ CẢ KHUNG NHÌN như popup thật mở bằng
992
+ // showModal (top layer, che cả header và thanh thông báo): Chris 04/10/2026 thấy popup của trang ghép nằm dưới header,
993
+ // trong luồng trang, không giống store. Trong canvas editor nó vẫn nằm trong luồng trang trên một nền mờ giả, để chọn
994
+ // và sửa được như mọi band khác.
995
+ // Dialog định vị ĐÚNG như modal của trình duyệt (position: fixed; inset: 0; margin: auto), nên mọi bố cục (fullscreen,
996
+ // thẻ, takeover, sheet kéo tới 88dvh) ra cùng khung chữ nhật với popup mở thật; nền chỉ thay ::backdrop (không có ở
997
+ // dialog không-modal).
998
+ ":root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] { position: fixed; inset: 0; z-index: 1000; padding: 0; background: rgb(0 0 0 / 0.55); }",
999
+ ":root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: fixed; inset: 0; display: block; }",
1000
+ ":root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: relative; display: block; inset: auto; }",
1001
+ ":root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: var(--gem-space-xl) var(--gem-layout-gutter); background: rgb(0 0 0 / 0.55); }",
1002
+ "@media (min-width: 750px) {",
1003
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] { padding: 0; }",
1004
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] .gem-popup-shell__dialog { height: 720px; max-height: none; }",
1005
+ "}",
1006
+ "@media (max-width: 749px) {",
1007
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: 0; }",
1008
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { height: 844px; max-height: none; width: 100%; max-width: 100%; }",
1009
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-mobile='sheet'] { padding-top: 160px; }",
1010
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-mobile='sheet'] .gem-popup-shell__dialog { min-height: 0; }",
1011
+ "}"
1012
+ ];
1013
+ var GEM_POPUP_SHELL_CSS = GEM_POPUP_SHELL_CSS_LINES.join("\n");
1014
+
766
1015
  // ../../packages/gem-framework/src/primitives.ts
767
1016
  var dividerMask = (path) => `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 10' preserveAspectRatio='none'%3E%3Cpath fill='%23fff' d='${path}'/%3E%3C/svg%3E")`;
768
1017
  var spikePath = (() => {
@@ -787,34 +1036,112 @@ var GEM_DIVIDER_CSS_LINES = [
787
1036
  // it entirely would paint square corners over that radius. A browser without
788
1037
  // `overflow-clip-margin` keeps plain `clip` — the divider stays hidden, which is today's
789
1038
  // behaviour rather than a broken layout.
790
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-top], :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-bottom] { position: relative; overflow: clip; overflow-clip-margin: var(--gem-divider-height, 48px); }",
1039
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top], :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom] { position: relative; overflow: clip; overflow-clip-margin: var(--gem-divider-height, 48px); }",
791
1040
  // Shared geometry. `top: 100%` / `bottom: 100%` put it outside the box; z-index 1 keeps it above the
792
1041
  // neighbouring band's background. pointer-events: none so it never eats a click near the seam.
793
1042
  // Qualified by SHAPE. Unqualified, any value paints a solid 48px slab — including the literal
794
1043
  // "none", which the attribute writer forwards unchanged. The clip used to hide that; widening
795
1044
  // it above makes a stray value visible.
796
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-bottom='wave']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-bottom='slope']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-bottom='spikes']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-bottom='arch']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-bottom='blob']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-top='wave']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-top='slope']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-top='spikes']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-top='arch']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-top='blob']::before { content: ''; position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; height: var(--gem-divider-height, 48px); background: var(--gem-section-bg, var(--gem-color-background)); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; }",
797
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-bottom]::after { top: 100%; }",
1045
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='wave']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='slope']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='spikes']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='arch']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='blob']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='wave']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='slope']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='spikes']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='arch']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='blob']::before { content: ''; position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; height: var(--gem-divider-height, 48px); background: var(--gem-section-bg, var(--gem-color-background)); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; }",
1046
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom]::after { top: 100%; }",
798
1047
  // The top edge reuses the same masks, mirrored, so five shapes cover both edges.
799
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-top]::before { bottom: 100%; transform: scaleY(-1); }",
1048
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top]::before { bottom: 100%; transform: scaleY(-1); }",
800
1049
  ...Object.entries(DIVIDER_SHAPES).flatMap(([name, path]) => {
801
1050
  const mask = dividerMask(path);
802
1051
  return [
803
- `:is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-bottom='${name}']::after { -webkit-mask-image: ${mask}; mask-image: ${mask}; }`,
804
- `:is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-divider-top='${name}']::before { -webkit-mask-image: ${mask}; mask-image: ${mask}; }`
1052
+ `:is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='${name}']::after { -webkit-mask-image: ${mask}; mask-image: ${mask}; }`,
1053
+ `:is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='${name}']::before { -webkit-mask-image: ${mask}; mask-image: ${mask}; }`
805
1054
  ];
806
1055
  })
807
1056
  ];
808
1057
  var GEM_DIVIDER_CSS = GEM_DIVIDER_CSS_LINES.join("\n");
809
1058
  var GEM_CARD_CSS_LINES = [
810
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-card] :is(.gem-section__inner, [class*='__inner']) { border-radius: var(--gem-radius-md); padding: var(--gem-space-xl); }",
811
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-card='outline'] :is(.gem-section__inner, [class*='__inner']) { border: 1px solid var(--gem-color-border); }",
812
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-card='shadow'] :is(.gem-section__inner, [class*='__inner']) { background: var(--gem-color-surface); box-shadow: var(--gem-shadow-md); }",
1059
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-card] :is(.gem-section__inner, [class*='__inner']) { border-radius: var(--gem-radius-md); padding: var(--gem-space-xl); }",
1060
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-card='outline'] :is(.gem-section__inner, [class*='__inner']) { border: 1px solid var(--gem-color-border); }",
1061
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-card='shadow'] :is(.gem-section__inner, [class*='__inner']) { background: var(--gem-color-surface); box-shadow: var(--gem-shadow-md); }",
813
1062
  // `filled` tints the section's own ground rather than reaching for a fixed colour, so it stays
814
1063
  // legible on an accent or dark band instead of punching a light panel into it.
815
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-card='filled'] :is(.gem-section__inner, [class*='__inner']) { background: color-mix(in srgb, currentColor 6%, transparent); }"
1064
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-card='filled'] :is(.gem-section__inner, [class*='__inner']) { background: color-mix(in srgb, currentColor 6%, transparent); }"
816
1065
  ];
817
1066
  var GEM_CARD_CSS = GEM_CARD_CSS_LINES.join("\n");
1067
+ var GEM_FORM_SET_CSS_LINES = [
1068
+ // FORM SET (form-set.ts). Every rule is scoped under [data-gem-form-set], which <body> carries only when the plan
1069
+ // sets `tokens.form`: a store without one renders as before. The variables are always COMPLETE (missing keys take
1070
+ // GEM_FORM_DEFAULTS) and are declared on the controls themselves (GEM_FORM_VAR_SCOPE), so these rules never fall back
1071
+ // to a value nobody chose and a band that redefines its palette recolours its fields.
1072
+ // THE FIELD LIST names the dawn-parity footer field (0,2,0, later in the sheet) so :is() — which takes its most
1073
+ // specific argument — outranks it: before, that field ignored every form set (review of #665). The main search is
1074
+ // LEFT OUT (`:where(:not(…))`, no specificity): it is a field and a button drawn as one block whose button belongs to
1075
+ // the profile, and restyling only the field half left it open on one side.
1076
+ // .gem-field-join is joined by construction (the seller chose the join element), so `data-gem-field-join` does not
1077
+ // open it; gemsite's own field + button pairs read that attribute.
1078
+ "[data-gem-form-set] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)) {",
1079
+ " min-height: var(--gem-input-height);",
1080
+ " height: auto;",
1081
+ " border: var(--gem-input-border-width) solid var(--gem-input-border-color);",
1082
+ " border-radius: var(--gem-input-radius);",
1083
+ " background: var(--gem-input-bg);",
1084
+ " padding-inline: var(--gem-input-padding-inline);",
1085
+ " font-size: var(--gem-input-font-size);",
1086
+ "}",
1087
+ // A textarea is two rows tall and never a pill: a 999px radius grows with the box as the shopper drags it. Keyed on
1088
+ // the ELEMENT, not .gem-textarea, so a copy whose list adds its own fields (gemsite's cart note) reaches them too.
1089
+ "[data-gem-form-set] textarea:is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)) {",
1090
+ " min-height: calc(var(--gem-input-height) * 2);",
1091
+ " border-radius: min(var(--gem-input-radius), 12px);",
1092
+ " padding-block: var(--gem-space-sm);",
1093
+ "}",
1094
+ "[data-gem-form-set] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)):focus-visible {",
1095
+ " outline: var(--gem-input-focus-ring, var(--gem-focus-ring, 2px solid var(--gem-color-accent)));",
1096
+ " outline-offset: var(--gem-focus-offset, 2px);",
1097
+ "}",
1098
+ // underline: only the bottom rule, no box. filled: the tinted field keeps its top corners and a bottom rule.
1099
+ '[data-gem-form-set][data-gem-field-shape="underline"] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)) {',
1100
+ " border-width: 0 0 var(--gem-input-border-width);",
1101
+ " border-radius: 0;",
1102
+ " background: transparent;",
1103
+ // Transparent, so it reads on whatever is behind it: the ink of the band it sits on, not the page's. A dark section
1104
+ // changes `color` and leaves --gem-color-text dark (review of #665: dark text on #202223, 1:1).
1105
+ " color: inherit;",
1106
+ "}",
1107
+ // On a band its placeholder must follow that ink too (the UA grey does not: 3.47:1 on a dark band, the footer field's
1108
+ // only visible label), and a select's options keep the system's own pair: the popup on Windows/Linux takes the
1109
+ // select's colours, so inherited light ink on a transparent field drew light options on a light list.
1110
+ '[data-gem-form-set][data-gem-field-shape="underline"] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input))::placeholder {',
1111
+ " color: color-mix(in srgb, currentColor 70%, transparent);",
1112
+ "}",
1113
+ '[data-gem-form-set][data-gem-field-shape="underline"] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)) option {',
1114
+ " color: CanvasText;",
1115
+ " background: Canvas;",
1116
+ "}",
1117
+ '[data-gem-form-set][data-gem-field-shape="filled"] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)) {',
1118
+ " border-width: 0 0 var(--gem-input-border-width);",
1119
+ " border-end-start-radius: 0;",
1120
+ " border-end-end-radius: 0;",
1121
+ "}",
1122
+ // The main search stays the profile's: the variables are declared on (and inherited by) every input, and the
1123
+ // profile's base .gem-input rule reads two of them as hooks, so set those back to "unset" here (morph: 1 → 2px).
1124
+ // DISABLED keeps a tinted fill (the base :disabled rule is 0,2,0 and the field rule above outranks it): the tint goes
1125
+ // OVER the field's own fill, so a white field on a dark band stays white under its dark text (review of #665: a
1126
+ // transparent tint there was 1:1). §9.2 state matrix: a disabled field must still look disabled, not only fainter.
1127
+ "[data-gem-form-set] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)):disabled {",
1128
+ " background: color-mix(in srgb, currentColor 6%, var(--gem-input-bg));",
1129
+ "}",
1130
+ // An underline field has no fill of its own (its ink is the band's): tint the band, not a fill it does not draw.
1131
+ '[data-gem-form-set][data-gem-field-shape="underline"] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)):disabled {',
1132
+ " background: color-mix(in srgb, currentColor 6%, transparent);",
1133
+ "}",
1134
+ "[data-gem-form-set] .gem-search-form .gem-input {",
1135
+ " --gem-input-border-width: initial;",
1136
+ " --gem-input-radius: initial;",
1137
+ "}",
1138
+ "[data-gem-form-set] .gem-checkbox {",
1139
+ " inline-size: var(--gem-checkbox-size);",
1140
+ " block-size: var(--gem-checkbox-size);",
1141
+ " accent-color: var(--gem-checkbox-accent);",
1142
+ "}"
1143
+ ];
1144
+ var GEM_FORM_SET_CSS = GEM_FORM_SET_CSS_LINES.join("\n");
818
1145
  var GEM_COUNTDOWN_ENDED_CSS_LINES = [
819
1146
  ":root:not(:has([data-gem-canvas])) [data-gem-section-id][data-gem-countdown-ended='true'] { display: none; }",
820
1147
  ":root:has([data-gem-canvas]) [data-gem-section-id][data-gem-countdown-ended='true'] { opacity: .55; }",
@@ -877,12 +1204,12 @@ var GEM_ROW_CSS_LINES = [
877
1204
  var GEM_ROW_GAP_CSS = GEM_ROW_GAP_CSS_LINES.join("\n");
878
1205
  var GEM_ROW_CSS = GEM_ROW_CSS_LINES.join("\n");
879
1206
  var GEM_GRID_COLUMNS_KNOB_CSS_LINES = [
880
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-grid-columns='2'] :is(.gem-section__grid, [class*='__grid']):not(.gem-row) { grid-template-columns: repeat(2, minmax(0, 1fr)); }",
881
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-grid-columns='2'] .gem-row:is(.gem-section__grid, [class*='__grid']):not(.gem-row .gem-row) { --gem-row-cols: 2; --gem-row-cols-md: 2; --gem-row-tracks: initial; --gem-row-tracks-md: initial; }",
882
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-grid-columns='3'] :is(.gem-section__grid, [class*='__grid']):not(.gem-row) { grid-template-columns: repeat(3, minmax(0, 1fr)); }",
883
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-grid-columns='3'] .gem-row:is(.gem-section__grid, [class*='__grid']):not(.gem-row .gem-row) { --gem-row-cols: 3; --gem-row-cols-md: 3; --gem-row-tracks: initial; --gem-row-tracks-md: initial; }",
884
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-grid-columns='4'] :is(.gem-section__grid, [class*='__grid']):not(.gem-row) { grid-template-columns: repeat(4, minmax(0, 1fr)); }",
885
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-grid-columns='4'] .gem-row:is(.gem-section__grid, [class*='__grid']):not(.gem-row .gem-row) { --gem-row-cols: 4; --gem-row-cols-md: 4; --gem-row-tracks: initial; --gem-row-tracks-md: initial; }"
1207
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-grid-columns='2'] :is(.gem-section__grid, [class*='__grid']):not(.gem-row) { grid-template-columns: repeat(2, minmax(0, 1fr)); }",
1208
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-grid-columns='2'] .gem-row:is(.gem-section__grid, [class*='__grid']):not(.gem-row .gem-row) { --gem-row-cols: 2; --gem-row-cols-md: 2; --gem-row-tracks: initial; --gem-row-tracks-md: initial; }",
1209
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-grid-columns='3'] :is(.gem-section__grid, [class*='__grid']):not(.gem-row) { grid-template-columns: repeat(3, minmax(0, 1fr)); }",
1210
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-grid-columns='3'] .gem-row:is(.gem-section__grid, [class*='__grid']):not(.gem-row .gem-row) { --gem-row-cols: 3; --gem-row-cols-md: 3; --gem-row-tracks: initial; --gem-row-tracks-md: initial; }",
1211
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-grid-columns='4'] :is(.gem-section__grid, [class*='__grid']):not(.gem-row) { grid-template-columns: repeat(4, minmax(0, 1fr)); }",
1212
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-grid-columns='4'] .gem-row:is(.gem-section__grid, [class*='__grid']):not(.gem-row .gem-row) { --gem-row-cols: 4; --gem-row-cols-md: 4; --gem-row-tracks: initial; --gem-row-tracks-md: initial; }"
886
1213
  ];
887
1214
  var GEM_GRID_COLUMNS_ROW_KNOB_CSS = GEM_GRID_COLUMNS_KNOB_CSS_LINES.filter((line) => line.includes(" .gem-row:is(")).join("\n");
888
1215
  function createGemStyleKnobCss(options = {}, selector = ":root") {
@@ -899,20 +1226,34 @@ function createGemStyleKnobCss(options = {}, selector = ":root") {
899
1226
  });
900
1227
  }
901
1228
  function createGemThemeCss(tokens, selector = ":root", options = {}) {
902
- return [
903
- createGemTokenCss(tokens, selector),
904
- // Set the knobs the space / radius / border / type ramps read (only when they differ from the
905
- // default 1, so a standard-shaped storefront emits nothing extra).
906
- ...createGemStyleKnobCss(options, selector),
907
- "",
908
- createGemBaseCss(options.profile ?? "dawn-parity")
909
- ].join("\n");
1229
+ const parts = createGemThemeCssParts(tokens, selector, options);
1230
+ return parts.tokens + parts.primitives + parts.popupShell + parts.sections;
1231
+ }
1232
+ function createGemThemeCssParts(tokens, selector = ":root", options = {}) {
1233
+ const base = createGemBaseCssParts(options.profile ?? "dawn-parity");
1234
+ return {
1235
+ tokens: [
1236
+ createGemTokenCss(tokens, selector),
1237
+ // Set the knobs the space / radius / border / type ramps read (only when they differ from the
1238
+ // default 1, so a standard-shaped storefront emits nothing extra).
1239
+ ...createGemStyleKnobCss(options, selector),
1240
+ "",
1241
+ ""
1242
+ ].join("\n"),
1243
+ primitives: base.primitives,
1244
+ popupShell: [GEM_POPUP_SHELL_CSS, "", ""].join("\n"),
1245
+ sections: base.sections
1246
+ };
910
1247
  }
911
1248
  function createSectionClassName(section) {
912
1249
  return `gem-section gem-${slugify(section.archetype)} gem-${slugify(section.archetype)}--${slugify(section.variant)}`;
913
1250
  }
914
1251
  function createGemBaseCss(profile = "dawn-parity") {
915
- return [
1252
+ const parts = createGemBaseCssParts(profile);
1253
+ return parts.primitives + parts.sections;
1254
+ }
1255
+ function createGemBaseCssParts(profile) {
1256
+ const reset = [
916
1257
  "*, *::before, *::after {",
917
1258
  " box-sizing: border-box;",
918
1259
  "}",
@@ -987,16 +1328,12 @@ function createGemBaseCss(profile = "dawn-parity") {
987
1328
  " overflow: hidden !important;",
988
1329
  " clip: rect(0, 0, 0, 0) !important;",
989
1330
  " white-space: nowrap !important;",
990
- "}",
991
- "",
992
- createPrimitiveCss(),
993
- "",
994
- createShellCss(),
995
- "",
996
- createSectionCss(),
997
- "",
998
- createThemeProfileCss(profile)
1331
+ "}"
999
1332
  ].join("\n");
1333
+ return {
1334
+ primitives: [reset, "", createPrimitiveCss(), "", ""].join("\n"),
1335
+ sections: [createShellCss(), "", createSectionCss(), "", createThemeProfileCss(profile)].join("\n")
1336
+ };
1000
1337
  }
1001
1338
  function createThemeProfileCss(profile = "dawn-parity") {
1002
1339
  return profile === "dawn-parity" ? createDawnParityCss() : createMorphProfileCss();
@@ -1236,6 +1573,7 @@ function createPrimitiveCss() {
1236
1573
  " block-size: var(--gem-space-lg);",
1237
1574
  " accent-color: var(--gem-color-accent);",
1238
1575
  "}",
1576
+ ...GEM_FORM_SET_CSS_LINES,
1239
1577
  ".gem-submit {",
1240
1578
  " cursor: pointer;",
1241
1579
  "}",
@@ -2531,17 +2869,17 @@ function createSectionCss() {
2531
2869
  // theme + published) and on the sim-preview markup. Grid items justify; text aligns. The text
2532
2870
  // rule also reaches hero copy + section content so heroes honor it (their absolute-positioned
2533
2871
  // copy box keeps its tuned position; only the text inside re-aligns).
2534
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-alignment='left'] .gem-section__inner { justify-items: start; }",
2535
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-alignment='left'] :is(.gem-section__inner, .gem-hero-banner__copy, [class*='__content']) { text-align: left; }",
2536
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-alignment='center'] .gem-section__inner { justify-items: center; }",
2537
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-alignment='center'] :is(.gem-section__inner, .gem-hero-banner__copy, [class*='__content']) { text-align: center; }",
2538
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-alignment='right'] .gem-section__inner { justify-items: end; }",
2539
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-alignment='right'] :is(.gem-section__inner, .gem-hero-banner__copy, [class*='__content']) { text-align: right; }",
2872
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-alignment='left'] .gem-section__inner { justify-items: start; }",
2873
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-alignment='left'] :is(.gem-section__inner, .gem-hero-banner__copy, [class*='__content']) { text-align: left; }",
2874
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-alignment='center'] .gem-section__inner { justify-items: center; }",
2875
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-alignment='center'] :is(.gem-section__inner, .gem-hero-banner__copy, [class*='__content']) { text-align: center; }",
2876
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-alignment='right'] .gem-section__inner { justify-items: end; }",
2877
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-alignment='right'] :is(.gem-section__inner, .gem-hero-banner__copy, [class*='__content']) { text-align: right; }",
2540
2878
  // SAFETY: justify-items above content-sizes the inner grid's items, which would COLLAPSE a
2541
2879
  // full-bleed grid child (a mosaic/collage/product grid that must span the row) to min-content —
2542
2880
  // i.e. the section visually disappears. Re-stretch any grid/media/mosaic direct child so alignment
2543
2881
  // only ever moves TEXT, never breaks a section's own layout (fix: collage vanished in wf_0102).
2544
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-alignment] .gem-section__inner > :is([class*='__grid'], [class*='__media'], [class*='__mosaic'], [class*='__masonry'], [class*='__frame'], [class*='__collage'], [class*='__row']) { justify-self: stretch; width: 100%; }",
2882
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-alignment] .gem-section__inner > :is([class*='__grid'], [class*='__media'], [class*='__mosaic'], [class*='__masonry'], [class*='__frame'], [class*='__collage'], [class*='__row']) { justify-self: stretch; width: 100%; }",
2545
2883
  // Text-position knob (the VERTICAL sibling of content_alignment) — set by set_layout text_position
2546
2884
  // (gemboss override key gem_text_position), emitted as data-gem-text-position by the lean-hero
2547
2885
  // renderer (shopify-adapter leanHeroLayoutKeys). It had NO CSS anywhere: the agent could pick it,
@@ -2553,16 +2891,16 @@ function createSectionCss() {
2553
2891
  // (0,3,0) beats the component's own `.gem-hero-banner--default .gem-hero-banner__copy` (0,2,0),
2554
2892
  // so this wins only when the operator sets the knob; unset, the tuned default position stands.
2555
2893
  // `bottom`/`top: auto` is required on each: leaving both insets set pins the box to BOTH edges.
2556
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-position='top'] .gem-hero-banner__copy { top: 50px; bottom: auto; }",
2557
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-position='center'] .gem-hero-banner__copy { top: 50%; bottom: auto; transform: translate(-50%, -50%); }",
2558
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-position='bottom'] .gem-hero-banner__copy { bottom: 50px; top: auto; }",
2894
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-position='top'] .gem-hero-banner__copy { top: 50px; bottom: auto; }",
2895
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-position='center'] .gem-hero-banner__copy { top: 50%; bottom: auto; transform: translate(-50%, -50%); }",
2896
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-position='bottom'] .gem-hero-banner__copy { bottom: 50px; top: auto; }",
2559
2897
  // Non-hero sections: move the content stack within whatever spare height the section has (a
2560
2898
  // `section_height: tall` section, say). `align-content` is a no-op when the grid has no spare
2561
2899
  // space, so this never disturbs a normally-sized section — it only answers the same vertical
2562
2900
  // question on the sections where the question can even be asked.
2563
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-position='top'] .gem-section__inner { align-content: start; }",
2564
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-position='center'] .gem-section__inner { align-content: center; }",
2565
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-position='bottom'] .gem-section__inner { align-content: end; }",
2901
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-position='top'] .gem-section__inner { align-content: start; }",
2902
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-position='center'] .gem-section__inner { align-content: center; }",
2903
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-position='bottom'] .gem-section__inner { align-content: end; }",
2566
2904
  // Media-position knob (section LAYOUT axis) — set by set_layout media_position (gemboss override key
2567
2905
  // gem_media_position). On a genuine two-column section it swaps the image column to the chosen side
2568
2906
  // by re-ordering the media child. TWO shapes qualify: (a) `__media` is a DIRECT child of the inner
@@ -2574,16 +2912,16 @@ function createSectionCss() {
2574
2912
  // __media), so `grid > __media` never matches a card's image → we swap real 2-col layouts and never
2575
2913
  // reorder cards. A full-bleed background-image hero has its `__media` absolutely positioned, so
2576
2914
  // `order` is a natural no-op there — exactly right.
2577
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-media-position='left'] .gem-section__inner > [class*='__media'], :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-media-position='left'] .gem-section__inner > [class*='__grid'] > [class*='__media'] { order: -1; }",
2578
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-media-position='right'] .gem-section__inner > [class*='__media'], :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-media-position='right'] .gem-section__inner > [class*='__grid'] > [class*='__media'] { order: 1; }",
2915
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-media-position='left'] .gem-section__inner > [class*='__media'], :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-media-position='left'] .gem-section__inner > [class*='__grid'] > [class*='__media'] { order: -1; }",
2916
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-media-position='right'] .gem-section__inner > [class*='__media'], :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-media-position='right'] .gem-section__inner > [class*='__grid'] > [class*='__media'] { order: 1; }",
2579
2917
  // Spacing/density knob — set by set_layout content_spacing. Scales the inner grid gap (the gap
2580
2918
  // between columns + stacked rows), the direct answer to wf_0101's 'khoảng cách giữa 2 cột'.
2581
2919
  // Density knob — set by set_layout content_spacing. Scales the gap of the inner content stack.
2582
2920
  // (Per-card GRID spacing is governed by the tuned card-mode/variant rules — changed via
2583
2921
  // swap_variant, not this knob — so we don't fight their specificity here.)
2584
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-spacing='tight'] .gem-section__inner { gap: var(--gem-space-sm); }",
2585
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-spacing='normal'] .gem-section__inner { gap: var(--gem-space-lg); }",
2586
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-spacing='roomy'] .gem-section__inner { gap: var(--gem-space-3xl); }",
2922
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-spacing='tight'] .gem-section__inner { gap: var(--gem-space-sm); }",
2923
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-spacing='normal'] .gem-section__inner { gap: var(--gem-space-lg); }",
2924
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-spacing='roomy'] .gem-section__inner { gap: var(--gem-space-3xl); }",
2587
2925
  // HERO COPY STACK — same rules as gemsite app/globals.css (gemsite #609), kept in sync by hand.
2588
2926
  // On a full-bleed hero the inner grid has ONE in-flow child (the media is absolutely positioned), so the
2589
2927
  // inner `gap` above spaces nothing and the knob was MUTE: probed on the live storefront (gemboss acceptance
@@ -2606,18 +2944,18 @@ function createSectionCss() {
2606
2944
  ":is(.gem-hero-banner--default, .gem-hero-banner--dawn-image-banner)[data-gem-content-spacing='roomy'] .gem-hero-banner__copy > :not(:last-child), .gem-hero-banner[data-gem-content-spacing='roomy'] .gem-hero-slideshow__copy > :not(:last-child) { margin-bottom: 0; }",
2607
2945
  // Width knob — set by set_layout content_width. Overrides the inner container width: full-bleed
2608
2946
  // edge-to-edge, the default contained width, or a narrow reading measure.
2609
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-width='full'] .gem-section__inner { width: 100%; max-width: none; }",
2610
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-width='contained'] .gem-section__inner { width: min(var(--gem-layout-page, 1180px), calc(100% - var(--gem-layout-gutter, 32px))); max-width: none; }",
2611
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-content-width='narrow'] .gem-section__inner { width: min(56rem, calc(100% - var(--gem-layout-gutter, 32px))); }",
2947
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-width='full'] .gem-section__inner { width: 100%; max-width: none; }",
2948
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-width='contained'] .gem-section__inner { width: min(var(--gem-layout-page, 1180px), calc(100% - var(--gem-layout-gutter, 32px))); max-width: none; }",
2949
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-content-width='narrow'] .gem-section__inner { width: min(56rem, calc(100% - var(--gem-layout-gutter, 32px))); }",
2612
2950
  // Height knob — set by set_layout section_height. Sets min-height on BOTH the section element
2613
2951
  // and its inner: the section-level rule (attr+class = (0,2,0)) beats a component's single-class
2614
2952
  // floor like `.gem-hero-banner--default { min-height: 720px }` (0,1,0), so "make the hero
2615
2953
  // shorter/taller" actually re-sizes the section (the inner-only rule alone left the section floored).
2616
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-height='short'] { min-height: 420px; }",
2617
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-height='tall'] { min-height: 760px; }",
2618
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-height='short'] .gem-section__inner { min-height: 420px; }",
2619
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-height='normal'] .gem-section__inner { min-height: auto; }",
2620
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-height='tall'] .gem-section__inner { min-height: 760px; }",
2954
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-height='short'] { min-height: 420px; }",
2955
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-height='tall'] { min-height: 760px; }",
2956
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-height='short'] .gem-section__inner { min-height: 420px; }",
2957
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-height='normal'] .gem-section__inner { min-height: auto; }",
2958
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-height='tall'] .gem-section__inner { min-height: 760px; }",
2621
2959
  // Columns knob — set by set_layout grid_columns. Overrides the grid container's column count. The
2622
2960
  // contract advertises grid_columns for any section with a `columns` setting (layoutCapabilitiesOf),
2623
2961
  // but MOST gem sections name their grid `{section}__grid` (gem-multicolumn__grid, gem-stats__grid,
@@ -2640,25 +2978,25 @@ function createSectionCss() {
2640
2978
  // The shape-divider below paints the section's OWN
2641
2979
  // colour into the neighbouring section, so it needs a single name for "whatever this band is",
2642
2980
  // rather than re-deriving the swatch in every divider rule.
2643
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='default'] { --gem-section-bg: var(--gem-color-background); background: transparent; }",
2644
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='surface'] { --gem-section-bg: var(--gem-color-surface); background: var(--gem-section-bg); padding-block: var(--gem-space-xl); }",
2645
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='accent'] { --gem-section-bg: var(--gem-color-accent); background: var(--gem-section-bg); color: var(--gem-color-accent-text); --gem-band-bg: var(--gem-color-accent); --gem-focus-ring: 2px solid var(--gem-color-accent-text); padding-block: var(--gem-space-xl); }",
2646
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='dark'] { --gem-section-bg: var(--gem-color-text); background: var(--gem-section-bg); color: var(--gem-color-background); --gem-band-bg: var(--gem-color-text); --gem-focus-ring: 2px solid var(--gem-color-background); padding-block: var(--gem-space-xl); }",
2647
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='light'] { --gem-section-bg: var(--gem-color-background); background: var(--gem-section-bg); color: var(--gem-color-text); padding-block: var(--gem-space-xl); }",
2981
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-background='default'] { --gem-section-bg: var(--gem-color-background); background: transparent; }",
2982
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-background='surface'] { --gem-section-bg: var(--gem-color-surface); background: var(--gem-section-bg); padding-block: var(--gem-space-xl); }",
2983
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-background='accent'] { --gem-section-bg: var(--gem-color-accent); background: var(--gem-section-bg); color: var(--gem-color-accent-text); --gem-band-bg: var(--gem-color-accent); --gem-focus-ring: 2px solid var(--gem-color-accent-text); padding-block: var(--gem-space-xl); }",
2984
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-background='dark'] { --gem-section-bg: var(--gem-color-text); background: var(--gem-section-bg); color: var(--gem-color-background); --gem-band-bg: var(--gem-color-text); --gem-focus-ring: 2px solid var(--gem-color-background); padding-block: var(--gem-space-xl); }",
2985
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-background='light'] { --gem-section-bg: var(--gem-color-background); background: var(--gem-section-bg); color: var(--gem-color-text); padding-block: var(--gem-space-xl); }",
2648
2986
  // On accent/dark bands, keep muted sub-copy + headings legible against the inverted ground.
2649
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='accent'] :is(.gem-section__heading, .gem-section__body, .gem-section__eyebrow), :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='dark'] :is(.gem-section__heading, .gem-section__body, .gem-section__eyebrow) { color: inherit; }",
2987
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-background='accent'] :is(.gem-section__heading, .gem-section__body, .gem-section__eyebrow), :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-section-background='dark'] :is(.gem-section__heading, .gem-section__body, .gem-section__eyebrow) { color: inherit; }",
2650
2988
  // Price-colour knob (set by set_style price_color) — recolours the PRICE text on a product/collection
2651
2989
  // section. red/green are literal sale/savings semantics; accent uses the brand token; default inherits.
2652
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-price-color='accent'] :is(.gem-price, .gem-price__regular, [class*='__price']) { color: var(--gem-color-accent); }",
2653
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-price-color='red'] :is(.gem-price, .gem-price__regular, [class*='__price']) { color: #d61f1f; }",
2654
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-price-color='green'] :is(.gem-price, .gem-price__regular, [class*='__price']) { color: #1a7f37; }",
2990
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-price-color='accent'] :is(.gem-price, .gem-price__regular, [class*='__price']) { color: var(--gem-color-accent); }",
2991
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-price-color='red'] :is(.gem-price, .gem-price__regular, [class*='__price']) { color: #d61f1f; }",
2992
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-price-color='green'] :is(.gem-price, .gem-price__regular, [class*='__price']) { color: #1a7f37; }",
2655
2993
  // Text-weight knob (set by set_layout text_weight) — scales the section's --gem-style-weight ramp
2656
2994
  // (which weight-bearing type multiplies) AND sets the heading font-weight directly, so 'bold'
2657
2995
  // emboldens the headings on any section. default/normal inherit.
2658
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-weight='bold'] { --gem-style-weight: 1.25; }",
2659
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-weight='bold'] :is(.gem-section__heading, .gem-hero-banner__copy h1, .gem-hero-banner__copy h2) { font-weight: 800; }",
2660
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-weight='light'] { --gem-style-weight: 0.8; }",
2661
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-text-weight='light'] :is(.gem-section__heading, .gem-hero-banner__copy h1, .gem-hero-banner__copy h2) { font-weight: 350; }",
2996
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-weight='bold'] { --gem-style-weight: 1.25; }",
2997
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-weight='bold'] :is(.gem-section__heading, .gem-hero-banner__copy h1, .gem-hero-banner__copy h2) { font-weight: 800; }",
2998
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-weight='light'] { --gem-style-weight: 0.8; }",
2999
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-text-weight='light'] :is(.gem-section__heading, .gem-hero-banner__copy h1, .gem-hero-banner__copy h2) { font-weight: 350; }",
2662
3000
  // G4 — per-element colour (set by set_style element_color {target, colour}). One mechanism colours
2663
3001
  // the heading / eyebrow / badge in a section; swatches map to brand token or literal sale/dark.
2664
3002
  // (price uses data-gem-price-color above.) accent=brand, red/green=sale/savings, dark=text token.
@@ -4390,37 +4728,58 @@ var promoPopupPluginRegistrationLines = [
4390
4728
  " registerPlugin('promo-popup', ({ api }) => {",
4391
4729
  // Only the explicit hook opts in. Every promo-popup section carries the archetype class .gem-promo-popup, and
4392
4730
  // binding by class took over variants that run their own behavior.js (GemFactory gap #16, 03/10/2026).
4731
+ // A section that has the hook AND its own behavior.js (promo-popup.email-capture.v1) renders the plugin's bound
4732
+ // mark, data-gem-bound-promo-popup, so mark() below skips it (gem-framework 0.8.3).
4393
4733
  " const popups = document.querySelectorAll('[data-gem-promo-popup]');",
4394
4734
  " let bound = 0;",
4735
+ " const shown = [];",
4395
4736
  " const storagePrefix = 'gem:promo-popup-dismissed:';",
4396
4737
  " const now = () => Date.now ? Date.now() : new Date().getTime();",
4397
- " const readDismissedUntil = (key) => {",
4398
- " try { return Number(target.localStorage && target.localStorage.getItem(storagePrefix + key) || '0'); }",
4738
+ // ONE storage key, two meanings in the wild (gem-framework 0.8.0). gemsite's port stores WHEN the shopper
4739
+ // dismissed; runtimes up to 0.7 stored when the dismissal ENDS. Read both: a value in the future can only be an
4740
+ // end, so it holds until then (and is rewritten once as a dismissal time); a value in the past is a dismissal time and
4741
+ // the popup's own window applies. Written from now on: the dismissal time (gemsite's meaning), which also lets a
4742
+ // seller who shortens the window shorten it for past dismissals. An end written by 0.7 that has already passed
4743
+ // reads as a dismissal time: that popup stays away one more window at most, never less than the shopper asked.
4744
+ " const readDismissal = (key) => {",
4745
+ " try { const value = Number(target.localStorage && target.localStorage.getItem(storagePrefix + key) || '0'); return Number.isFinite(value) && value > 0 ? value : 0; }",
4399
4746
  " catch { return 0; }",
4400
4747
  " };",
4401
- " const writeDismissedUntil = (key, timestamp) => {",
4748
+ " const writeDismissedAt = (key, timestamp) => {",
4402
4749
  " try { if (target.localStorage) target.localStorage.setItem(storagePrefix + key, String(timestamp)); }",
4403
4750
  " catch { /* storage can be unavailable in private contexts */ }",
4404
4751
  " };",
4752
+ " const isDismissed = (key, windowMs) => {",
4753
+ " const stored = readDismissal(key);",
4754
+ " if (!stored) return false;",
4755
+ " const at = now();",
4756
+ // Converted ONCE: only when the end minus this window is already past (it is then the dismissal time). An end
4757
+ // further out than one window (the 0.7 window was longer) is left as it is and holds until it passes.
4758
+ " if (stored > at) { if (stored - windowMs <= at) writeDismissedAt(key, stored - windowMs); return true; }",
4759
+ " return at - stored < windowMs;",
4760
+ " };",
4405
4761
  " popups.forEach((popup, index) => {",
4406
4762
  " if (!mark(popup, 'promo-popup')) return;",
4407
4763
  " const key = popup.id || popup.getAttribute && popup.getAttribute('data-gem-section-library-id') || String(index);",
4408
4764
  " const delaySeconds = Number(popup.getAttribute && (popup.getAttribute('data-gem-promo-delay') || '0'));",
4409
4765
  " const frequencyDays = Number(popup.getAttribute && (popup.getAttribute('data-gem-promo-frequency-days') || '7'));",
4766
+ " const windowMs = (Number.isFinite(frequencyDays) ? Math.max(1, frequencyDays) : 7) * 86400000;",
4410
4767
  " const dialog = popup.querySelector && popup.querySelector('[data-gem-promo-popup-dialog]');",
4411
4768
  " const overlay = popup.querySelector && popup.querySelector('[data-gem-promo-popup-overlay]');",
4412
- " const open = (reason = 'timer') => {",
4413
- " if (readDismissedUntil(key) > now()) { popup.dataset.gemPromoPopupState = 'dismissed'; return false; }",
4769
+ " const show = (reason) => {",
4414
4770
  " popup.hidden = false;",
4415
4771
  " if (dialog) dialog.hidden = false;",
4416
4772
  " if (overlay) overlay.hidden = false;",
4417
4773
  " popup.dataset.gemPromoPopupState = 'open';",
4418
4774
  " api.emit('gem:promo-popup:open', { source: 'gem-runtime', id: popup.id || undefined, reason });",
4775
+ " };",
4776
+ " const open = (reason = 'timer') => {",
4777
+ " if (isDismissed(key, windowMs)) { popup.dataset.gemPromoPopupState = 'dismissed'; return false; }",
4778
+ " show(reason);",
4419
4779
  " return true;",
4420
4780
  " };",
4421
4781
  " const close = (reason = 'dismiss') => {",
4422
- " const days = Number.isFinite(frequencyDays) ? Math.max(1, frequencyDays) : 7;",
4423
- " writeDismissedUntil(key, now() + days * 86400000);",
4782
+ " writeDismissedAt(key, now());",
4424
4783
  " if (dialog) dialog.hidden = true;",
4425
4784
  " if (overlay) overlay.hidden = true;",
4426
4785
  " popup.dataset.gemPromoPopupState = 'closed';",
@@ -4433,10 +4792,63 @@ var promoPopupPluginRegistrationLines = [
4433
4792
  " if (canListen(overlay)) overlay.addEventListener('click', () => close('overlay'));",
4434
4793
  " popup.dataset.gemPromoPopupState = 'ready';",
4435
4794
  " const delay = Math.max(0, Number.isFinite(delaySeconds) ? delaySeconds : 0) * 1000;",
4436
- " target.setTimeout ? target.setTimeout(() => open('timer'), delay) : open('timer');",
4795
+ // WHEN it opens, gemsite's contract (components/behaviors/promoPopup.js): data-gem-promo-trigger = "delay"
4796
+ // (default, and any unknown value) | "exit_intent" (the pointer leaves through the top 10px of the window; a
4797
+ // touch screen has no pointer to leave with, so it falls back to the delay) | "scroll" (the shopper has scrolled
4798
+ // data-gem-promo-scroll-percent of the page, 10 to 90, default 50; a page that still cannot scroll at the delay
4799
+ // opens then). It opens once per page view, whichever fires first. data-gem-promo-armed says which one is live.
4800
+ // The delay keeps the runtime's default of 0 seconds (gemsite's port defaults to 6).
4801
+ " const trigger = String(popup.getAttribute && popup.getAttribute('data-gem-promo-trigger') || 'delay').trim();",
4802
+ " const rawPercent = String(popup.getAttribute && popup.getAttribute('data-gem-promo-scroll-percent') || '').trim();",
4803
+ " const parsedPercent = rawPercent ? Number(rawPercent) : NaN;",
4804
+ " const scrollPercent = Number.isFinite(parsedPercent) ? Math.min(90, Math.max(10, parsedPercent)) : 50;",
4805
+ " const canHover = () => { try { return Boolean(target.matchMedia && target.matchMedia('(hover: hover) and (pointer: fine)').matches); } catch { return false; } };",
4806
+ " const armed = trigger === 'exit_intent' && canHover() ? 'exit_intent' : trigger === 'scroll' ? 'scroll' : 'delay';",
4807
+ " if (popup.setAttribute) popup.setAttribute('data-gem-promo-armed', armed);",
4808
+ " let opened = false;",
4809
+ " const disarm = [];",
4810
+ " const openOnce = (reason) => { if (opened) return; opened = true; disarm.splice(0).forEach((off) => off()); open(reason); };",
4811
+ // An explicit open (api.promoPopup.open) counts as THE open of this page view: it disarms the pending trigger, so the
4812
+ // delay or exit intent does not show the popup a second time (code review of #675), and it does nothing on a popup
4813
+ // that is already open.
4814
+ " shown.push((reason) => { opened = true; disarm.splice(0).forEach((off) => off()); if (popup.dataset.gemPromoPopupState !== 'open') show(reason); });",
4815
+ " if (armed === 'exit_intent') {",
4816
+ " const onMouseOut = (event) => { if (event.relatedTarget || event.clientY >= 10) return; openOnce('exit_intent'); };",
4817
+ " if (canListen(document)) {",
4818
+ " document.addEventListener('mouseout', onMouseOut);",
4819
+ " disarm.push(() => document.removeEventListener && document.removeEventListener('mouseout', onMouseOut));",
4820
+ " }",
4821
+ " } else if (armed === 'scroll') {",
4822
+ " const room = () => Number(document.documentElement && document.documentElement.scrollHeight || 0) - Number(target.innerHeight || 0);",
4823
+ " const check = () => { const r = room(); if (r > 0 && (Number(target.scrollY || target.pageYOffset || 0) / r) * 100 >= scrollPercent) openOnce('scroll'); };",
4824
+ " let ticking = false;",
4825
+ " const onScroll = () => {",
4826
+ " if (ticking) return;",
4827
+ " ticking = true;",
4828
+ " const frame = target.requestAnimationFrame ? (fn) => target.requestAnimationFrame(fn) : (fn) => target.setTimeout && target.setTimeout(fn, 16);",
4829
+ " frame(() => { ticking = false; check(); });",
4830
+ " };",
4831
+ " if (canListen(target)) {",
4832
+ " target.addEventListener('scroll', onScroll, { passive: true });",
4833
+ " target.addEventListener('resize', check);",
4834
+ " target.addEventListener('load', check);",
4835
+ " disarm.push(() => { if (!target.removeEventListener) return; target.removeEventListener('scroll', onScroll); target.removeEventListener('resize', check); target.removeEventListener('load', check); });",
4836
+ " }",
4837
+ " if (target.setTimeout) {",
4838
+ " const fallback = target.setTimeout(() => { if (room() <= 0) openOnce('timer'); }, delay);",
4839
+ " disarm.push(() => target.clearTimeout && target.clearTimeout(fallback));",
4840
+ " }",
4841
+ " check();",
4842
+ " } else {",
4843
+ " target.setTimeout ? target.setTimeout(() => openOnce('timer'), delay) : openOnce('timer');",
4844
+ " }",
4437
4845
  " bound += 1;",
4438
4846
  " });",
4439
- " api.promoPopup = { open: () => popups.forEach((popup) => { popup.hidden = false; popup.dataset.gemPromoPopupState = 'open'; }) };",
4847
+ // The popups THIS plugin bound, never every [data-gem-promo-popup]: one a section's own behavior.js drives carries the
4848
+ // bound mark already (email-capture renders data-gem-bound-promo-popup="section") and is not the plugin's to open. An
4849
+ // explicit open (a teaser, a test) shows the dialog itself, not only the root, and ignores the dismissal window,
4850
+ // which is about opening on its own. Before 0.8.3 it unhid the root only, so the dialog stayed hidden.
4851
+ " api.promoPopup = { open: () => shown.forEach((show) => show('api')) };",
4440
4852
  " api.emit('gem:promo-popup:ready', { source: 'gem-runtime', count: bound });",
4441
4853
  " });"
4442
4854
  ];
@@ -4452,7 +4864,14 @@ var stickyAtcPluginRegistrationLines = [
4452
4864
  " };",
4453
4865
  " bars.forEach((bar) => {",
4454
4866
  " if (!mark(bar, 'sticky-atc')) return;",
4455
- " const threshold = Number(bar.getAttribute && (bar.getAttribute('data-gem-sticky-threshold') || '240'));",
4867
+ // Two attribute names (gem-framework 0.8.0), each with its own default. data-gem-sticky-threshold is the
4868
+ // runtime's (default 240). data-gem-sticky-atc-threshold is gemsite's (StickyAtcCompactBar.tsx): when the bar
4869
+ // carries it, gemsite's rule decides: that number, else the --gem-sticky-atc-threshold CSS variable, else 360.
4870
+ // A bar carrying both follows gemsite's attribute.
4871
+ " const cssThreshold = () => { try { return target.getComputedStyle ? Number(target.getComputedStyle(bar).getPropertyValue('--gem-sticky-atc-threshold')) : 0; } catch { return 0; } };",
4872
+ " const threshold = bar.hasAttribute && bar.hasAttribute('data-gem-sticky-atc-threshold')",
4873
+ " ? (Number(bar.getAttribute('data-gem-sticky-atc-threshold')) || cssThreshold() || 360)",
4874
+ " : Number(bar.getAttribute && (bar.getAttribute('data-gem-sticky-threshold') || '240'));",
4456
4875
  " const update = () => {",
4457
4876
  " const scrollY = Number(target.scrollY || target.pageYOffset || 0);",
4458
4877
  " setState(bar, scrollY >= threshold ? 'visible' : 'hidden');",
@@ -4514,12 +4933,111 @@ var countdownPluginRegistrationLines = [
4514
4933
  " const targetNode = timer.querySelector && timer.querySelector(selector);",
4515
4934
  " if (targetNode) targetNode.textContent = pad(value);",
4516
4935
  " };",
4936
+ // Relative mode: `data-gem-countdown-duration="N"` counts N seconds for THIS shopper instead of running to a
4937
+ // calendar deadline (a popup offer: "this code is yours for 1:00"). `data-gem-countdown-start` says when the N
4938
+ // seconds begin: "mount" (default) at bind; "visible" only while the timer is on screen, which is what a popup
4939
+ // wants: hidden until the popup opens, paused while the shopper has it closed or has moved past the step that
4940
+ // holds it, and no race with the open event (this plugin loads on `viewport`, so it can bind after a popup
4941
+ // that opened at once); any other value is an event name heard on the timer's section. Readouts:
4942
+ // [data-gem-countdown-days|hours|minutes|seconds] (two digits) and [data-gem-countdown-clock] ("1:00", "1:02:05").
4943
+ // At zero the TIMER gets data-gem-countdown-ended="true" and a bubbling gem:countdown:complete leaves it, so
4944
+ // the section that owns the offer can end it. The band is NOT marked: this ending belongs to one shopper, and
4945
+ // the band rule above (data-gem-countdown-end) removes a band from the store for everyone.
4946
+ " const clockText = (s) => {",
4947
+ " const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;",
4948
+ " return (h ? h + ':' + pad(m) : String(m)) + ':' + pad(sec);",
4949
+ " };",
4950
+ " const bindDuration = (timer, total) => {",
4951
+ " const scope = (timer.closest && timer.closest('[data-gem-section-id]')) || document;",
4952
+ " const mode = String(timer.getAttribute('data-gem-countdown-start') || 'mount').trim() || 'mount';",
4953
+ " const stopEvent = String(timer.getAttribute('data-gem-countdown-stop') || '').trim();",
4954
+ " let left = total * 1000;",
4955
+ " let endAt = 0;",
4956
+ " let handle = null;",
4957
+ " let state = 'idle';",
4958
+ // gem:countdown:start means the N seconds began: once. A visible-mode timer pauses and resumes as it leaves and
4959
+ // re-enters the screen, and each resume used to announce a new start (gemboss PR #3758 review, item 7).
4960
+ " let started = false;",
4961
+ // The visible-mode observer, released as soon as the timer can no longer run (complete or stopped), not at the
4962
+ // next intersection change after it.
4963
+ " let observer = null;",
4964
+ " const release = () => { if (observer) { observer.disconnect(); observer = null; } };",
4965
+ " const detail = () => ({ source: 'gem-runtime', id: timer.id || undefined, mode: 'duration', duration: total, remainingSeconds: Math.max(0, Math.ceil(left / 1000)) });",
4966
+ " const render = () => {",
4967
+ " const s = Math.max(0, Math.ceil(left / 1000));",
4968
+ " timer.dataset.gemCountdownState = state;",
4969
+ " timer.dataset.gemCountdownRemainingSeconds = String(s);",
4970
+ " write(timer, '[data-gem-countdown-days]', Math.floor(s / 86400));",
4971
+ " write(timer, '[data-gem-countdown-hours]', Math.floor((s % 86400) / 3600));",
4972
+ " write(timer, '[data-gem-countdown-minutes]', Math.floor((s % 3600) / 60));",
4973
+ " write(timer, '[data-gem-countdown-seconds]', s % 60);",
4974
+ " const readout = timer.querySelector && timer.querySelector('[data-gem-countdown-clock]');",
4975
+ " if (readout) readout.textContent = clockText(s);",
4976
+ " };",
4977
+ " const halt = () => { if (handle !== null && target.clearInterval) target.clearInterval(handle); handle = null; };",
4978
+ " const finish = () => {",
4979
+ " halt();",
4980
+ " release();",
4981
+ " left = 0;",
4982
+ " state = 'complete';",
4983
+ " render();",
4984
+ " timer.setAttribute('data-gem-countdown-ended', 'true');",
4985
+ // One event, not two: dispatched from the timer it bubbles to the document; api.emit would add a second
4986
+ // document event. Without a dispatchable timer (tests, detached nodes) fall back to api.emit.
4987
+ " if (timer.dispatchEvent && timer.isConnected !== false) {",
4988
+ " timer.dispatchEvent(new CustomEvent('gem:countdown:complete', { bubbles: true, detail: detail() }));",
4989
+ " for (const callback of api.listeners['gem:countdown:complete'] || []) callback(detail());",
4990
+ " } else api.emit('gem:countdown:complete', detail());",
4991
+ " };",
4992
+ " const tick = () => { left = endAt - Date.now(); if (left <= 0) finish(); else render(); };",
4993
+ " const run = () => {",
4994
+ " if (state === 'running' || state === 'complete' || state === 'stopped') return;",
4995
+ " state = 'running';",
4996
+ " endAt = Date.now() + left;",
4997
+ " render();",
4998
+ " if (target.setInterval) handle = target.setInterval(tick, 250);",
4999
+ " if (!started) { started = true; api.emit('gem:countdown:start', detail()); }",
5000
+ " };",
5001
+ " const pause = (next) => {",
5002
+ " if (state === 'running') { left = Math.max(0, endAt - Date.now()); halt(); }",
5003
+ " if (state === 'complete' || state === 'stopped') return;",
5004
+ " state = next;",
5005
+ " if (next === 'stopped') release();",
5006
+ " render();",
5007
+ " };",
5008
+ " render();",
5009
+ " if (stopEvent && canListen(scope)) scope.addEventListener(stopEvent, () => pause('stopped'));",
5010
+ " if (mode === 'visible' && target.IntersectionObserver) {",
5011
+ " observer = new target.IntersectionObserver((entries) => {",
5012
+ " for (const entry of entries) entry.isIntersecting ? run() : pause('paused');",
5013
+ " });",
5014
+ " observer.observe(timer);",
5015
+ " } else if (mode === 'mount' || mode === 'visible') run();",
5016
+ " else if (canListen(scope)) scope.addEventListener(mode, run);",
5017
+ " };",
4517
5018
  " timers.forEach((timer) => {",
4518
5019
  " if (!mark(timer, 'countdown')) return;",
5020
+ " const duration = Number(timer.getAttribute && timer.getAttribute('data-gem-countdown-duration'));",
5021
+ " if (Number.isFinite(duration) && duration > 0) {",
5022
+ " bindDuration(timer, Math.min(Math.round(duration), 86400));",
5023
+ " bound += 1;",
5024
+ " return;",
5025
+ " }",
4519
5026
  " const targetTime = countdownEnd(timer.getAttribute && timer.getAttribute('data-gem-countdown-target'));",
4520
5027
  " const section = timer.closest ? timer.closest('[data-gem-section-id]') : null;",
4521
5028
  " const band = section && section.hasAttribute && section.hasAttribute('data-gem-countdown-end') ? section : null;",
5029
+ // Two readout schemes, one countdown (gem-framework 0.8.0). The runtime's own cells are
5030
+ // [data-gem-countdown-days|hours|minutes|seconds]; the Liquid theme's initCountdown and gemsite write
5031
+ // [data-gem-cd-days|hours|mins|secs] (gemsite CountdownBannerOfferBar.tsx). Both are written; a cell the markup
5032
+ // does not carry is skipped. The readout ticks every second only when it shows seconds, else once a minute ON the
5033
+ // minute of the remaining time (0.8.2): the next update is set for the instant the minute digit changes,
5034
+ // (remaining % 60000) + a few ms, and re-aimed from the clock each time, so the digit is never a minute late.
5035
+ // In the last minute the next change IS the end, so the chain aims at the end itself and stops there: nothing is
5036
+ // left scheduled after it, and a deadline too far off for the one-shot end timer below still ends on time.
5037
+ // A once-a-minute interval from mount time (0.8.0, 0.8.1) let it lag the true remaining time by up to 59 s.
5038
+ " const hasSecondsCell = Boolean(timer.querySelector && (timer.querySelector('[data-gem-cd-secs]') || timer.querySelector('[data-gem-countdown-seconds]')));",
4522
5039
  " let handle = null;",
5040
+ " let announced = '';",
4523
5041
  " const update = () => {",
4524
5042
  " if (!Number.isFinite(targetTime)) {",
4525
5043
  " timer.dataset.gemCountdownState = 'invalid';",
@@ -4530,20 +5048,43 @@ var countdownPluginRegistrationLines = [
4530
5048
  " const days = Math.floor(remainingSeconds / 86400);",
4531
5049
  " const hours = Math.floor((remainingSeconds % 86400) / 3600);",
4532
5050
  " const minutes = Math.floor((remainingSeconds % 3600) / 60);",
5051
+ " const seconds = remainingSeconds % 60;",
5052
+ // ONE end state: data-gem-countdown-state="complete" (the runtime's word, same as the event
5053
+ // gem:countdown:complete and the duration timer). gemsite's port wrote "ended"; its meaning travels as the
5054
+ // attribute data-gem-countdown-ended="true" on the timer, the same mark a duration timer gets at zero. A timer
5055
+ // that is itself a section root is left to the band rule above, so it never hides a band that does not own a deadline.
4533
5056
  " timer.dataset.gemCountdownState = ms > 0 ? 'running' : 'complete';",
4534
5057
  " timer.dataset.gemCountdownRemainingSeconds = String(remainingSeconds);",
4535
5058
  " write(timer, '[data-gem-countdown-days]', days);",
4536
5059
  " write(timer, '[data-gem-countdown-hours]', hours);",
4537
5060
  " write(timer, '[data-gem-countdown-minutes]', minutes);",
5061
+ " write(timer, '[data-gem-countdown-seconds]', seconds);",
5062
+ " write(timer, '[data-gem-cd-days]', days);",
5063
+ " write(timer, '[data-gem-cd-hours]', hours);",
5064
+ " write(timer, '[data-gem-cd-mins]', minutes);",
5065
+ " write(timer, '[data-gem-cd-secs]', seconds);",
4538
5066
  " if (ms <= 0) {",
4539
5067
  " if (band) band.setAttribute('data-gem-countdown-ended', 'true');",
5068
+ " if (timer.setAttribute && !(timer.hasAttribute && timer.hasAttribute('data-gem-section-id'))) timer.setAttribute('data-gem-countdown-ended', 'true');",
4540
5069
  " if (handle !== null && target.clearInterval) { target.clearInterval(handle); handle = null; }",
4541
5070
  " }",
4542
- " api.emit(ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });",
5071
+ // start: once a minute as before, but a per-second readout announces it once; complete: once.
5072
+ " const event = ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete';",
5073
+ " if (event === announced && (event === 'gem:countdown:complete' || hasSecondsCell)) return;",
5074
+ " announced = event;",
5075
+ " api.emit(event, { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });",
5076
+ " };",
5077
+ " const onTheMinute = () => {",
5078
+ " const ms = targetTime - Date.now();",
5079
+ " if (!(ms > 0) || !target.setTimeout) return;",
5080
+ " target.setTimeout(() => { update(); onTheMinute(); }, Math.min((ms % 60000) + 5, ms));",
4543
5081
  " };",
4544
5082
  " update();",
4545
- " if (target.setInterval && Number.isFinite(targetTime) && targetTime - Date.now() > 0) handle = target.setInterval(update, 60000);",
4546
- // The readout moves once a minute, but the END is not allowed to wait for the next minute: one timer
5083
+ " if (Number.isFinite(targetTime) && targetTime - Date.now() > 0) {",
5084
+ " if (!hasSecondsCell) onTheMinute();",
5085
+ " else if (target.setInterval) handle = target.setInterval(update, 1000);",
5086
+ " }",
5087
+ // A minute readout moves once a minute, but the END is not allowed to wait for the next minute: one timer
4547
5088
  // for the exact instant (while it fits a setTimeout, ~24.8 days; past that the minute tick gets there).
4548
5089
  " const left = targetTime - Date.now();",
4549
5090
  " if (target.setTimeout && Number.isFinite(left) && left > 0 && left < 2147483647) target.setTimeout(update, left);",
@@ -4653,6 +5194,39 @@ var offerEnginePluginRegistrationLines = [
4653
5194
  " api.emit('gem:offer:ready', { source: 'gem-runtime' });",
4654
5195
  " });"
4655
5196
  ];
5197
+ var runtimePluginSourceLines = [
5198
+ ["cart-drawer", cartDrawerPluginRegistrationLines],
5199
+ ["mobile-menu", mobileMenuPluginRegistrationLines],
5200
+ ["quantity-input", quantityInputPluginRegistrationLines],
5201
+ ["variant-selects", variantSelectsPluginRegistrationLines],
5202
+ ["predictive-search", predictiveSearchPluginRegistrationLines],
5203
+ ["localization", localizationPluginRegistrationLines],
5204
+ ["disclosure", disclosurePluginRegistrationLines],
5205
+ ["tabs", tabsPluginRegistrationLines],
5206
+ ["media-gallery", mediaGalleryPluginRegistrationLines],
5207
+ ["carousel", carouselPluginRegistrationLines],
5208
+ ["hero-slideshow", heroSlideshowPluginRegistrationLines],
5209
+ ["quick-view", quickViewPluginRegistrationLines],
5210
+ ["promo-popup", promoPopupPluginRegistrationLines],
5211
+ ["sticky-atc", stickyAtcPluginRegistrationLines],
5212
+ ["before_after", beforeAfterPluginRegistrationLines],
5213
+ ["countdown", countdownPluginRegistrationLines],
5214
+ ["reveal-on-scroll", revealOnScrollPluginRegistrationLines],
5215
+ ["recently-viewed", recentlyViewedPluginRegistrationLines],
5216
+ ["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
5217
+ ["offer-engine", offerEnginePluginRegistrationLines]
5218
+ ];
5219
+ var noopPluginRegistrationLine = (key, readyEvent) => ` registerNoopPlugin(${JSON.stringify(key)}, ${JSON.stringify(readyEvent)});`;
5220
+ function gemRuntimePluginSources() {
5221
+ const withBody = runtimePluginSourceLines.map(([key, lines]) => ({
5222
+ key,
5223
+ readyEvent: runtimePluginReadyEvents[key] ?? `gem:${key}:ready`,
5224
+ hasBody: true,
5225
+ lines
5226
+ }));
5227
+ const noop = Object.entries(runtimePluginReadyEvents).filter(([key]) => !runtimePluginSourceLines.some(([sourceKey]) => sourceKey === key)).map(([key, readyEvent]) => ({ key, readyEvent, hasBody: false, lines: [noopPluginRegistrationLine(key, readyEvent)] }));
5228
+ return [...withBody, ...noop];
5229
+ }
4656
5230
  function pushPlugin(plugins, spec) {
4657
5231
  if (plugins.some((plugin) => plugin.key === spec.key)) return;
4658
5232
  plugins.push(spec);
@@ -4711,7 +5285,7 @@ function createHeaderRuntimeGrammar(header) {
4711
5285
  }
4712
5286
  function createGemRuntimeBootstrap(configArg) {
4713
5287
  const config = configArg.plugins.some((plugin) => plugin.key === "hero-slideshow") ? configArg : { ...configArg, plugins: [...configArg.plugins, { key: "hero-slideshow", loadStrategy: "idle" }] };
4714
- const pluginRegistrationLines = [...new Set(config.plugins.map((plugin) => plugin.key))].filter((key) => key !== "cart-drawer").filter((key) => key !== "mobile-menu").filter((key) => key !== "quantity-input").filter((key) => key !== "variant-selects").filter((key) => key !== "predictive-search").filter((key) => key !== "localization").filter((key) => key !== "disclosure" && key !== "tabs" && key !== "media-gallery" && key !== "carousel" && key !== "quick-view" && key !== "sticky-atc").filter((key) => key !== "promo-popup").filter((key) => key !== "before_after").filter((key) => key !== "countdown").filter((key) => key !== "reveal-on-scroll").filter((key) => key !== "recently-viewed").filter((key) => key !== "runtime-slot-loader").filter((key) => key !== "offer-engine").filter((key) => runtimePluginReadyEvents[key]).map((key) => ` registerNoopPlugin(${JSON.stringify(key)}, ${JSON.stringify(runtimePluginReadyEvents[key])});`);
5288
+ const pluginRegistrationLines = [...new Set(config.plugins.map((plugin) => plugin.key))].filter((key) => !runtimePluginSourceLines.some(([sourceKey]) => sourceKey === key)).filter((key) => runtimePluginReadyEvents[key]).map((key) => noopPluginRegistrationLine(key, runtimePluginReadyEvents[key]));
4715
5289
  const enabledPluginKeys = new Set(config.plugins.map((plugin) => plugin.key));
4716
5290
  return [
4717
5291
  "(() => {",
@@ -4784,28 +5358,11 @@ function createGemRuntimeBootstrap(configArg) {
4784
5358
  " if (readyEvent) api.emit(readyEvent, { source: 'gem-runtime' });",
4785
5359
  " });",
4786
5360
  " };",
4787
- ...enabledPluginKeys.has("cart-drawer") ? cartDrawerPluginRegistrationLines : [],
4788
- ...enabledPluginKeys.has("mobile-menu") ? mobileMenuPluginRegistrationLines : [],
4789
- ...enabledPluginKeys.has("quantity-input") ? quantityInputPluginRegistrationLines : [],
4790
- ...enabledPluginKeys.has("variant-selects") ? variantSelectsPluginRegistrationLines : [],
4791
- ...enabledPluginKeys.has("predictive-search") ? predictiveSearchPluginRegistrationLines : [],
4792
- ...enabledPluginKeys.has("localization") ? localizationPluginRegistrationLines : [],
4793
- ...enabledPluginKeys.has("disclosure") ? disclosurePluginRegistrationLines : [],
4794
- ...enabledPluginKeys.has("tabs") ? tabsPluginRegistrationLines : [],
4795
- ...enabledPluginKeys.has("media-gallery") ? mediaGalleryPluginRegistrationLines : [],
4796
- ...enabledPluginKeys.has("carousel") ? carouselPluginRegistrationLines : [],
4797
- // hero-slideshow is always scheduled (added to config above) + self-guards, so always register it.
4798
- ...enabledPluginKeys.has("hero-slideshow") ? heroSlideshowPluginRegistrationLines : [],
4799
- ...enabledPluginKeys.has("quick-view") ? quickViewPluginRegistrationLines : [],
4800
- ...enabledPluginKeys.has("promo-popup") ? promoPopupPluginRegistrationLines : [],
4801
- ...enabledPluginKeys.has("sticky-atc") ? stickyAtcPluginRegistrationLines : [],
4802
- ...enabledPluginKeys.has("before_after") ? beforeAfterPluginRegistrationLines : [],
4803
- ...enabledPluginKeys.has("countdown") ? countdownPluginRegistrationLines : [],
4804
- ...enabledPluginKeys.has("reveal-on-scroll") ? revealOnScrollPluginRegistrationLines : [],
4805
- ...enabledPluginKeys.has("recently-viewed") ? recentlyViewedPluginRegistrationLines : [],
4806
- ...enabledPluginKeys.has("runtime-slot-loader") ? runtimeSlotLoaderPluginRegistrationLines : [],
4807
- ...enabledPluginKeys.has("offer-engine") ? offerEnginePluginRegistrationLines : [],
5361
+ ...runtimePluginSourceLines.flatMap(([key, lines]) => enabledPluginKeys.has(key) ? lines : []),
4808
5362
  ...pluginRegistrationLines,
5363
+ // `data-gem-bound-<key>` is a CONTRACT, not private bookkeeping: markup may carry it to claim an element for its
5364
+ // own behavior (promo-popup.email-capture.v1 renders data-gem-bound-promo-popup="section"). Any value counts as
5365
+ // bound; runtime-core.js mark() keeps the same rule, and its unmount removes only the marks it set.
4809
5366
  " const mark = (element, key) => {",
4810
5367
  " const attr = 'data-gem-bound-' + key;",
4811
5368
  " if (!element || element.hasAttribute(attr)) return false;",
@@ -4880,6 +5437,63 @@ function createGemRuntimeBootstrap(configArg) {
4880
5437
  ].join("\n");
4881
5438
  }
4882
5439
 
5440
+ // ../../packages/gem-framework/src/runtime-modules.ts
5441
+ var LISTEN_CALL = /(?<![\w$.\])?])([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\.addEventListener\(/g;
5442
+ function routeListenersThroughLedger(body, key = "plugin") {
5443
+ const routed = body.replace(LISTEN_CALL, "__gemListen($1, ");
5444
+ const unseen = routed.match(/.{0,40}\.addEventListener\(/);
5445
+ if (unseen) throw new Error(`gem-framework: plugin "${key}" adds a listener the module cannot track: ${unseen[0]}`);
5446
+ return routed;
5447
+ }
5448
+ var PLUGIN_GUARDS = {
5449
+ "offer-engine": { selector: "[data-gem-offer-engine]", prop: "__gemOffer" }
5450
+ };
5451
+ function gemRuntimePluginModuleKeys() {
5452
+ return gemRuntimePluginSources().map((source) => source.key);
5453
+ }
5454
+ function createGemRuntimePluginModule(key) {
5455
+ const source = gemRuntimePluginSources().find((candidate) => candidate.key === key);
5456
+ if (!source) throw new Error(`gem-framework: no runtime plugin "${key}"`);
5457
+ const body = routeListenersThroughLedger(source.lines.join("\n"), key);
5458
+ const guard = PLUGIN_GUARDS[key];
5459
+ return [
5460
+ `// @gemboss/gem-framework/plugins/${key}: the gem-runtime plugin "${key}" as an ES module. Generated from`,
5461
+ "// packages/gem-framework/src/runtime.ts, the same lines gem-runtime.js carries; do not edit here.",
5462
+ 'import { mountPlugin } from "../runtime-core.js";',
5463
+ "",
5464
+ `export const key = ${JSON.stringify(key)};`,
5465
+ `export const readyEvent = ${JSON.stringify(source.readyEvent)};`,
5466
+ "",
5467
+ "function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {",
5468
+ body,
5469
+ "}",
5470
+ "",
5471
+ `/** Bind every "${key}" hook inside root (default: the whole document). Returns unmount(). Mounting the same root twice binds once. */`,
5472
+ "export function mount(root, options) {",
5473
+ ` return mountPlugin(key, install, root, options${guard ? `, ${JSON.stringify(guard)}` : ""});`,
5474
+ "}",
5475
+ "",
5476
+ "export default mount;",
5477
+ ""
5478
+ ].join("\n");
5479
+ }
5480
+ function createGemRuntimePluginModuleTypes(key) {
5481
+ const source = gemRuntimePluginSources().find((candidate) => candidate.key === key);
5482
+ if (!source) throw new Error(`gem-framework: no runtime plugin "${key}"`);
5483
+ return [
5484
+ `// @gemboss/gem-framework/plugins/${key}: types of the ES module next to this file. Generated; do not edit here.`,
5485
+ 'import type { MountOptions, Unmount } from "../runtime-core.js";',
5486
+ "",
5487
+ "export type { MountOptions, Unmount };",
5488
+ `export declare const key: ${JSON.stringify(key)};`,
5489
+ `export declare const readyEvent: ${JSON.stringify(source.readyEvent)};`,
5490
+ `/** Bind every "${key}" hook inside root (default: the whole document). Returns unmount(). Mounting the same root twice binds once. */`,
5491
+ "export declare function mount(root?: ParentNode | null, options?: MountOptions): Unmount;",
5492
+ "export default mount;",
5493
+ ""
5494
+ ].join("\n");
5495
+ }
5496
+
4883
5497
  // ../../packages/gem-framework/src/validation.ts
4884
5498
  var validationRulesByCode = new Map(gemFrameworkValidationRules.map((rule) => [rule.code, rule]));
4885
5499
  var primitiveClassNames = new Set(
@@ -4905,7 +5519,9 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
4905
5519
  "--gem-icon-size",
4906
5520
  "--gem-drawer-offset",
4907
5521
  // The row primitive's variables (primitives.ts GEM_ROW_CSS). Sections SET these on their grid.
4908
- ...GEM_ROW_CSS_VARIABLES
5522
+ ...GEM_ROW_CSS_VARIABLES,
5523
+ // The form set's variables (form-set.ts): set from a plan's `tokens.form`, read by fields under [data-gem-form-set].
5524
+ ...GEM_FORM_CSS_VARIABLES
4909
5525
  ]);
4910
5526
  var TOKENIZED_PROPERTIES = [
4911
5527
  "background",
@@ -5127,6 +5743,7 @@ function renderGemPreviewDocument(plan, options = {}) {
5127
5743
  const footer = renderGemShellFooter(plan);
5128
5744
  const sectionsHtml = renderGemPageSections(plan, page, options.sectionMarkup ?? {});
5129
5745
  const title = options.title ?? `${plan.name} preview`;
5746
+ const formAttrs = Object.entries(gemFormAttributes(plan.tokens.form)).map(([k, v]) => ` ${k}="${escapeAttribute(v)}"`).join("");
5130
5747
  const bodyClass = ["gem-theme", `gem-theme--${slugify(profile)}`, options.bodyClassName].filter(Boolean).join(" ");
5131
5748
  return [
5132
5749
  "<!doctype html>",
@@ -5137,7 +5754,7 @@ function renderGemPreviewDocument(plan, options = {}) {
5137
5754
  ` <title>${escapeHtml(title)}</title>`,
5138
5755
  ` <style>${css}</style>`,
5139
5756
  "</head>",
5140
- `<body class="${escapeAttribute(bodyClass)}" data-gem-plan-id="${escapeAttribute(plan.id)}" data-gem-style-pack-id="${escapeAttribute(plan.style_pack?.id ?? "none")}" data-gem-button-style="${escapeAttribute(plan.global_settings?.button_style ?? "solid")}" data-gem-preview-page="${escapeAttribute(page)}">`,
5757
+ `<body class="${escapeAttribute(bodyClass)}" data-gem-plan-id="${escapeAttribute(plan.id)}" data-gem-style-pack-id="${escapeAttribute(plan.style_pack?.id ?? "none")}" data-gem-button-style="${escapeAttribute(plan.global_settings?.button_style ?? "solid")}" data-gem-preview-page="${escapeAttribute(page)}"${formAttrs}>`,
5141
5758
  ' <a class="gem-skip-to-content-link gem-u-visually-hidden" href="#MainContent">Skip to content</a>',
5142
5759
  header,
5143
5760
  ` <main id="MainContent" class="gem-main" data-gem-page="${escapeAttribute(page)}">`,
@@ -6187,8 +6804,17 @@ export {
6187
6804
  COUNTDOWN_END_RUNTIME_LINES,
6188
6805
  GEM_AMBIENT_CSS,
6189
6806
  GEM_COUNTDOWN_ENDED_CSS,
6807
+ GEM_FIELD_JOINS,
6808
+ GEM_FIELD_SHAPES,
6809
+ GEM_FORM_CSS_VARIABLES,
6810
+ GEM_FORM_DEFAULTS,
6811
+ GEM_FORM_SETS,
6812
+ GEM_FORM_SET_CSS,
6813
+ GEM_FORM_TOKEN_KEYS,
6814
+ GEM_FORM_VAR_SCOPE,
6190
6815
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
6191
6816
  GEM_PART_CLASS,
6817
+ GEM_POPUP_SHELL_CSS,
6192
6818
  GEM_REVEAL_CSS,
6193
6819
  GEM_ROW_CSS,
6194
6820
  GEM_ROW_CSS_VARIABLES,
@@ -6202,9 +6828,12 @@ export {
6202
6828
  createGemBaseCss,
6203
6829
  createGemFrameworkManifest,
6204
6830
  createGemRuntimeBootstrap,
6831
+ createGemRuntimePluginModule,
6832
+ createGemRuntimePluginModuleTypes,
6205
6833
  createGemSectionRuntimeContractCss,
6206
6834
  createGemStyleKnobCss,
6207
6835
  createGemThemeCss,
6836
+ createGemThemeCssParts,
6208
6837
  createGemTokenCss as createGemTokenVariablesCss,
6209
6838
  createHeaderRuntimeGrammar,
6210
6839
  createMorphProfileCss,
@@ -6215,12 +6844,18 @@ export {
6215
6844
  createThemeProfileCss,
6216
6845
  defaultGemTokens,
6217
6846
  defaultSelection,
6847
+ gemFormAttributes,
6848
+ gemFormCssBlock,
6849
+ gemFormCssVars,
6850
+ gemFormSetsFor,
6218
6851
  gemFrameworkPrimitiveGroups,
6219
6852
  gemFrameworkRuntimePlugins,
6220
6853
  gemFrameworkValidationRules,
6221
6854
  gemFrameworkVersion,
6222
6855
  gemPartClass,
6223
6856
  gemPlanShapeKnobs,
6857
+ gemRuntimePluginModuleKeys,
6858
+ gemRuntimePluginSources,
6224
6859
  getGemFrameworkValidationRule,
6225
6860
  isModernMineralFamilyLineagePlan,
6226
6861
  modernMineralFamilyLineage,