@gemboss/gem-framework 0.6.0 → 0.9.2

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 +69 -1
  2. package/dist/SOURCE.json +2 -2
  3. package/dist/gem-framework.css +206 -147
  4. package/dist/gem-runtime.js +101 -16
  5. package/dist/index.js +637 -179
  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 +44 -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 +315 -86
  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"];
@@ -496,15 +613,6 @@ var gemFrameworkPrimitiveGroups = [
496
613
  "gem-delivery-date",
497
614
  "gem-stock-indicator",
498
615
  "gem-popup",
499
- // Popup shell (primitives.ts GEM_POPUP_SHELL_CSS): the dialog frame popup sections draw their content in.
500
- "gem-popup-shell",
501
- "gem-popup-shell__dialog",
502
- "gem-popup-shell__frame",
503
- "gem-popup-shell__media",
504
- "gem-popup-shell__image",
505
- "gem-popup-shell__panel",
506
- "gem-popup-shell__close",
507
- "gem-popup-shell__teaser",
508
616
  "gem-back-to-top",
509
617
  // measured commerce atoms mapped by parts.ts (GEM_PART_CLASS) alongside gem-price/gem-badge.
510
618
  "gem-discount",
@@ -531,6 +639,22 @@ var gemFrameworkPrimitiveGroups = [
531
639
  "gem-cart-line-item__price"
532
640
  ]
533
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
+ },
534
658
  {
535
659
  group: "sections",
536
660
  classes: [
@@ -772,58 +896,7 @@ function createGemFrameworkManifest() {
772
896
  };
773
897
  }
774
898
 
775
- // ../../packages/gem-framework/src/primitives.ts
776
- 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")`;
777
- var spikePath = (() => {
778
- const teeth = [];
779
- for (let x = 95; x > 0; x -= 5) teeth.push(`L${x} ${teeth.length % 2 === 0 ? 10 : 3}`);
780
- return `M0 0 H100 V3 ${teeth.join(" ")} L0 ${teeth.length % 2 === 0 ? 10 : 3} Z`;
781
- })();
782
- var DIVIDER_SHAPES = {
783
- wave: "M0 0 H100 V3 C80 9 70 -1 50 4 C30 9 20 1 0 4 Z",
784
- slope: "M0 0 H100 V10 Z",
785
- spikes: spikePath,
786
- arch: "M0 0 H100 V2 Q50 12 0 2 Z",
787
- blob: "M0 0 H100 V4 C85 11 72 0 58 5 C44 10 30 2 16 6 C10 8 5 5 0 6 Z"
788
- };
789
- var GEM_DIVIDER_CSS_LINES = [
790
- // The clip is WIDENED, not removed. `.gem-section` sets `overflow: clip` and the wedge is drawn
791
- // at `top: 100%` / `bottom: 100%` — entirely outside the box — so the parent deleted every
792
- // divider on every track. Measured on the live storefront 2026-08-25: a band with the documented
793
- // `data-gem-section-background="dark"` + `data-gem-divider-bottom="wave"` rendered a FLAT edge,
794
- // while the attribute, the rule and the fallback colour were all present and correct.
795
- // NOT `overflow: visible`: `.gem-hero` uses the clip to hold its own border-radius, so releasing
796
- // it entirely would paint square corners over that radius. A browser without
797
- // `overflow-clip-margin` keeps plain `clip` — the divider stays hidden, which is today's
798
- // behaviour rather than a broken layout.
799
- ":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); }",
800
- // Shared geometry. `top: 100%` / `bottom: 100%` put it outside the box; z-index 1 keeps it above the
801
- // neighbouring band's background. pointer-events: none so it never eats a click near the seam.
802
- // Qualified by SHAPE. Unqualified, any value paints a solid 48px slab — including the literal
803
- // "none", which the attribute writer forwards unchanged. The clip used to hide that; widening
804
- // it above makes a stray value visible.
805
- ":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%; }",
806
- ":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%; }",
807
- // The top edge reuses the same masks, mirrored, so five shapes cover both edges.
808
- ":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); }",
809
- ...Object.entries(DIVIDER_SHAPES).flatMap(([name, path]) => {
810
- const mask = dividerMask(path);
811
- return [
812
- `: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}; }`,
813
- `: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}; }`
814
- ];
815
- })
816
- ];
817
- var GEM_DIVIDER_CSS = GEM_DIVIDER_CSS_LINES.join("\n");
818
- var GEM_CARD_CSS_LINES = [
819
- ":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); }",
820
- ":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); }",
821
- ":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); }",
822
- // `filled` tints the section's own ground rather than reaching for a fixed colour, so it stays
823
- // legible on an accent or dark band instead of punching a light panel into it.
824
- ":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); }"
825
- ];
826
- var GEM_CARD_CSS = GEM_CARD_CSS_LINES.join("\n");
899
+ // ../../packages/gem-framework/src/popup-shell.ts
827
900
  var GEM_POPUP_SHELL_CSS_LINES = [
828
901
  ".gem-popup-shell { padding: 0; }",
829
902
  ".gem-popup-shell__dialog {",
@@ -843,8 +916,12 @@ var GEM_POPUP_SHELL_CSS_LINES = [
843
916
  ".gem-popup-shell[data-gem-popup-desktop='split-right'] .gem-popup-shell__media { order: 2; }",
844
917
  ".gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__frame,",
845
918
  ".gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__frame { grid-template-columns: 1fr; }",
846
- ".gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__dialog,",
847
- ".gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__dialog { width: min(480px, 92vw); }",
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
+ "}",
848
925
  ".gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__media { display: none; }",
849
926
  ".gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__media { max-height: 200px; }",
850
927
  ".gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__frame { grid-template-columns: 1fr; place-items: center; }",
@@ -883,6 +960,12 @@ var GEM_POPUP_SHELL_CSS_LINES = [
883
960
  " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'] .gem-popup-shell__media { position: absolute; inset: 0; }",
884
961
  " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'] .gem-popup-shell__panel { position: relative; z-index: 1; }",
885
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; }",
886
969
  "}",
887
970
  "@media (max-width: 749px) {",
888
971
  // Mobile: takeover is the whole screen; a sheet rises from the bottom to at most 88% of it. The image
@@ -894,7 +977,9 @@ var GEM_POPUP_SHELL_CSS_LINES = [
894
977
  " .gem-popup-shell__panel { justify-content: flex-start; padding: var(--gem-space-3xl) var(--gem-space-lg) var(--gem-space-xl); }",
895
978
  " .gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__panel,",
896
979
  " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__panel { justify-content: center; }",
897
- " .gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__panel { width: 100%; margin: 0; border-radius: 0; box-shadow: none; }",
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; }",
898
983
  " .gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__media { position: relative; }",
899
984
  " .gem-popup-shell__media { order: 2; max-height: 34dvh; }",
900
985
  " .gem-popup-shell[data-gem-popup-media='top'] .gem-popup-shell__media { order: 0; }",
@@ -903,20 +988,160 @@ var GEM_POPUP_SHELL_CSS_LINES = [
903
988
  " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__frame { grid-template-rows: 1fr; }",
904
989
  " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media { position: absolute; inset: 0; max-height: none; }",
905
990
  "}",
906
- ".gem-popup-shell[data-gem-popup-preview='true'] { padding: var(--gem-space-xl) var(--gem-layout-gutter); background: rgb(0 0 0 / 0.55); }",
907
- ".gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: relative; display: block; inset: auto; }",
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); }",
908
1002
  "@media (min-width: 750px) {",
909
- " .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] { padding: 0; }",
910
- " .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] .gem-popup-shell__dialog { height: 720px; max-height: none; }",
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; }",
911
1005
  "}",
912
1006
  "@media (max-width: 749px) {",
913
- " .gem-popup-shell[data-gem-popup-preview='true'] { padding: 0; }",
914
- " .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { height: 844px; max-height: none; width: 100%; max-width: 100%; }",
915
- " .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-mobile='sheet'] { padding-top: 160px; }",
916
- " .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-mobile='sheet'] .gem-popup-shell__dialog { min-height: 0; }",
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; }",
917
1011
  "}"
