@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.
- package/README.md +63 -1
- package/dist/SOURCE.json +2 -2
- package/dist/gem-framework.css +206 -64
- package/dist/gem-runtime.js +158 -12
- package/dist/index.js +747 -112
- package/dist/plugins/before_after.d.ts +9 -0
- package/dist/plugins/before_after.js +37 -0
- package/dist/plugins/carousel.d.ts +9 -0
- package/dist/plugins/carousel.js +49 -0
- package/dist/plugins/cart-drawer.d.ts +9 -0
- package/dist/plugins/cart-drawer.js +69 -0
- package/dist/plugins/context-adapter.d.ts +9 -0
- package/dist/plugins/context-adapter.js +17 -0
- package/dist/plugins/countdown.d.ts +9 -0
- package/dist/plugins/countdown.js +164 -0
- package/dist/plugins/disclosure.d.ts +9 -0
- package/dist/plugins/disclosure.js +40 -0
- package/dist/plugins/hero-slideshow.d.ts +9 -0
- package/dist/plugins/hero-slideshow.js +57 -0
- package/dist/plugins/localization.d.ts +9 -0
- package/dist/plugins/localization.js +37 -0
- package/dist/plugins/media-gallery.d.ts +9 -0
- package/dist/plugins/media-gallery.js +57 -0
- package/dist/plugins/mobile-menu.d.ts +9 -0
- package/dist/plugins/mobile-menu.js +51 -0
- package/dist/plugins/offer-engine.d.ts +9 -0
- package/dist/plugins/offer-engine.js +57 -0
- package/dist/plugins/predictive-search.d.ts +9 -0
- package/dist/plugins/predictive-search.js +77 -0
- package/dist/plugins/promo-popup.d.ts +9 -0
- package/dist/plugins/promo-popup.js +117 -0
- package/dist/plugins/quantity-input.d.ts +9 -0
- package/dist/plugins/quantity-input.js +44 -0
- package/dist/plugins/quick-view.d.ts +9 -0
- package/dist/plugins/quick-view.js +69 -0
- package/dist/plugins/recently-viewed.d.ts +9 -0
- package/dist/plugins/recently-viewed.js +71 -0
- package/dist/plugins/reveal-on-scroll.d.ts +9 -0
- package/dist/plugins/reveal-on-scroll.js +37 -0
- package/dist/plugins/runtime-slot-loader.d.ts +9 -0
- package/dist/plugins/runtime-slot-loader.js +29 -0
- package/dist/plugins/seasonal-effects.d.ts +9 -0
- package/dist/plugins/seasonal-effects.js +17 -0
- package/dist/plugins/sticky-atc.d.ts +9 -0
- package/dist/plugins/sticky-atc.js +43 -0
- package/dist/plugins/tabs.d.ts +9 -0
- package/dist/plugins/tabs.js +50 -0
- package/dist/plugins/variant-selects.d.ts +9 -0
- package/dist/plugins/variant-selects.js +70 -0
- package/dist/popup-shell.css +91 -0
- package/dist/primitives.css +1430 -0
- package/dist/runtime-core.d.ts +51 -0
- package/dist/runtime-core.js +176 -0
- package/dist/section-gate.js +432 -21
- package/dist/sections.css +1116 -0
- package/dist/tokens.css +67 -0
- package/package.json +21 -1
package/dist/section-gate.js
CHANGED
|
@@ -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
|
|
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
|
|
365
|
-
":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
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
|
|
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
|
|
372
|
-
`:is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
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
|
|
379
|
-
":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
380
|
-
":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
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
|
|
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
|
|
449
|
-
":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
450
|
-
":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
451
|
-
":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
452
|
-
":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
453
|
-
":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow
|
|
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
|
-
|
|
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 (
|
|
554
|
-
|
|
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,
|