@gemboss/gem-framework 0.5.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +63 -1
  2. package/dist/SOURCE.json +2 -2
  3. package/dist/gem-framework.css +206 -64
  4. package/dist/gem-runtime.js +158 -12
  5. package/dist/index.js +747 -112
  6. package/dist/plugins/before_after.d.ts +9 -0
  7. package/dist/plugins/before_after.js +37 -0
  8. package/dist/plugins/carousel.d.ts +9 -0
  9. package/dist/plugins/carousel.js +49 -0
  10. package/dist/plugins/cart-drawer.d.ts +9 -0
  11. package/dist/plugins/cart-drawer.js +69 -0
  12. package/dist/plugins/context-adapter.d.ts +9 -0
  13. package/dist/plugins/context-adapter.js +17 -0
  14. package/dist/plugins/countdown.d.ts +9 -0
  15. package/dist/plugins/countdown.js +164 -0
  16. package/dist/plugins/disclosure.d.ts +9 -0
  17. package/dist/plugins/disclosure.js +40 -0
  18. package/dist/plugins/hero-slideshow.d.ts +9 -0
  19. package/dist/plugins/hero-slideshow.js +57 -0
  20. package/dist/plugins/localization.d.ts +9 -0
  21. package/dist/plugins/localization.js +37 -0
  22. package/dist/plugins/media-gallery.d.ts +9 -0
  23. package/dist/plugins/media-gallery.js +57 -0
  24. package/dist/plugins/mobile-menu.d.ts +9 -0
  25. package/dist/plugins/mobile-menu.js +51 -0
  26. package/dist/plugins/offer-engine.d.ts +9 -0
  27. package/dist/plugins/offer-engine.js +57 -0
  28. package/dist/plugins/predictive-search.d.ts +9 -0
  29. package/dist/plugins/predictive-search.js +77 -0
  30. package/dist/plugins/promo-popup.d.ts +9 -0
  31. package/dist/plugins/promo-popup.js +117 -0
  32. package/dist/plugins/quantity-input.d.ts +9 -0
  33. package/dist/plugins/quantity-input.js +44 -0
  34. package/dist/plugins/quick-view.d.ts +9 -0
  35. package/dist/plugins/quick-view.js +69 -0
  36. package/dist/plugins/recently-viewed.d.ts +9 -0
  37. package/dist/plugins/recently-viewed.js +71 -0
  38. package/dist/plugins/reveal-on-scroll.d.ts +9 -0
  39. package/dist/plugins/reveal-on-scroll.js +37 -0
  40. package/dist/plugins/runtime-slot-loader.d.ts +9 -0
  41. package/dist/plugins/runtime-slot-loader.js +29 -0
  42. package/dist/plugins/seasonal-effects.d.ts +9 -0
  43. package/dist/plugins/seasonal-effects.js +17 -0
  44. package/dist/plugins/sticky-atc.d.ts +9 -0
  45. package/dist/plugins/sticky-atc.js +43 -0
  46. package/dist/plugins/tabs.d.ts +9 -0
  47. package/dist/plugins/tabs.js +50 -0
  48. package/dist/plugins/variant-selects.d.ts +9 -0
  49. package/dist/plugins/variant-selects.js +70 -0
  50. package/dist/popup-shell.css +91 -0
  51. package/dist/primitives.css +1430 -0
  52. package/dist/runtime-core.d.ts +51 -0
  53. package/dist/runtime-core.js +176 -0
  54. package/dist/section-gate.js +432 -21
  55. package/dist/sections.css +1116 -0
  56. package/dist/tokens.css +67 -0
  57. package/package.json +21 -1
@@ -1,3 +1,18 @@
1
+ // ../../packages/gem-framework/src/form-set.ts
2
+ var FORM_CSS_VAR = {
3
+ radius: "--gem-input-radius",
4
+ borderWidth: "--gem-input-border-width",
5
+ borderColor: "--gem-input-border-color",
6
+ fill: "--gem-input-bg",
7
+ height: "--gem-input-height",
8
+ paddingInline: "--gem-input-padding-inline",
9
+ fontSize: "--gem-input-font-size",
10
+ focusRing: "--gem-input-focus-ring",
11
+ checkboxAccent: "--gem-checkbox-accent",
12
+ checkboxSize: "--gem-checkbox-size"
13
+ };
14
+ var GEM_FORM_CSS_VARIABLES = Object.values(FORM_CSS_VAR);
15
+
1
16
  // ../../packages/gem-framework/src/tokens.ts
2
17
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
3
18
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
@@ -229,6 +244,22 @@ var gemFrameworkPrimitiveGroups = [
229
244
  "gem-cart-line-item__price"
230
245
  ]
231
246
  },