918
1012
  ];
919
1013
  var GEM_POPUP_SHELL_CSS = GEM_POPUP_SHELL_CSS_LINES.join("\n");
1014
+
1015
+ // ../../packages/gem-framework/src/primitives.ts
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")`;
1017
+ var spikePath = (() => {
1018
+ const teeth = [];
1019
+ for (let x = 95; x > 0; x -= 5) teeth.push(`L${x} ${teeth.length % 2 === 0 ? 10 : 3}`);
1020
+ return `M0 0 H100 V3 ${teeth.join(" ")} L0 ${teeth.length % 2 === 0 ? 10 : 3} Z`;
1021
+ })();
1022
+ var DIVIDER_SHAPES = {
1023
+ wave: "M0 0 H100 V3 C80 9 70 -1 50 4 C30 9 20 1 0 4 Z",
1024
+ slope: "M0 0 H100 V10 Z",
1025
+ spikes: spikePath,
1026
+ arch: "M0 0 H100 V2 Q50 12 0 2 Z",
1027
+ blob: "M0 0 H100 V4 C85 11 72 0 58 5 C44 10 30 2 16 6 C10 8 5 5 0 6 Z"
1028
+ };
1029
+ var GEM_DIVIDER_CSS_LINES = [
1030
+ // The clip is WIDENED, not removed. `.gem-section` sets `overflow: clip` and the wedge is drawn
1031
+ // at `top: 100%` / `bottom: 100%` — entirely outside the box — so the parent deleted every
1032
+ // divider on every track. Measured on the live storefront 2026-08-25: a band with the documented
1033
+ // `data-gem-section-background="dark"` + `data-gem-divider-bottom="wave"` rendered a FLAT edge,
1034
+ // while the attribute, the rule and the fallback colour were all present and correct.
1035
+ // NOT `overflow: visible`: `.gem-hero` uses the clip to hold its own border-radius, so releasing
1036
+ // it entirely would paint square corners over that radius. A browser without
1037
+ // `overflow-clip-margin` keeps plain `clip` — the divider stays hidden, which is today's
1038
+ // behaviour rather than a broken layout.
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); }",
1040
+ // Shared geometry. `top: 100%` / `bottom: 100%` put it outside the box; z-index 1 keeps it above the
1041
+ // neighbouring band's background. pointer-events: none so it never eats a click near the seam.
1042
+ // Qualified by SHAPE. Unqualified, any value paints a solid 48px slab — including the literal
1043
+ // "none", which the attribute writer forwards unchanged. The clip used to hide that; widening
1044
+ // it above makes a stray value visible.
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%; }",
1047
+ // The top edge reuses the same masks, mirrored, so five shapes cover both edges.
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); }",
1049
+ ...Object.entries(DIVIDER_SHAPES).flatMap(([name, path]) => {
1050
+ const mask = dividerMask(path);
1051
+ return [
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}; }`
1054
+ ];
1055
+ })
1056
+ ];
1057
+ var GEM_DIVIDER_CSS = GEM_DIVIDER_CSS_LINES.join("\n");
1058
+ var GEM_CARD_CSS_LINES = [
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); }",
1062
+ // `filled` tints the section's own ground rather than reaching for a fixed colour, so it stays
1063
+ // legible on an accent or dark band instead of punching a light panel into it.
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); }"
1065
+ ];
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");
920
1145
  var GEM_COUNTDOWN_ENDED_CSS_LINES = [
921
1146
  ":root:not(:has([data-gem-canvas])) [data-gem-section-id][data-gem-countdown-ended='true'] { display: none; }",
922
1147
  ":root:has([data-gem-canvas]) [data-gem-section-id][data-gem-countdown-ended='true'] { opacity: .55; }",
@@ -979,12 +1204,12 @@ var GEM_ROW_CSS_LINES = [
979
1204
  var GEM_ROW_GAP_CSS = GEM_ROW_GAP_CSS_LINES.join("\n");
980
1205
  var GEM_ROW_CSS = GEM_ROW_CSS_LINES.join("\n");
981
1206
  var GEM_GRID_COLUMNS_KNOB_CSS_LINES = [
982
- ":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)); }",
983
- ":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; }",
984
- ":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)); }",
985
- ":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; }",
986
- ":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)); }",
987
- ":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; }"
988
1213
  ];
989
1214
  var GEM_GRID_COLUMNS_ROW_KNOB_CSS = GEM_GRID_COLUMNS_KNOB_CSS_LINES.filter((line) => line.includes(" .gem-row:is(")).join("\n");
990
1215
  function createGemStyleKnobCss(options = {}, selector = ":root") {
@@ -1001,20 +1226,34 @@ function createGemStyleKnobCss(options = {}, selector = ":root") {
1001
1226
  });
1002
1227
  }
1003
1228
  function createGemThemeCss(tokens, selector = ":root", options = {}) {
1004
- return [
1005
- createGemTokenCss(tokens, selector),
1006
- // Set the knobs the space / radius / border / type ramps read (only when they differ from the
1007
- // default 1, so a standard-shaped storefront emits nothing extra).
1008
- ...createGemStyleKnobCss(options, selector),
1009
- "",
1010
- createGemBaseCss(options.profile ?? "dawn-parity")
1011
- ].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
+ };
1012
1247
  }
1013
1248
  function createSectionClassName(section) {
1014
1249
  return `gem-section gem-${slugify(section.archetype)} gem-${slugify(section.archetype)}--${slugify(section.variant)}`;
1015
1250
  }
1016
1251
  function createGemBaseCss(profile = "dawn-parity") {
1017
- return [
1252
+ const parts = createGemBaseCssParts(profile);
1253
+ return parts.primitives + parts.sections;
1254
+ }
1255
+ function createGemBaseCssParts(profile) {
1256
+ const reset = [
1018
1257
  "*, *::before, *::after {",
1019
1258
  " box-sizing: border-box;",
1020
1259
  "}",
@@ -1089,16 +1328,12 @@ function createGemBaseCss(profile = "dawn-parity") {
1089
1328
  " overflow: hidden !important;",
1090
1329
  " clip: rect(0, 0, 0, 0) !important;",
1091
1330
  " white-space: nowrap !important;",
1092
- "}",
1093
- "",
1094
- createPrimitiveCss(),
1095
- "",
1096
- createShellCss(),
1097
- "",
1098
- createSectionCss(),
1099
- "",
1100
- createThemeProfileCss(profile)
1331
+ "}"
1101
1332
  ].join("\n");
1333
+ return {
1334
+ primitives: [reset, "", createPrimitiveCss(), "", ""].join("\n"),
1335
+ sections: [createShellCss(), "", createSectionCss(), "", createThemeProfileCss(profile)].join("\n")
1336
+ };
1102
1337
  }
1103
1338
  function createThemeProfileCss(profile = "dawn-parity") {
1104
1339
  return profile === "dawn-parity" ? createDawnParityCss() : createMorphProfileCss();
@@ -1338,6 +1573,7 @@ function createPrimitiveCss() {
1338
1573
  " block-size: var(--gem-space-lg);",
1339
1574
  " accent-color: var(--gem-color-accent);",
1340
1575
  "}",
1576
+ ...GEM_FORM_SET_CSS_LINES,
1341
1577
  ".gem-submit {",
1342
1578
  " cursor: pointer;",
1343
1579
  "}",
@@ -2092,7 +2328,6 @@ function createPrimitiveCss() {
2092
2328
  ...GEM_DIVIDER_CSS_LINES,
2093
2329
  ...GEM_CARD_CSS_LINES,
2094
2330
  ...GEM_COUNTDOWN_ENDED_CSS_LINES,
2095
- ...GEM_POPUP_SHELL_CSS_LINES,
2096
2331
  ".gem-promo-banner, .gem-promo-tile {",
2097
2332
  " border: var(--gem-border-subtle, 1px solid var(--gem-color-border));",
2098
2333
  " border-radius: var(--gem-radius-md);",
@@ -2634,17 +2869,17 @@ function createSectionCss() {
2634
2869
  // theme + published) and on the sim-preview markup. Grid items justify; text aligns. The text
2635
2870
  // rule also reaches hero copy + section content so heroes honor it (their absolute-positioned
2636
2871
  // copy box keeps its tuned position; only the text inside re-aligns).
2637
- ":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; }",
2638
- ":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; }",
2639
- ":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; }",
2640
- ":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; }",
2641
- ":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; }",
2642
- ":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; }",
2643
2878
  // SAFETY: justify-items above content-sizes the inner grid's items, which would COLLAPSE a
2644
2879
  // full-bleed grid child (a mosaic/collage/product grid that must span the row) to min-content —
2645
2880
  // i.e. the section visually disappears. Re-stretch any grid/media/mosaic direct child so alignment
2646
2881
  // only ever moves TEXT, never breaks a section's own layout (fix: collage vanished in wf_0102).
2647
- ":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%; }",
2648
2883
  // Text-position knob (the VERTICAL sibling of content_alignment) — set by set_layout text_position
2649
2884
  // (gemboss override key gem_text_position), emitted as data-gem-text-position by the lean-hero
2650
2885
  // renderer (shopify-adapter leanHeroLayoutKeys). It had NO CSS anywhere: the agent could pick it,
@@ -2656,16 +2891,16 @@ function createSectionCss() {
2656
2891
  // (0,3,0) beats the component's own `.gem-hero-banner--default .gem-hero-banner__copy` (0,2,0),
2657
2892
  // so this wins only when the operator sets the knob; unset, the tuned default position stands.
2658
2893
  // `bottom`/`top: auto` is required on each: leaving both insets set pins the box to BOTH edges.
2659
- ":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; }",
2660
- ":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%); }",
2661
- ":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; }",
2662
2897
  // Non-hero sections: move the content stack within whatever spare height the section has (a
2663
2898
  // `section_height: tall` section, say). `align-content` is a no-op when the grid has no spare
2664
2899
  // space, so this never disturbs a normally-sized section — it only answers the same vertical
2665
2900
  // question on the sections where the question can even be asked.
2666
- ":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; }",
2667
- ":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; }",
2668
- ":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; }",
2669
2904
  // Media-position knob (section LAYOUT axis) — set by set_layout media_position (gemboss override key
2670
2905
  // gem_media_position). On a genuine two-column section it swaps the image column to the chosen side
2671
2906
  // by re-ordering the media child. TWO shapes qualify: (a) `__media` is a DIRECT child of the inner
@@ -2677,16 +2912,16 @@ function createSectionCss() {
2677
2912
  // __media), so `grid > __media` never matches a card's image → we swap real 2-col layouts and never
2678
2913
  // reorder cards. A full-bleed background-image hero has its `__media` absolutely positioned, so
2679
2914
  // `order` is a natural no-op there — exactly right.
2680
- ":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; }",
2681
- ":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; }",
2682
2917
  // Spacing/density knob — set by set_layout content_spacing. Scales the inner grid gap (the gap
2683
2918
  // between columns + stacked rows), the direct answer to wf_0101's 'khoảng cách giữa 2 cột'.
2684
2919
  // Density knob — set by set_layout content_spacing. Scales the gap of the inner content stack.
2685
2920
  // (Per-card GRID spacing is governed by the tuned card-mode/variant rules — changed via
2686
2921
  // swap_variant, not this knob — so we don't fight their specificity here.)
2687
- ":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); }",
2688
- ":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); }",
2689
- ":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); }",
2690
2925
  // HERO COPY STACK — same rules as gemsite app/globals.css (gemsite #609), kept in sync by hand.
2691
2926
  // On a full-bleed hero the inner grid has ONE in-flow child (the media is absolutely positioned), so the
2692
2927
  // inner `gap` above spaces nothing and the knob was MUTE: probed on the live storefront (gemboss acceptance
@@ -2709,18 +2944,18 @@ function createSectionCss() {
2709
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; }",
2710
2945
  // Width knob — set by set_layout content_width. Overrides the inner container width: full-bleed
2711
2946
  // edge-to-edge, the default contained width, or a narrow reading measure.
2712
- ":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; }",
2713
- ":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; }",
2714
- ":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))); }",
2715
2950
  // Height knob — set by set_layout section_height. Sets min-height on BOTH the section element
2716
2951
  // and its inner: the section-level rule (attr+class = (0,2,0)) beats a component's single-class
2717
2952
  // floor like `.gem-hero-banner--default { min-height: 720px }` (0,1,0), so "make the hero
2718
2953
  // shorter/taller" actually re-sizes the section (the inner-only rule alone left the section floored).
2719
- ":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; }",
2720
- ":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; }",
2721
- ":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; }",
2722
- ":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; }",
2723
- ":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; }",
2724
2959
  // Columns knob — set by set_layout grid_columns. Overrides the grid container's column count. The
2725
2960
  // contract advertises grid_columns for any section with a `columns` setting (layoutCapabilitiesOf),
2726
2961
  // but MOST gem sections name their grid `{section}__grid` (gem-multicolumn__grid, gem-stats__grid,
@@ -2743,25 +2978,25 @@ function createSectionCss() {
2743
2978
  // The shape-divider below paints the section's OWN
2744
2979
  // colour into the neighbouring section, so it needs a single name for "whatever this band is",
2745
2980
  // rather than re-deriving the swatch in every divider rule.
2746
- ":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; }",
2747
- ":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); }",
2748
- ":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); }",
2749
- ":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); }",
2750
- ":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); }",
2751
2986
  // On accent/dark bands, keep muted sub-copy + headings legible against the inverted ground.
2752
- ":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; }",
2753
2988
  // Price-colour knob (set by set_style price_color) — recolours the PRICE text on a product/collection
2754
2989
  // section. red/green are literal sale/savings semantics; accent uses the brand token; default inherits.
2755
- ":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); }",
2756
- ":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; }",
2757
- ":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; }",
2758
2993
  // Text-weight knob (set by set_layout text_weight) — scales the section's --gem-style-weight ramp
2759
2994
  // (which weight-bearing type multiplies) AND sets the heading font-weight directly, so 'bold'
2760
2995
  // emboldens the headings on any section. default/normal inherit.
2761
- ":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; }",
2762
- ":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; }",
2763
- ":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; }",
2764
- ":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; }",
2765
3000
  // G4 — per-element colour (set by set_style element_color {target, colour}). One mechanism colours
2766
3001
  // the heading / eyebrow / badge in a section; swatches map to brand token or literal sale/dark.
2767
3002
  // (price uses data-gem-price-color above.) accent=brand, red/green=sale/savings, dark=text token.
@@ -4493,37 +4728,58 @@ var promoPopupPluginRegistrationLines = [
4493
4728
  " registerPlugin('promo-popup', ({ api }) => {",
4494
4729
  // Only the explicit hook opts in. Every promo-popup section carries the archetype class .gem-promo-popup, and
4495
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).
4496
4733
  " const popups = document.querySelectorAll('[data-gem-promo-popup]');",
4497
4734
  " let bound = 0;",
4735
+ " const shown = [];",
4498
4736
  " const storagePrefix = 'gem:promo-popup-dismissed:';",
4499
4737
  " const now = () => Date.now ? Date.now() : new Date().getTime();",
4500
- " const readDismissedUntil = (key) => {",
4501
- " 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; }",
4502
4746
  " catch { return 0; }",
4503
4747
  " };",
4504
- " const writeDismissedUntil = (key, timestamp) => {",
4748
+ " const writeDismissedAt = (key, timestamp) => {",
4505
4749
  " try { if (target.localStorage) target.localStorage.setItem(storagePrefix + key, String(timestamp)); }",
4506
4750
  " catch { /* storage can be unavailable in private contexts */ }",
4507
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
+ " };",
4508
4761
  " popups.forEach((popup, index) => {",
4509
4762
  " if (!mark(popup, 'promo-popup')) return;",
4510
4763
  " const key = popup.id || popup.getAttribute && popup.getAttribute('data-gem-section-library-id') || String(index);",
4511
4764
  " const delaySeconds = Number(popup.getAttribute && (popup.getAttribute('data-gem-promo-delay') || '0'));",
4512
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;",
4513
4767
  " const dialog = popup.querySelector && popup.querySelector('[data-gem-promo-popup-dialog]');",
4514
4768
  " const overlay = popup.querySelector && popup.querySelector('[data-gem-promo-popup-overlay]');",
4515
- " const open = (reason = 'timer') => {",
4516
- " if (readDismissedUntil(key) > now()) { popup.dataset.gemPromoPopupState = 'dismissed'; return false; }",
4769
+ " const show = (reason) => {",
4517
4770
  " popup.hidden = false;",
4518
4771
  " if (dialog) dialog.hidden = false;",
4519
4772
  " if (overlay) overlay.hidden = false;",
4520
4773
  " popup.dataset.gemPromoPopupState = 'open';",
4521
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);",
4522
4779
  " return true;",
4523
4780
  " };",
4524
4781
  " const close = (reason = 'dismiss') => {",
4525
- " const days = Number.isFinite(frequencyDays) ? Math.max(1, frequencyDays) : 7;",
4526
- " writeDismissedUntil(key, now() + days * 86400000);",
4782
+ " writeDismissedAt(key, now());",
4527
4783
  " if (dialog) dialog.hidden = true;",
4528
4784
  " if (overlay) overlay.hidden = true;",
4529
4785
  " popup.dataset.gemPromoPopupState = 'closed';",
@@ -4536,10 +4792,63 @@ var promoPopupPluginRegistrationLines = [
4536
4792
  " if (canListen(overlay)) overlay.addEventListener('click', () => close('overlay'));",
4537
4793
  " popup.dataset.gemPromoPopupState = 'ready';",
4538
4794
  " const delay = Math.max(0, Number.isFinite(delaySeconds) ? delaySeconds : 0) * 1000;",
4539
- " 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
+ " }",
4540
4845
  " bound += 1;",
4541
4846
  " });",
4542
- " 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')) };",
4543
4852
  " api.emit('gem:promo-popup:ready', { source: 'gem-runtime', count: bound });",
4544
4853
  " });"
4545
4854
  ];
@@ -4555,7 +4864,14 @@ var stickyAtcPluginRegistrationLines = [
4555
4864
  " };",
4556
4865
  " bars.forEach((bar) => {",
4557
4866
  " if (!mark(bar, 'sticky-atc')) return;",
4558
- " 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'));",
4559
4875
  " const update = () => {",
4560
4876
  " const scrollY = Number(target.scrollY || target.pageYOffset || 0);",
4561
4877
  " setState(bar, scrollY >= threshold ? 'visible' : 'hidden');",
@@ -4639,6 +4955,13 @@ var countdownPluginRegistrationLines = [
4639
4955
  " let endAt = 0;",
4640
4956
  " let handle = null;",
4641
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; } };",
4642
4965
  " const detail = () => ({ source: 'gem-runtime', id: timer.id || undefined, mode: 'duration', duration: total, remainingSeconds: Math.max(0, Math.ceil(left / 1000)) });",
4643
4966
  " const render = () => {",
4644
4967
  " const s = Math.max(0, Math.ceil(left / 1000));",
@@ -4654,6 +4977,7 @@ var countdownPluginRegistrationLines = [
4654
4977
  " const halt = () => { if (handle !== null && target.clearInterval) target.clearInterval(handle); handle = null; };",
4655
4978
  " const finish = () => {",
4656
4979
  " halt();",
4980
+ " release();",
4657
4981
  " left = 0;",
4658
4982
  " state = 'complete';",
4659
4983
  " render();",
@@ -4672,20 +4996,20 @@ var countdownPluginRegistrationLines = [
4672
4996
  " endAt = Date.now() + left;",
4673
4997
  " render();",
4674
4998
  " if (target.setInterval) handle = target.setInterval(tick, 250);",
4675
- " api.emit('gem:countdown:start', detail());",
4999
+ " if (!started) { started = true; api.emit('gem:countdown:start', detail()); }",
4676
5000
  " };",
4677
5001
  " const pause = (next) => {",
4678
5002
  " if (state === 'running') { left = Math.max(0, endAt - Date.now()); halt(); }",
4679
5003
  " if (state === 'complete' || state === 'stopped') return;",
4680
5004
  " state = next;",
5005
+ " if (next === 'stopped') release();",
4681
5006
  " render();",
4682
5007
  " };",
4683
5008
  " render();",
4684
5009
  " if (stopEvent && canListen(scope)) scope.addEventListener(stopEvent, () => pause('stopped'));",
4685
5010
  " if (mode === 'visible' && target.IntersectionObserver) {",
4686
- " const observer = new target.IntersectionObserver((entries) => {",
5011
+ " observer = new target.IntersectionObserver((entries) => {",
4687
5012
  " for (const entry of entries) entry.isIntersecting ? run() : pause('paused');",
4688
- " if (state === 'complete' || state === 'stopped') observer.disconnect();",
4689
5013
  " });",
4690
5014
  " observer.observe(timer);",
4691
5015
  " } else if (mode === 'mount' || mode === 'visible') run();",
@@ -4702,7 +5026,18 @@ var countdownPluginRegistrationLines = [
4702
5026
  " const targetTime = countdownEnd(timer.getAttribute && timer.getAttribute('data-gem-countdown-target'));",
4703
5027
  " const section = timer.closest ? timer.closest('[data-gem-section-id]') : null;",
4704
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]')));",
4705
5039
  " let handle = null;",
5040
+ " let announced = '';",
4706
5041
  " const update = () => {",
4707
5042
  " if (!Number.isFinite(targetTime)) {",
4708
5043
  " timer.dataset.gemCountdownState = 'invalid';",
@@ -4713,20 +5048,43 @@ var countdownPluginRegistrationLines = [
4713
5048
  " const days = Math.floor(remainingSeconds / 86400);",
4714
5049
  " const hours = Math.floor((remainingSeconds % 86400) / 3600);",
4715
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.
4716
5056
  " timer.dataset.gemCountdownState = ms > 0 ? 'running' : 'complete';",
4717
5057
  " timer.dataset.gemCountdownRemainingSeconds = String(remainingSeconds);",
4718
5058
  " write(timer, '[data-gem-countdown-days]', days);",
4719
5059
  " write(timer, '[data-gem-countdown-hours]', hours);",
4720
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);",
4721
5066
  " if (ms <= 0) {",
4722
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');",
4723
5069
  " if (handle !== null && target.clearInterval) { target.clearInterval(handle); handle = null; }",
4724
5070
  " }",
4725
- " 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));",
4726
5081
  " };",
4727
5082
  " update();",
4728
- " if (target.setInterval && Number.isFinite(targetTime) && targetTime - Date.now() > 0) handle = target.setInterval(update, 60000);",
4729
- // 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
4730
5088
  // for the exact instant (while it fits a setTimeout, ~24.8 days; past that the minute tick gets there).
4731
5089
  " const left = targetTime - Date.now();",
4732
5090
  " if (target.setTimeout && Number.isFinite(left) && left > 0 && left < 2147483647) target.setTimeout(update, left);",
@@ -4740,7 +5098,13 @@ var revealOnScrollPluginRegistrationLines = [
4740
5098
  " const nodes = document.querySelectorAll('[data-gem-reveal], .gem-reveal');",
4741
5099
  " let bound = 0;",
4742
5100
  " const reveal = (node) => { node.dataset.gemRevealState = 'in'; };",
4743
- " if (!target.IntersectionObserver) { nodes.forEach(reveal); api.emit('gem:reveal:ready', { source: 'gem-runtime', count: 0 }); return; }",
5101
+ " // Reduced motion and no IntersectionObserver land on the same answer: show everything now (from gemsite revealOnScroll.js).",
5102
+ " const reduce = typeof target.matchMedia === 'function' && target.matchMedia('(prefers-reduced-motion: reduce)').matches;",
5103
+ " if (reduce || !target.IntersectionObserver) { nodes.forEach(reveal); api.emit('gem:reveal:ready', { source: 'gem-runtime', count: 0 }); return; }",
5104
+ " // Above the fold the browser has already painted the section: writing 'ready' would blink it out and back in.",
5105
+ ' // `none` and `default` are the knob\'s "apply nothing" values; the attribute is still written, so skip them here.',
5106
+ " const off = (node) => { const v = node.getAttribute ? node.getAttribute('data-gem-reveal') : null; return v === 'none' || v === 'default'; };",
5107
+ " const inViewport = (node) => { if (!node.getBoundingClientRect) return true; const r = node.getBoundingClientRect(); const h = target.innerHeight || (document.documentElement && document.documentElement.clientHeight) || 0; return r.top < h && r.bottom > 0; };",
4744
5108
  " const observer = new target.IntersectionObserver((entries, obs) => {",
4745
5109
  " entries.forEach((entry) => {",
4746
5110
  " if (!entry.isIntersecting) return;",
@@ -4751,6 +5115,7 @@ var revealOnScrollPluginRegistrationLines = [
4751
5115
  " }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' });",
4752
5116
  " nodes.forEach((node) => {",
4753
5117
  " if (!mark(node, 'reveal-on-scroll')) return;",
5118
+ " if (off(node) || inViewport(node)) { reveal(node); bound += 1; return; }",
4754
5119
  " if (!node.dataset.gemRevealState) node.dataset.gemRevealState = 'ready';",
4755
5120
  " observer.observe(node);",
4756
5121
  " bound += 1;",
@@ -4836,6 +5201,39 @@ var offerEnginePluginRegistrationLines = [
4836
5201
  " api.emit('gem:offer:ready', { source: 'gem-runtime' });",
4837
5202
  " });"
4838
5203
  ];
5204
+ var runtimePluginSourceLines = [
5205
+ ["cart-drawer", cartDrawerPluginRegistrationLines],
5206
+ ["mobile-menu", mobileMenuPluginRegistrationLines],
5207
+ ["quantity-input", quantityInputPluginRegistrationLines],
5208
+ ["variant-selects", variantSelectsPluginRegistrationLines],
5209
+ ["predictive-search", predictiveSearchPluginRegistrationLines],
5210
+ ["localization", localizationPluginRegistrationLines],
5211
+ ["disclosure", disclosurePluginRegistrationLines],
5212
+ ["tabs", tabsPluginRegistrationLines],
5213
+ ["media-gallery", mediaGalleryPluginRegistrationLines],
5214
+ ["carousel", carouselPluginRegistrationLines],
5215
+ ["hero-slideshow", heroSlideshowPluginRegistrationLines],
5216
+ ["quick-view", quickViewPluginRegistrationLines],
5217
+ ["promo-popup", promoPopupPluginRegistrationLines],
5218
+ ["sticky-atc", stickyAtcPluginRegistrationLines],
5219
+ ["before_after", beforeAfterPluginRegistrationLines],
5220
+ ["countdown", countdownPluginRegistrationLines],
5221
+ ["reveal-on-scroll", revealOnScrollPluginRegistrationLines],
5222
+ ["recently-viewed", recentlyViewedPluginRegistrationLines],
5223
+ ["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
5224
+ ["offer-engine", offerEnginePluginRegistrationLines]
5225
+ ];
5226
+ var noopPluginRegistrationLine = (key, readyEvent) => ` registerNoopPlugin(${JSON.stringify(key)}, ${JSON.stringify(readyEvent)});`;
5227
+ function gemRuntimePluginSources() {
5228
+ const withBody = runtimePluginSourceLines.map(([key, lines]) => ({
5229
+ key,
5230
+ readyEvent: runtimePluginReadyEvents[key] ?? `gem:${key}:ready`,
5231
+ hasBody: true,
5232
+ lines
5233
+ }));
5234
+ const noop = Object.entries(runtimePluginReadyEvents).filter(([key]) => !runtimePluginSourceLines.some(([sourceKey]) => sourceKey === key)).map(([key, readyEvent]) => ({ key, readyEvent, hasBody: false, lines: [noopPluginRegistrationLine(key, readyEvent)] }));
5235
+ return [...withBody, ...noop];
5236
+ }
4839
5237
  function pushPlugin(plugins, spec) {
4840
5238
  if (plugins.some((plugin) => plugin.key === spec.key)) return;
4841
5239
  plugins.push(spec);
@@ -4894,7 +5292,7 @@ function createHeaderRuntimeGrammar(header) {
4894
5292
  }
4895
5293
  function createGemRuntimeBootstrap(configArg) {
4896
5294
  const config = configArg.plugins.some((plugin) => plugin.key === "hero-slideshow") ? configArg : { ...configArg, plugins: [...configArg.plugins, { key: "hero-slideshow", loadStrategy: "idle" }] };
4897
- 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])});`);
5295
+ 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]));
4898
5296
  const enabledPluginKeys = new Set(config.plugins.map((plugin) => plugin.key));
4899
5297
  return [
4900
5298
  "(() => {",
@@ -4967,28 +5365,11 @@ function createGemRuntimeBootstrap(configArg) {
4967
5365
  " if (readyEvent) api.emit(readyEvent, { source: 'gem-runtime' });",
4968
5366
  " });",
4969
5367
  " };",
4970
- ...enabledPluginKeys.has("cart-drawer") ? cartDrawerPluginRegistrationLines : [],
4971
- ...enabledPluginKeys.has("mobile-menu") ? mobileMenuPluginRegistrationLines : [],
4972
- ...enabledPluginKeys.has("quantity-input") ? quantityInputPluginRegistrationLines : [],
4973
- ...enabledPluginKeys.has("variant-selects") ? variantSelectsPluginRegistrationLines : [],
4974
- ...enabledPluginKeys.has("predictive-search") ? predictiveSearchPluginRegistrationLines : [],
4975
- ...enabledPluginKeys.has("localization") ? localizationPluginRegistrationLines : [],
4976
- ...enabledPluginKeys.has("disclosure") ? disclosurePluginRegistrationLines : [],
4977
- ...enabledPluginKeys.has("tabs") ? tabsPluginRegistrationLines : [],
4978
- ...enabledPluginKeys.has("media-gallery") ? mediaGalleryPluginRegistrationLines : [],
4979
- ...enabledPluginKeys.has("carousel") ? carouselPluginRegistrationLines : [],
4980
- // hero-slideshow is always scheduled (added to config above) + self-guards, so always register it.
4981
- ...enabledPluginKeys.has("hero-slideshow") ? heroSlideshowPluginRegistrationLines : [],
4982
- ...enabledPluginKeys.has("quick-view") ? quickViewPluginRegistrationLines : [],
4983
- ...enabledPluginKeys.has("promo-popup") ? promoPopupPluginRegistrationLines : [],
4984
- ...enabledPluginKeys.has("sticky-atc") ? stickyAtcPluginRegistrationLines : [],
4985
- ...enabledPluginKeys.has("before_after") ? beforeAfterPluginRegistrationLines : [],
4986
- ...enabledPluginKeys.has("countdown") ? countdownPluginRegistrationLines : [],
4987
- ...enabledPluginKeys.has("reveal-on-scroll") ? revealOnScrollPluginRegistrationLines : [],
4988
- ...enabledPluginKeys.has("recently-viewed") ? recentlyViewedPluginRegistrationLines : [],
4989
- ...enabledPluginKeys.has("runtime-slot-loader") ? runtimeSlotLoaderPluginRegistrationLines : [],
4990
- ...enabledPluginKeys.has("offer-engine") ? offerEnginePluginRegistrationLines : [],
5368
+ ...runtimePluginSourceLines.flatMap(([key, lines]) => enabledPluginKeys.has(key) ? lines : []),
4991
5369
  ...pluginRegistrationLines,
5370
+ // `data-gem-bound-<key>` is a CONTRACT, not private bookkeeping: markup may carry it to claim an element for its
5371
+ // own behavior (promo-popup.email-capture.v1 renders data-gem-bound-promo-popup="section"). Any value counts as
5372
+ // bound; runtime-core.js mark() keeps the same rule, and its unmount removes only the marks it set.
4992
5373
  " const mark = (element, key) => {",
4993
5374
  " const attr = 'data-gem-bound-' + key;",
4994
5375
  " if (!element || element.hasAttribute(attr)) return false;",
@@ -5063,6 +5444,63 @@ function createGemRuntimeBootstrap(configArg) {
5063
5444
  ].join("\n");
5064
5445
  }
5065
5446
 
5447
+ // ../../packages/gem-framework/src/runtime-modules.ts
5448
+ var LISTEN_CALL = /(?<![\w$.\])?])([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\.addEventListener\(/g;
5449
+ function routeListenersThroughLedger(body, key = "plugin") {
5450
+ const routed = body.replace(LISTEN_CALL, "__gemListen($1, ");
5451
+ const unseen = routed.match(/.{0,40}\.addEventListener\(/);
5452
+ if (unseen) throw new Error(`gem-framework: plugin "${key}" adds a listener the module cannot track: ${unseen[0]}`);
5453
+ return routed;
5454
+ }
5455
+ var PLUGIN_GUARDS = {
5456
+ "offer-engine": { selector: "[data-gem-offer-engine]", prop: "__gemOffer" }
5457
+ };
5458
+ function gemRuntimePluginModuleKeys() {
5459
+ return gemRuntimePluginSources().map((source) => source.key);
5460
+ }
5461
+ function createGemRuntimePluginModule(key) {
5462
+ const source = gemRuntimePluginSources().find((candidate) => candidate.key === key);
5463
+ if (!source) throw new Error(`gem-framework: no runtime plugin "${key}"`);
5464
+ const body = routeListenersThroughLedger(source.lines.join("\n"), key);
5465
+ const guard = PLUGIN_GUARDS[key];
5466
+ return [
5467
+ `// @gemboss/gem-framework/plugins/${key}: the gem-runtime plugin "${key}" as an ES module. Generated from`,
5468
+ "// packages/gem-framework/src/runtime.ts, the same lines gem-runtime.js carries; do not edit here.",
5469
+ 'import { mountPlugin } from "../runtime-core.js";',
5470
+ "",
5471
+ `export const key = ${JSON.stringify(key)};`,
5472
+ `export const readyEvent = ${JSON.stringify(source.readyEvent)};`,
5473
+ "",
5474
+ "function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {",
5475
+ body,
5476
+ "}",
5477
+ "",
5478
+ `/** Bind every "${key}" hook inside root (default: the whole document). Returns unmount(). Mounting the same root twice binds once. */`,
5479
+ "export function mount(root, options) {",
5480
+ ` return mountPlugin(key, install, root, options${guard ? `, ${JSON.stringify(guard)}` : ""});`,
5481
+ "}",
5482
+ "",
5483
+ "export default mount;",
5484
+ ""
5485
+ ].join("\n");
5486
+ }
5487
+ function createGemRuntimePluginModuleTypes(key) {
5488
+ const source = gemRuntimePluginSources().find((candidate) => candidate.key === key);
5489
+ if (!source) throw new Error(`gem-framework: no runtime plugin "${key}"`);
5490
+ return [
5491
+ `// @gemboss/gem-framework/plugins/${key}: types of the ES module next to this file. Generated; do not edit here.`,
5492
+ 'import type { MountOptions, Unmount } from "../runtime-core.js";',
5493
+ "",
5494
+ "export type { MountOptions, Unmount };",
5495
+ `export declare const key: ${JSON.stringify(key)};`,
5496
+ `export declare const readyEvent: ${JSON.stringify(source.readyEvent)};`,
5497
+ `/** Bind every "${key}" hook inside root (default: the whole document). Returns unmount(). Mounting the same root twice binds once. */`,
5498
+ "export declare function mount(root?: ParentNode | null, options?: MountOptions): Unmount;",
5499
+ "export default mount;",
5500
+ ""
5501
+ ].join("\n");
5502
+ }
5503
+
5066
5504
  // ../../packages/gem-framework/src/validation.ts
5067
5505
  var validationRulesByCode = new Map(gemFrameworkValidationRules.map((rule) => [rule.code, rule]));
5068
5506
  var primitiveClassNames = new Set(
@@ -5088,7 +5526,9 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
5088
5526
  "--gem-icon-size",
5089
5527
  "--gem-drawer-offset",
5090
5528
  // The row primitive's variables (primitives.ts GEM_ROW_CSS). Sections SET these on their grid.
5091
- ...GEM_ROW_CSS_VARIABLES
5529
+ ...GEM_ROW_CSS_VARIABLES,
5530
+ // The form set's variables (form-set.ts): set from a plan's `tokens.form`, read by fields under [data-gem-form-set].
5531
+ ...GEM_FORM_CSS_VARIABLES
5092
5532
  ]);
5093
5533
  var TOKENIZED_PROPERTIES = [
5094
5534
  "background",
@@ -5310,6 +5750,7 @@ function renderGemPreviewDocument(plan, options = {}) {
5310
5750
  const footer = renderGemShellFooter(plan);
5311
5751
  const sectionsHtml = renderGemPageSections(plan, page, options.sectionMarkup ?? {});
5312
5752
  const title = options.title ?? `${plan.name} preview`;
5753
+ const formAttrs = Object.entries(gemFormAttributes(plan.tokens.form)).map(([k, v]) => ` ${k}="${escapeAttribute(v)}"`).join("");
5313
5754
  const bodyClass = ["gem-theme", `gem-theme--${slugify(profile)}`, options.bodyClassName].filter(Boolean).join(" ");
5314
5755
  return [
5315
5756
  "<!doctype html>",
@@ -5320,7 +5761,7 @@ function renderGemPreviewDocument(plan, options = {}) {
5320
5761
  ` <title>${escapeHtml(title)}</title>`,
5321
5762
  ` <style>${css}</style>`,
5322
5763
  "</head>",
5323
- `<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)}">`,
5764
+ `<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}>`,
5324
5765
  ' <a class="gem-skip-to-content-link gem-u-visually-hidden" href="#MainContent">Skip to content</a>',
5325
5766
  header,
5326
5767
  ` <main id="MainContent" class="gem-main" data-gem-page="${escapeAttribute(page)}">`,
@@ -6370,6 +6811,14 @@ export {
6370
6811
  COUNTDOWN_END_RUNTIME_LINES,
6371
6812
  GEM_AMBIENT_CSS,
6372
6813
  GEM_COUNTDOWN_ENDED_CSS,
6814
+ GEM_FIELD_JOINS,
6815
+ GEM_FIELD_SHAPES,
6816
+ GEM_FORM_CSS_VARIABLES,
6817
+ GEM_FORM_DEFAULTS,
6818
+ GEM_FORM_SETS,
6819
+ GEM_FORM_SET_CSS,
6820
+ GEM_FORM_TOKEN_KEYS,
6821
+ GEM_FORM_VAR_SCOPE,
6373
6822
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
6374
6823
  GEM_PART_CLASS,
6375
6824
  GEM_POPUP_SHELL_CSS,
@@ -6386,9 +6835,12 @@ export {
6386
6835
  createGemBaseCss,
6387
6836
  createGemFrameworkManifest,
6388
6837
  createGemRuntimeBootstrap,
6838
+ createGemRuntimePluginModule,
6839
+ createGemRuntimePluginModuleTypes,
6389
6840
  createGemSectionRuntimeContractCss,
6390
6841
  createGemStyleKnobCss,
6391
6842
  createGemThemeCss,
6843
+ createGemThemeCssParts,
6392
6844
  createGemTokenCss as createGemTokenVariablesCss,
6393
6845
  createHeaderRuntimeGrammar,
6394
6846
  createMorphProfileCss,
@@ -6399,12 +6851,18 @@ export {
6399
6851
  createThemeProfileCss,
6400
6852
  defaultGemTokens,
6401
6853
  defaultSelection,
6854
+ gemFormAttributes,
6855
+ gemFormCssBlock,
6856
+ gemFormCssVars,
6857
+ gemFormSetsFor,
6402
6858
  gemFrameworkPrimitiveGroups,
6403
6859
  gemFrameworkRuntimePlugins,
6404
6860
  gemFrameworkValidationRules,
6405
6861
  gemFrameworkVersion,
6406
6862
  gemPartClass,
6407
6863
  gemPlanShapeKnobs,
6864
+ gemRuntimePluginModuleKeys,
6865
+ gemRuntimePluginSources,
6408
6866
  getGemFrameworkValidationRule,
6409
6867
  isModernMineralFamilyLineagePlan,
6410
6868
  modernMineralFamilyLineage,