247
+ {
248
+ // The popup shell: the dialog frame popup sections draw their content in. Styled by popup-shell.ts
249
+ // (GEM_POPUP_SHELL_CSS), which ships as its own popup-shell.css since 0.8.3, not in primitives.css: a page that
250
+ // draws one of these loads that file (gem-framework.css, the whole file, carries it too).
251
+ group: "popup-shell",
252
+ classes: [
253
+ "gem-popup-shell",
254
+ "gem-popup-shell__dialog",
255
+ "gem-popup-shell__frame",
256
+ "gem-popup-shell__media",
257
+ "gem-popup-shell__image",
258
+ "gem-popup-shell__panel",
259
+ "gem-popup-shell__close",
260
+ "gem-popup-shell__teaser"
261
+ ]
262
+ },
232
263
  {
233
264
  group: "sections",
234
265
  classes: [
@@ -331,6 +362,122 @@ var gemFrameworkValidationRules = [
331
362
  { code: "external_dependency", severity: "error", surface: "runtime" }
332
363
  ];
333
364
 
365
+ // ../../packages/gem-framework/src/popup-shell.ts
366
+ var GEM_POPUP_SHELL_CSS_LINES = [
367
+ ".gem-popup-shell { padding: 0; }",
368
+ ".gem-popup-shell__dialog {",
369
+ " padding: 0; border: 0; margin: auto; color: var(--gem-color-text); background: var(--gem-color-background);",
370
+ " width: min(880px, 92vw); max-width: none; max-height: min(640px, 92vh); overflow: hidden;",
371
+ " border-radius: var(--gem-radius-lg); box-shadow: var(--gem-shadow-md);",
372
+ "}",
373
+ ".gem-popup-shell__dialog::backdrop { background: rgb(0 0 0 / 0.55); }",
374
+ ".gem-popup-shell__frame { position: relative; display: grid; grid-template-columns: 1fr 1fr; min-height: 520px; height: 100%; }",
375
+ ".gem-popup-shell__media { position: relative; min-width: 0; overflow: hidden; background: var(--gem-color-surface); }",
376
+ ".gem-popup-shell__image { display: block; width: 100%; height: 100%; object-fit: cover; }",
377
+ ".gem-popup-shell__panel {",
378
+ " position: relative; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: var(--gem-space-md);",
379
+ " padding: var(--gem-space-2xl) var(--gem-space-xl); overflow-y: auto; align-items: center; text-align: center;",
380
+ "}",
381
+ ".gem-popup-shell[data-gem-popup-align='start'] .gem-popup-shell__panel { align-items: stretch; text-align: start; }",
382
+ ".gem-popup-shell[data-gem-popup-desktop='split-right'] .gem-popup-shell__media { order: 2; }",
383
+ ".gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__frame,",
384
+ ".gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__frame { grid-template-columns: 1fr; }",
385
+ // 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
386
+ // (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.
387
+ "@media (min-width: 750px) {",
388
+ " .gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__dialog,",
389
+ " .gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__dialog { width: min(480px, 92vw); }",
390
+ "}",
391
+ ".gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__media { display: none; }",
392
+ ".gem-popup-shell[data-gem-popup-desktop='card'] .gem-popup-shell__media { max-height: 200px; }",
393
+ ".gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__frame { grid-template-columns: 1fr; place-items: center; }",
394
+ ".gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__media { position: absolute; inset: 0; }",
395
+ ".gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__panel {",
396
+ " width: min(420px, 86%); margin: var(--gem-space-xl) 0; background: var(--gem-color-background); border-radius: var(--gem-radius-lg); box-shadow: var(--gem-shadow-sm);",
397
+ "}",
398
+ // Image behind the text: light text over a dark wash, every size. A card over the image has its own
399
+ // background, so on desktop it keeps the preset's text colour.
400
+ ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media::after { content: ''; position: absolute; inset: 0; background: linear-gradient(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.6)); }",
401
+ ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__panel,",
402
+ ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__close { color: #fff; }",
403
+ ".gem-popup-shell__close {",
404
+ " position: absolute; inset-block-start: var(--gem-space-xs); inset-inline-end: var(--gem-space-xs); z-index: 2;",
405
+ " display: inline-grid; place-items: center; width: var(--gem-tap-min); height: var(--gem-tap-min); padding: 0;",
406
+ " border: 0; border-radius: var(--gem-radius-pill); color: inherit; background: transparent; cursor: pointer;",
407
+ "}",
408
+ ".gem-popup-shell__close:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); }",
409
+ ".gem-popup-shell__teaser {",
410
+ " position: fixed; inset-block-end: var(--gem-space-md); inset-inline-end: var(--gem-space-md); z-index: 40;",
411
+ " min-height: var(--gem-tap-min); padding: 0 var(--gem-space-md); font: inherit; font-weight: 600; cursor: pointer;",
412
+ " color: var(--gem-color-accent-text); background: var(--gem-color-accent); border: 0; border-radius: var(--gem-radius-pill); box-shadow: var(--gem-shadow-md);",
413
+ "}",
414
+ ".gem-popup-shell__teaser[data-position='left'] { inset-inline-end: auto; inset-inline-start: var(--gem-space-md); }",
415
+ ".gem-popup-shell__teaser[hidden] { display: none; }",
416
+ "@media (min-width: 750px) {",
417
+ // Fullscreen: the dialog covers the viewport. The image goes right; behind the text when media=background,
418
+ // and then text aligned to the start keeps the left half so it never sits half on the image.
419
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'] .gem-popup-shell__dialog { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; box-shadow: none; }",
420
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'] .gem-popup-shell__frame { min-height: 100%; }",
421
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'] .gem-popup-shell__media { order: 2; }",
422
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'] .gem-popup-shell__panel { padding: var(--gem-space-3xl); }",
423
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-align='start'] .gem-popup-shell__panel { padding-inline-start: max(var(--gem-space-3xl), 8vw); }",
424
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'] .gem-popup-shell__frame { grid-template-columns: 1fr; }",
425
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'][data-gem-popup-align='start'] .gem-popup-shell__frame { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }",
426
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'] .gem-popup-shell__media { position: absolute; inset: 0; }",
427
+ " .gem-popup-shell[data-gem-popup-desktop='fullscreen'][data-gem-popup-media='background'] .gem-popup-shell__panel { position: relative; z-index: 1; }",
428
+ " .gem-popup-shell[data-gem-popup-desktop='card-over-image'][data-gem-popup-media='background'] .gem-popup-shell__panel { color: var(--gem-color-text); }",
429
+ // Ở 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
430
+ // đặ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
431
+ // 04/10/2026: 1,04:1 ở mọi bản ghép như vậy.
432
+ " .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,",
433
+ " .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); }",
434
+ " .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; }",
435
+ "}",
436
+ "@media (max-width: 749px) {",
437
+ // Mobile: takeover is the whole screen; a sheet rises from the bottom to at most 88% of it. The image
438
+ // sits under the text (top when media=top), capped at a third of the screen; with no image or the image
439
+ // behind, the text is centred vertically like the measured takeovers.
440
+ " .gem-popup-shell__dialog { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; }",
441
+ " .gem-popup-shell[data-gem-popup-mobile='sheet'] .gem-popup-shell__dialog { height: auto; max-height: 88dvh; margin: auto 0 0; border-radius: var(--gem-radius-lg) var(--gem-radius-lg) 0 0; }",
442
+ " .gem-popup-shell__frame { grid-template-columns: 1fr; grid-template-rows: auto 1fr; min-height: 100%; }",
443
+ " .gem-popup-shell__panel { justify-content: flex-start; padding: var(--gem-space-3xl) var(--gem-space-lg) var(--gem-space-xl); }",
444
+ " .gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__panel,",
445
+ " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__panel { justify-content: center; }",
446
+ // 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
447
+ // ả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.
448
+ " .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; }",
449
+ " .gem-popup-shell[data-gem-popup-desktop='card-over-image'] .gem-popup-shell__media { position: relative; }",
450
+ " .gem-popup-shell__media { order: 2; max-height: 34dvh; }",
451
+ " .gem-popup-shell[data-gem-popup-media='top'] .gem-popup-shell__media { order: 0; }",
452
+ " .gem-popup-shell[data-gem-popup-media='top'] .gem-popup-shell__panel { padding-top: var(--gem-space-lg); }",
453
+ " .gem-popup-shell[data-gem-popup-media='none'] .gem-popup-shell__frame,",
454
+ " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__frame { grid-template-rows: 1fr; }",
455
+ " .gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media { position: absolute; inset: 0; max-height: none; }",
456
+ "}",
457
+ // 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
458
+ // 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,
459
+ // 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
460
+ // và sửa được như mọi band khác.
461
+ // 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,
462
+ // 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ó ở
463
+ // dialog không-modal).
464
+ ":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); }",
465
+ ":root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: fixed; inset: 0; display: block; }",
466
+ ":root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: relative; display: block; inset: auto; }",
467
+ ":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); }",
468
+ "@media (min-width: 750px) {",
469
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] { padding: 0; }",
470
+ " :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; }",
471
+ "}",
472
+ "@media (max-width: 749px) {",
473
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: 0; }",
474
+ " :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%; }",
475
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-mobile='sheet'] { padding-top: 160px; }",
476
+ " :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; }",
477
+ "}"
478
+ ];
479
+ var GEM_POPUP_SHELL_CSS = GEM_POPUP_SHELL_CSS_LINES.join("\n");
480
+
334
481
  // ../../packages/gem-framework/src/primitives.ts
335
482
  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")`;
336
483
  var spikePath = (() => {
@@ -355,34 +502,112 @@ var GEM_DIVIDER_CSS_LINES = [
355
502
  // it entirely would paint square corners over that radius. A browser without
356
503
  // `overflow-clip-margin` keeps plain `clip` — the divider stays hidden, which is today's
357
504
  // behaviour rather than a broken layout.
358
- ":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); }",
505
+ ":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); }",
359
506
  // Shared geometry. `top: 100%` / `bottom: 100%` put it outside the box; z-index 1 keeps it above the
360
507
  // neighbouring band's background. pointer-events: none so it never eats a click near the seam.
361
508
  // Qualified by SHAPE. Unqualified, any value paints a solid 48px slab — including the literal
362
509
  // "none", which the attribute writer forwards unchanged. The clip used to hide that; widening
363
510
  // it above makes a stray value visible.
364
- ":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%; }",
365
- ":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%; }",
511
+ ":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%; }",
512
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom]::after { top: 100%; }",
366
513
  // The top edge reuses the same masks, mirrored, so five shapes cover both edges.
367
- ":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); }",
514
+ ":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); }",
368
515
  ...Object.entries(DIVIDER_SHAPES).flatMap(([name, path]) => {
369
516
  const mask = dividerMask(path);
370
517
  return [
371
- `: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}; }`,
372
- `: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}; }`
518
+ `: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}; }`,
519
+ `: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}; }`
373
520
  ];
374
521
  })
375
522
  ];
376
523
  var GEM_DIVIDER_CSS = GEM_DIVIDER_CSS_LINES.join("\n");
377
524
  var GEM_CARD_CSS_LINES = [
378
- ":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); }",
379
- ":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); }",
380
- ":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); }",
525
+ ":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); }",
526
+ ":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); }",
527
+ ":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); }",
381
528
  // `filled` tints the section's own ground rather than reaching for a fixed colour, so it stays
382
529
  // legible on an accent or dark band instead of punching a light panel into it.
383
- ":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); }"
530
+ ":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); }"
384
531
  ];
385
532
  var GEM_CARD_CSS = GEM_CARD_CSS_LINES.join("\n");
533
+ var GEM_FORM_SET_CSS_LINES = [
534
+ // FORM SET (form-set.ts). Every rule is scoped under [data-gem-form-set], which <body> carries only when the plan
535
+ // sets `tokens.form`: a store without one renders as before. The variables are always COMPLETE (missing keys take
536
+ // GEM_FORM_DEFAULTS) and are declared on the controls themselves (GEM_FORM_VAR_SCOPE), so these rules never fall back
537
+ // to a value nobody chose and a band that redefines its palette recolours its fields.
538
+ // THE FIELD LIST names the dawn-parity footer field (0,2,0, later in the sheet) so :is() — which takes its most
539
+ // specific argument — outranks it: before, that field ignored every form set (review of #665). The main search is
540
+ // LEFT OUT (`:where(:not(…))`, no specificity): it is a field and a button drawn as one block whose button belongs to
541
+ // the profile, and restyling only the field half left it open on one side.
542
+ // .gem-field-join is joined by construction (the seller chose the join element), so `data-gem-field-join` does not
543
+ // open it; gemsite's own field + button pairs read that attribute.
544
+ "[data-gem-form-set] :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)) {",
545
+ " min-height: var(--gem-input-height);",
546
+ " height: auto;",
547
+ " border: var(--gem-input-border-width) solid var(--gem-input-border-color);",
548
+ " border-radius: var(--gem-input-radius);",
549
+ " background: var(--gem-input-bg);",
550
+ " padding-inline: var(--gem-input-padding-inline);",
551
+ " font-size: var(--gem-input-font-size);",
552
+ "}",
553
+ // A textarea is two rows tall and never a pill: a 999px radius grows with the box as the shopper drags it. Keyed on
554
+ // the ELEMENT, not .gem-textarea, so a copy whose list adds its own fields (gemsite's cart note) reaches them too.
555
+ "[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)) {",
556
+ " min-height: calc(var(--gem-input-height) * 2);",
557
+ " border-radius: min(var(--gem-input-radius), 12px);",
558
+ " padding-block: var(--gem-space-sm);",
559
+ "}",
560
+ "[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 {",
561
+ " outline: var(--gem-input-focus-ring, var(--gem-focus-ring, 2px solid var(--gem-color-accent)));",
562
+ " outline-offset: var(--gem-focus-offset, 2px);",
563
+ "}",
564
+ // underline: only the bottom rule, no box. filled: the tinted field keeps its top corners and a bottom rule.
565
+ '[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)) {',
566
+ " border-width: 0 0 var(--gem-input-border-width);",
567
+ " border-radius: 0;",
568
+ " background: transparent;",
569
+ // Transparent, so it reads on whatever is behind it: the ink of the band it sits on, not the page's. A dark section
570
+ // changes `color` and leaves --gem-color-text dark (review of #665: dark text on #202223, 1:1).
571
+ " color: inherit;",
572
+ "}",
573
+ // 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
574
+ // only visible label), and a select's options keep the system's own pair: the popup on Windows/Linux takes the
575
+ // select's colours, so inherited light ink on a transparent field drew light options on a light list.
576
+ '[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 {',
577
+ " color: color-mix(in srgb, currentColor 70%, transparent);",
578
+ "}",
579
+ '[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 {',
580
+ " color: CanvasText;",
581
+ " background: Canvas;",
582
+ "}",
583
+ '[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)) {',
584
+ " border-width: 0 0 var(--gem-input-border-width);",
585
+ " border-end-start-radius: 0;",
586
+ " border-end-end-radius: 0;",
587
+ "}",
588
+ // The main search stays the profile's: the variables are declared on (and inherited by) every input, and the
589
+ // profile's base .gem-input rule reads two of them as hooks, so set those back to "unset" here (morph: 1 → 2px).
590
+ // DISABLED keeps a tinted fill (the base :disabled rule is 0,2,0 and the field rule above outranks it): the tint goes
591
+ // OVER the field's own fill, so a white field on a dark band stays white under its dark text (review of #665: a
592
+ // transparent tint there was 1:1). §9.2 state matrix: a disabled field must still look disabled, not only fainter.
593
+ "[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 {",
594
+ " background: color-mix(in srgb, currentColor 6%, var(--gem-input-bg));",
595
+ "}",
596
+ // 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.
597
+ '[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 {',
598
+ " background: color-mix(in srgb, currentColor 6%, transparent);",
599
+ "}",
600
+ "[data-gem-form-set] .gem-search-form .gem-input {",
601
+ " --gem-input-border-width: initial;",
602
+ " --gem-input-radius: initial;",
603
+ "}",
604
+ "[data-gem-form-set] .gem-checkbox {",
605
+ " inline-size: var(--gem-checkbox-size);",
606
+ " block-size: var(--gem-checkbox-size);",
607
+ " accent-color: var(--gem-checkbox-accent);",
608
+ "}"
609
+ ];
610
+ var GEM_FORM_SET_CSS = GEM_FORM_SET_CSS_LINES.join("\n");
386
611
  var GEM_COUNTDOWN_ENDED_CSS_LINES = [
387
612
  ":root:not(:has([data-gem-canvas])) [data-gem-section-id][data-gem-countdown-ended='true'] { display: none; }",
388
613
  ":root:has([data-gem-canvas]) [data-gem-section-id][data-gem-countdown-ended='true'] { opacity: .55; }",
@@ -445,12 +670,12 @@ var GEM_ROW_CSS_LINES = [
445
670
  var GEM_ROW_GAP_CSS = GEM_ROW_GAP_CSS_LINES.join("\n");
446
671
  var GEM_ROW_CSS = GEM_ROW_CSS_LINES.join("\n");
447
672
  var GEM_GRID_COLUMNS_KNOB_CSS_LINES = [
448
- ":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)); }",
449
- ":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; }",
450
- ":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)); }",
451
- ":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; }",
452
- ":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)); }",
453
- ":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; }"
673
+ ":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)); }",
674
+ ":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; }",
675
+ ":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)); }",
676
+ ":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; }",
677
+ ":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)); }",
678
+ ":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; }"
454
679
  ];
455
680
  var GEM_GRID_COLUMNS_ROW_KNOB_CSS = GEM_GRID_COLUMNS_KNOB_CSS_LINES.filter((line) => line.includes(" .gem-row:is(")).join("\n");
456
681
 
@@ -522,12 +747,111 @@ var countdownPluginRegistrationLines = [
522
747
  " const targetNode = timer.querySelector && timer.querySelector(selector);",
523
748
  " if (targetNode) targetNode.textContent = pad(value);",
524
749
  " };",
750
+ // Relative mode: `data-gem-countdown-duration="N"` counts N seconds for THIS shopper instead of running to a
751
+ // calendar deadline (a popup offer: "this code is yours for 1:00"). `data-gem-countdown-start` says when the N
752
+ // seconds begin: "mount" (default) at bind; "visible" only while the timer is on screen, which is what a popup
753
+ // wants: hidden until the popup opens, paused while the shopper has it closed or has moved past the step that
754
+ // holds it, and no race with the open event (this plugin loads on `viewport`, so it can bind after a popup
755
+ // that opened at once); any other value is an event name heard on the timer's section. Readouts:
756
+ // [data-gem-countdown-days|hours|minutes|seconds] (two digits) and [data-gem-countdown-clock] ("1:00", "1:02:05").
757
+ // At zero the TIMER gets data-gem-countdown-ended="true" and a bubbling gem:countdown:complete leaves it, so
758
+ // the section that owns the offer can end it. The band is NOT marked: this ending belongs to one shopper, and
759
+ // the band rule above (data-gem-countdown-end) removes a band from the store for everyone.
760
+ " const clockText = (s) => {",
761
+ " const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;",
762
+ " return (h ? h + ':' + pad(m) : String(m)) + ':' + pad(sec);",
763
+ " };",
764
+ " const bindDuration = (timer, total) => {",
765
+ " const scope = (timer.closest && timer.closest('[data-gem-section-id]')) || document;",
766
+ " const mode = String(timer.getAttribute('data-gem-countdown-start') || 'mount').trim() || 'mount';",
767
+ " const stopEvent = String(timer.getAttribute('data-gem-countdown-stop') || '').trim();",
768
+ " let left = total * 1000;",
769
+ " let endAt = 0;",
770
+ " let handle = null;",
771
+ " let state = 'idle';",
772
+ // gem:countdown:start means the N seconds began: once. A visible-mode timer pauses and resumes as it leaves and
773
+ // re-enters the screen, and each resume used to announce a new start (gemboss PR #3758 review, item 7).
774
+ " let started = false;",
775
+ // The visible-mode observer, released as soon as the timer can no longer run (complete or stopped), not at the
776
+ // next intersection change after it.
777
+ " let observer = null;",
778
+ " const release = () => { if (observer) { observer.disconnect(); observer = null; } };",
779
+ " const detail = () => ({ source: 'gem-runtime', id: timer.id || undefined, mode: 'duration', duration: total, remainingSeconds: Math.max(0, Math.ceil(left / 1000)) });",
780
+ " const render = () => {",
781
+ " const s = Math.max(0, Math.ceil(left / 1000));",
782
+ " timer.dataset.gemCountdownState = state;",
783
+ " timer.dataset.gemCountdownRemainingSeconds = String(s);",
784
+ " write(timer, '[data-gem-countdown-days]', Math.floor(s / 86400));",
785
+ " write(timer, '[data-gem-countdown-hours]', Math.floor((s % 86400) / 3600));",
786
+ " write(timer, '[data-gem-countdown-minutes]', Math.floor((s % 3600) / 60));",
787
+ " write(timer, '[data-gem-countdown-seconds]', s % 60);",
788
+ " const readout = timer.querySelector && timer.querySelector('[data-gem-countdown-clock]');",
789
+ " if (readout) readout.textContent = clockText(s);",
790
+ " };",
791
+ " const halt = () => { if (handle !== null && target.clearInterval) target.clearInterval(handle); handle = null; };",
792
+ " const finish = () => {",
793
+ " halt();",
794
+ " release();",
795
+ " left = 0;",
796
+ " state = 'complete';",
797
+ " render();",
798
+ " timer.setAttribute('data-gem-countdown-ended', 'true');",
799
+ // One event, not two: dispatched from the timer it bubbles to the document; api.emit would add a second
800
+ // document event. Without a dispatchable timer (tests, detached nodes) fall back to api.emit.
801
+ " if (timer.dispatchEvent && timer.isConnected !== false) {",
802
+ " timer.dispatchEvent(new CustomEvent('gem:countdown:complete', { bubbles: true, detail: detail() }));",
803
+ " for (const callback of api.listeners['gem:countdown:complete'] || []) callback(detail());",
804
+ " } else api.emit('gem:countdown:complete', detail());",
805
+ " };",
806
+ " const tick = () => { left = endAt - Date.now(); if (left <= 0) finish(); else render(); };",
807
+ " const run = () => {",
808
+ " if (state === 'running' || state === 'complete' || state === 'stopped') return;",
809
+ " state = 'running';",
810
+ " endAt = Date.now() + left;",
811
+ " render();",
812
+ " if (target.setInterval) handle = target.setInterval(tick, 250);",
813
+ " if (!started) { started = true; api.emit('gem:countdown:start', detail()); }",
814
+ " };",
815
+ " const pause = (next) => {",
816
+ " if (state === 'running') { left = Math.max(0, endAt - Date.now()); halt(); }",
817
+ " if (state === 'complete' || state === 'stopped') return;",
818
+ " state = next;",
819
+ " if (next === 'stopped') release();",
820
+ " render();",
821
+ " };",
822
+ " render();",
823
+ " if (stopEvent && canListen(scope)) scope.addEventListener(stopEvent, () => pause('stopped'));",
824
+ " if (mode === 'visible' && target.IntersectionObserver) {",
825
+ " observer = new target.IntersectionObserver((entries) => {",
826
+ " for (const entry of entries) entry.isIntersecting ? run() : pause('paused');",
827
+ " });",
828
+ " observer.observe(timer);",
829
+ " } else if (mode === 'mount' || mode === 'visible') run();",
830
+ " else if (canListen(scope)) scope.addEventListener(mode, run);",
831
+ " };",
525
832
  " timers.forEach((timer) => {",
526
833
  " if (!mark(timer, 'countdown')) return;",
834
+ " const duration = Number(timer.getAttribute && timer.getAttribute('data-gem-countdown-duration'));",
835
+ " if (Number.isFinite(duration) && duration > 0) {",
836
+ " bindDuration(timer, Math.min(Math.round(duration), 86400));",
837
+ " bound += 1;",
838
+ " return;",
839
+ " }",
527
840
  " const targetTime = countdownEnd(timer.getAttribute && timer.getAttribute('data-gem-countdown-target'));",
528
841
  " const section = timer.closest ? timer.closest('[data-gem-section-id]') : null;",
529
842
  " const band = section && section.hasAttribute && section.hasAttribute('data-gem-countdown-end') ? section : null;",
843
+ // Two readout schemes, one countdown (gem-framework 0.8.0). The runtime's own cells are
844
+ // [data-gem-countdown-days|hours|minutes|seconds]; the Liquid theme's initCountdown and gemsite write
845
+ // [data-gem-cd-days|hours|mins|secs] (gemsite CountdownBannerOfferBar.tsx). Both are written; a cell the markup
846
+ // does not carry is skipped. The readout ticks every second only when it shows seconds, else once a minute ON the
847
+ // minute of the remaining time (0.8.2): the next update is set for the instant the minute digit changes,
848
+ // (remaining % 60000) + a few ms, and re-aimed from the clock each time, so the digit is never a minute late.
849
+ // In the last minute the next change IS the end, so the chain aims at the end itself and stops there: nothing is
850
+ // left scheduled after it, and a deadline too far off for the one-shot end timer below still ends on time.
851
+ // 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.
852
+ " const hasSecondsCell = Boolean(timer.querySelector && (timer.querySelector('[data-gem-cd-secs]') || timer.querySelector('[data-gem-countdown-seconds]')));",
530
853
  " let handle = null;",
854
+ " let announced = '';",
531
855
  " const update = () => {",
532
856
  " if (!Number.isFinite(targetTime)) {",
533
857
  " timer.dataset.gemCountdownState = 'invalid';",
@@ -538,20 +862,43 @@ var countdownPluginRegistrationLines = [
538
862
  " const days = Math.floor(remainingSeconds / 86400);",
539
863
  " const hours = Math.floor((remainingSeconds % 86400) / 3600);",
540
864
  " const minutes = Math.floor((remainingSeconds % 3600) / 60);",
865
+ " const seconds = remainingSeconds % 60;",
866
+ // ONE end state: data-gem-countdown-state="complete" (the runtime's word, same as the event
867
+ // gem:countdown:complete and the duration timer). gemsite's port wrote "ended"; its meaning travels as the
868
+ // attribute data-gem-countdown-ended="true" on the timer, the same mark a duration timer gets at zero. A timer
869
+ // 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.
541
870
  " timer.dataset.gemCountdownState = ms > 0 ? 'running' : 'complete';",
542
871
  " timer.dataset.gemCountdownRemainingSeconds = String(remainingSeconds);",
543
872
  " write(timer, '[data-gem-countdown-days]', days);",
544
873
  " write(timer, '[data-gem-countdown-hours]', hours);",
545
874
  " write(timer, '[data-gem-countdown-minutes]', minutes);",
875
+ " write(timer, '[data-gem-countdown-seconds]', seconds);",
876
+ " write(timer, '[data-gem-cd-days]', days);",
877
+ " write(timer, '[data-gem-cd-hours]', hours);",
878
+ " write(timer, '[data-gem-cd-mins]', minutes);",
879
+ " write(timer, '[data-gem-cd-secs]', seconds);",
546
880
  " if (ms <= 0) {",
547
881
  " if (band) band.setAttribute('data-gem-countdown-ended', 'true');",
882
+ " if (timer.setAttribute && !(timer.hasAttribute && timer.hasAttribute('data-gem-section-id'))) timer.setAttribute('data-gem-countdown-ended', 'true');",
548
883
  " if (handle !== null && target.clearInterval) { target.clearInterval(handle); handle = null; }",
549
884
  " }",
550
- " api.emit(ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });",
885
+ // start: once a minute as before, but a per-second readout announces it once; complete: once.
886
+ " const event = ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete';",
887
+ " if (event === announced && (event === 'gem:countdown:complete' || hasSecondsCell)) return;",
888
+ " announced = event;",
889
+ " api.emit(event, { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });",
890
+ " };",
891
+ " const onTheMinute = () => {",
892
+ " const ms = targetTime - Date.now();",
893
+ " if (!(ms > 0) || !target.setTimeout) return;",
894
+ " target.setTimeout(() => { update(); onTheMinute(); }, Math.min((ms % 60000) + 5, ms));",
551
895
  " };",
552
896
  " update();",
553
- " if (target.setInterval && Number.isFinite(targetTime) && targetTime - Date.now() > 0) handle = target.setInterval(update, 60000);",
554
- // The readout moves once a minute, but the END is not allowed to wait for the next minute: one timer
897
+ " if (Number.isFinite(targetTime) && targetTime - Date.now() > 0) {",
898
+ " if (!hasSecondsCell) onTheMinute();",
899
+ " else if (target.setInterval) handle = target.setInterval(update, 1000);",
900
+ " }",
901
+ // A minute readout moves once a minute, but the END is not allowed to wait for the next minute: one timer
555
902
  // for the exact instant (while it fits a setTimeout, ~24.8 days; past that the minute tick gets there).
556
903
  " const left = targetTime - Date.now();",
557
904
  " if (target.setTimeout && Number.isFinite(left) && left > 0 && left < 2147483647) target.setTimeout(update, left);",
@@ -592,7 +939,9 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
592
939
  "--gem-icon-size",
593
940
  "--gem-drawer-offset",
594
941
  // The row primitive's variables (primitives.ts GEM_ROW_CSS). Sections SET these on their grid.
595
- ...GEM_ROW_CSS_VARIABLES
942
+ ...GEM_ROW_CSS_VARIABLES,
943
+ // The form set's variables (form-set.ts): set from a plan's `tokens.form`, read by fields under [data-gem-form-set].
944
+ ...GEM_FORM_CSS_VARIABLES
596
945
  ]);
597
946
  var TOKENIZED_PROPERTIES = [
598
947
  "background",
@@ -997,6 +1346,67 @@ var PROMISE_VI_PARTS = {
997
1346
  moneyBack: `${VI_START}(?<!kh\xF4ng(?:\\s+(?:\u0111\u01B0\u1EE3c|th\u1EC3))?\\s)(?:nh\u1EADn|ho\xE0n|tr\u1EA3|g\u1EEDi)\\s+l\u1EA1i\\s+(?:(?:to\xE0n b\u1ED9|\u0111\u1EE7|\u0111\u1EA7y \u0111\u1EE7|100\\s*%)\\s+)?(?:s\u1ED1\\s+)?ti\u1EC1n(?!\\s+(?:th\u1EEBa|th\u1ED1i|l\u1EBB))${VI_END}`
998
1347
  };
999
1348
  var PROMISE_VI = new RegExp(Object.values(PROMISE_VI_PARTS).join("|"), "iu");
1349
+ var SHIP = String.raw`(?:shipping|delivery)`;
1350
+ var MONEY = String.raw`(?:fees?|costs?|charges?)`;
1351
+ var TAIL = String.raw`(?:\s+(?:apply(?:\s+to\s+(?:this|your)\s+order)?|at\s+checkout|on\s+(?:this|your|every)\s+order|for\s+members|ever|will\s+be\s+charged))?`;
1352
+ var LEAD = String.raw`(?:(?:plus|and|also|absolutely|with|yes|so|oh,?\s+and)[,!]?\s+)?`;
1353
+ var VI_SHIP_WORD = String.raw`(?:ship|vận chuyển|giao hàng)`;
1354
+ var SHIPPING_ZERO_SENTENCES = {
1355
+ noExtraShipping: new RegExp(String.raw`^${LEAD}no\s+(?:extra|additional|added)\s+${SHIP}(?:\s+${MONEY})?${TAIL}$`, "i"),
1356
+ noExtraChargeForShipping: new RegExp(String.raw`^${LEAD}no\s+(?:extra|additional|added)\s+${MONEY}\s+for\s+${SHIP}${TAIL}$`, "i"),
1357
+ noShippingFees: new RegExp(String.raw`^${LEAD}no\s+${SHIP}\s+${MONEY}${TAIL}$`, "i"),
1358
+ zeroShippingFees: new RegExp(String.raw`^${LEAD}zero\s+${SHIP}\s+${MONEY}${TAIL}$`, "i"),
1359
+ // "Shipping included", "Shipping costs: included", "Shipping — included". "Total: Shipping included" không khớp: câu bắt
1360
+ // đầu bằng "Total" (nhãn giỏ, cố ý sạch).
1361
+ shippingIncluded: new RegExp(String.raw`^${LEAD}(?:(?:taxes?|duties)\s+(?:and|&)\s+)?${SHIP}(?:\s+(?:and|&)\s+handling)?(?:\s+${MONEY})?(?:['’]s|\s+(?:is|are)|\s*[:—–-])?\s+(?:always\s+)?included(?:\s+in\s+(?:the\s+)?price)?$`, "i"),
1362
+ // Chỉ "Includes" (ngôi thứ ba, một lời mô tả gói hàng). "Include shipping" là nhãn tuỳ chọn / công tắc.
1363
+ includesShipping: new RegExp(String.raw`^includes\s+${SHIP}$`, "i"),
1364
+ // "Không cần trả thêm phí giao hàng", "Không mất phí ship", "Không thu phí vận chuyển". KHÔNG nhận "không trả phí ship…"
1365
+ // trần ("Shop không trả phí ship đổi trả" là lời từ chối) và "không tính phí…" (nhãn giá: CHƯA tính phí ship).
1366
+ viNoFee: new RegExp(String.raw`^không\s+(?:cần\s+(?:trả|mất|tốn)|mất|thu|tốn)\s+(?:thêm\s+)?(?:phí|tiền)\s+${VI_SHIP_WORD}$`, "iu"),
1367
+ viNoFeeBare: new RegExp(String.raw`^không\s+(?:phí|tiền)\s+${VI_SHIP_WORD}$`, "iu"),
1368
+ // "Giá đã bao gồm phí ship", "Đã gồm phí ship". BẮT BUỘC "đã": "Bao gồm phí ship" trần là nhãn tuỳ chọn (như "Include
1369
+ // shipping" bên tiếng Anh). "Chưa/không bao gồm…" không khớp vì câu phải BẮT ĐẦU bằng các chữ này.
1370
+ viIncludedFee: new RegExp(String.raw`^(?:giá(?:\s+(?:sản phẩm|trên))?\s+)?đã\s+(?:bao\s+)?gồm\s+(?:cả\s+)?phí\s+${VI_SHIP_WORD}$`, "iu"),
1371
+ // "Phí ship đã bao gồm" (trong giá). "Phí ship đã bao gồm VAT" không khớp trọn câu: phí đó CÓ, nó gồm thứ khác.
1372
+ viFeeIncluded: new RegExp(String.raw`^phí\s+${VI_SHIP_WORD}\s+đã\s+(?:được\s+)?(?:bao\s+)?gồm(?:\s+trong\s+giá)?$`, "iu")
1373
+ };
1374
+ var DECOR = String.raw`[\s\-*+>•·–—✓✔☑\p{Extended_Pictographic}️]`;
1375
+ var DECOR_START = new RegExp(`^${DECOR}+`, "u");
1376
+ var DECOR_END = new RegExp(String.raw`(?:${DECOR}|[.!,;:)])+$`, "u");
1377
+ var ABBREV = /\b(?:e\.g|i\.e|approx|ex|etc|vs|incl|excl|mr|mrs|ms|dr)\.$/iu;
1378
+ var LABEL_LEAD = /^(?:(?:grand\s+|sub\s*)?total|subtotal|price|unless\s+noted|tổng(?:\s+cộng)?|tạm\s+tính|giá)\b/iu;
1379
+ var QUESTION_END = /[??❓❔][\s\p{Extended_Pictographic}\uFE0F]*$/u;
1380
+ var MENTIONS_SHIPPING = /ship|deliver|vận chuyển|giao hàng/iu;
1381
+ var SHIPPING_ZERO_ENTRIES = Object.entries(SHIPPING_ZERO_SENTENCES);
1382
+ function sentencesOf(text) {
1383
+ const out = [];
1384
+ for (const line of text.split(/\n+|\s[|•·]\s|\s(?=[✓✔☑]\s)/u)) {
1385
+ let cur = "";
1386
+ for (const piece of line.split(/(?<=[.!?;])\s+/u)) {
1387
+ cur = cur ? `${cur} ${piece}` : piece;
1388
+ if (!ABBREV.test(cur)) {
1389
+ out.push(cur);
1390
+ cur = "";
1391
+ }
1392
+ }
1393
+ if (cur) out.push(cur);
1394
+ }
1395
+ return out.map((x) => x.trim()).filter(Boolean);
1396
+ }
1397
+ function shippingZeroPromiseIn(raw) {
1398
+ if (!MENTIONS_SHIPPING.test(raw)) return null;
1399
+ const text = raw.normalize("NFC").replace(/\\n/g, "\n").replace(/\\(['"’])/g, "$1");
1400
+ for (const sentence of sentencesOf(text)) {
1401
+ if (QUESTION_END.test(sentence)) continue;
1402
+ const whole = sentence.replace(DECOR_START, "").replace(DECOR_END, "").trim();
1403
+ const clauses = LABEL_LEAD.test(whole) ? [whole] : [whole, ...whole.split(/,\s+|\s+[—–]\s+/u)].map((c) => c.replace(DECOR_START, "").replace(DECOR_END, "").trim());
1404
+ for (const clause of clauses) {
1405
+ for (const [name, re] of SHIPPING_ZERO_ENTRIES) if (re.test(clause)) return name;
1406
+ }
1407
+ }
1408
+ return null;
1409
+ }
1000
1410
  var INVENTED_PERSON_VI = /^\s*[-–—]?\s*(?:(?:Chị|Anh|Cô|Chú|Bác|Em|Bạn|Ông|Bà|Nguyễn|Trần|Lê|Phạm|Hoàng|Huỳnh|Phan|Vũ|Võ|Đặng|Bùi|Đỗ|Ngô|Dương|Đinh|Trương|Đoàn|Lương|Trịnh|Lý)(?:\s+\p{Lu}\p{Ll}*){1,3}\s*(?:,|\(|\s[-–—·])\s*\S|(?:Nguyễn|Trần|Lê|Phạm|Hoàng|Huỳnh|Phan|Vũ|Võ|Đặng|Bùi|Đỗ|Ngô|Dương|Đinh|Trương|Đoàn|Lương|Trịnh|Lý)\s+(?:Thị|Văn)(?:\s+\p{Lu}\p{Ll}*){1,2}\s*$)/u;
1001
1411
  var PRICE_K_VI = /(?<![\p{L}\p{N}.,])\d{2,3}(?:[.,]\d{3})*\s?k(?![\p{L}\p{N}])/u;
1002
1412
  var DISPLAY_QUANTITY = /(_to_show|_per_page|_per_row|_limit|_max|_visible|_columns?)$/;
@@ -1052,7 +1462,7 @@ function claimIn(raw) {
1052
1462
  if (INVENTED_PERSON.test(`"${text}"`) || INVENTED_PERSON_VI.test(text)) return "invented person";
1053
1463
  if (isQuestion(text)) return PRICE_ANY.test(text) || PRICE_K_VI.test(text) ? "promise the merchant never made" : null;
1054
1464
  if (PRICE_K_VI.test(text) && /^\s*\d[\d.,]*\s?k\s*\+?\s*$/.test(text)) return "hardcoded figure";
1055
- if (PROMISE.test(text) || PROMISE_VI.test(text) || PRICE_K_VI.test(text)) return "promise the merchant never made";
1465
+ if (PROMISE.test(text) || PROMISE_VI.test(text) || shippingZeroPromiseIn(text) || PRICE_K_VI.test(text)) return "promise the merchant never made";
1056
1466
  return null;
1057
1467
  }
1058
1468
  function demoInvents(value) {
@@ -1294,6 +1704,7 @@ export {
1294
1704
  requiredArtifactPaths,
1295
1705
  sectionClassesIn,
1296
1706
  sectionLibraryIdOf,
1707
+ shippingZeroPromiseIn,
1297
1708
  slotContractDrift,
1298
1709
  slotSourceFromElementTree,
1299
1710
  slotsReadBy,