@gemboss/gem-framework 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/SOURCE.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "@gemboss/gem-framework",
3
- "version": "0.4.0",
4
- "gemtheme_sha": "6c4690cdfe8c48e980cb55d8455370bb39a60701",
3
+ "version": "0.5.1",
4
+ "gemtheme_sha": "b0ca8d5022be58f1deb0102c302de2b2ee6ef12c",
5
5
  "gemtheme_dirty": false,
6
6
  "framework_version": "0.1.0"
7
7
  }
@@ -19,6 +19,9 @@
19
19
  --gem-space-xl: calc(32px * var(--gem-style-space, 1));
20
20
  --gem-space-2xl: calc(48px * var(--gem-style-space, 1));
21
21
  --gem-space-3xl: calc(64px * var(--gem-style-space, 1));
22
+ --gem-space-2xs: calc(var(--gem-space-md) * 0.125);
23
+ --gem-space-ms: calc(var(--gem-space-md) * 0.75);
24
+ --gem-space-4xl: calc(var(--gem-space-md) * 7);
22
25
  --gem-radius-sm: calc(4px * var(--gem-style-radius, 1));
23
26
  --gem-radius-md: calc(8px * var(--gem-style-radius, 1));
24
27
  --gem-radius-lg: calc(16px * var(--gem-style-radius, 1));
@@ -59,6 +62,7 @@
59
62
  --gem-h1: calc(var(--gem-h2) * var(--gem-type-scale));
60
63
  --gem-focus-ring: 2px solid var(--gem-color-accent);
61
64
  --gem-tap-min: 44px;
65
+ --gem-focus-offset: 3px;
62
66
  }
63
67
 
64
68
  *, *::before, *::after {
@@ -93,9 +97,15 @@ button, [type='button'], [type='submit'] {
93
97
  a {
94
98
  color: inherit;
95
99
  }
100
+ :where(.gem-section, [data-gem-section-id]) {
101
+ --gem-focus-ring: 2px solid var(--gem-color-accent);
102
+ }
96
103
  :focus-visible {
97
104
  outline: var(--gem-focus-ring, 2px solid var(--gem-color-accent));
98
- outline-offset: 3px;
105
+ outline-offset: var(--gem-focus-offset, 3px);
106
+ }
107
+ :where([data-gem-section-background='accent'], [data-gem-section-background='dark']) :focus-visible:focus-visible {
108
+ box-shadow: 0 0 0 calc(var(--gem-focus-offset, 3px) + 4px) var(--gem-band-bg);
99
109
  }
100
110
  .gem-skip-to-content-link:focus {
101
111
  position: fixed;
@@ -267,6 +277,46 @@ a {
267
277
  color: var(--gem-color-text);
268
278
  padding: 0 var(--gem-space-md);
269
279
  }
280
+ .gem-field-join {
281
+ display: flex;
282
+ align-items: stretch;
283
+ min-width: 0;
284
+ }
285
+ .gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden) {
286
+ min-height: var(--gem-component-input-height, 44px);
287
+ margin: 0;
288
+ }
289
+ .gem-field-join > :is(.gem-input, .gem-select, .gem-form-field) {
290
+ flex: 1 1 10rem;
291
+ width: auto;
292
+ min-width: min(10rem, 50%);
293
+ }
294
+ .gem-field-join > .gem-form-field > :is(.gem-input, .gem-select) {
295
+ width: 100%;
296
+ height: 100%;
297
+ min-height: var(--gem-component-input-height, 44px);
298
+ }
299
+ .gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(.gem-input, .gem-select, .gem-form-field) {
300
+ flex: 0 1 auto;
301
+ min-width: var(--gem-component-input-height, 44px);
302
+ white-space: normal;
303
+ overflow-wrap: anywhere;
304
+ }
305
+ .gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(:nth-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))), .gem-field-join > .gem-form-field:not(:nth-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))) > :is(.gem-input, .gem-select) {
306
+ border-start-start-radius: 0;
307
+ border-end-start-radius: 0;
308
+ }
309
+ .gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(:nth-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))) {
310
+ margin-inline-start: calc(-1 * var(--gem-input-border-width, 1px));
311
+ }
312
+ .gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(:nth-last-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))), .gem-field-join > .gem-form-field:not(:nth-last-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))) > :is(.gem-input, .gem-select) {
313
+ border-start-end-radius: 0;
314
+ border-end-end-radius: 0;
315
+ }
316
+ .gem-field-join > :focus-within {
317
+ position: relative;
318
+ z-index: 1;
319
+ }
270
320
  .gem-textarea {
271
321
  min-height: calc(var(--gem-component-input-height, 44px) * 2);
272
322
  padding-block: var(--gem-space-sm);
@@ -307,10 +357,17 @@ a {
307
357
  display: inline-flex;
308
358
  align-items: center;
309
359
  justify-content: center;
310
- inline-size: var(--gem-space-lg);
311
- block-size: var(--gem-space-lg);
360
+ inline-size: var(--gem-icon-size, var(--gem-space-lg));
361
+ block-size: var(--gem-icon-size, var(--gem-space-lg));
312
362
  color: var(--gem-color-accent);
313
363
  }
364
+ .gem-icon--sm { --gem-icon-size: 16px; }
365
+ .gem-icon--md { --gem-icon-size: 20px; }
366
+ .gem-icon--lg { --gem-icon-size: 24px; }
367
+ .gem-icon--current { color: currentColor; }
368
+ .gem-icon--chevron { transition: rotate var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease); }
369
+ :is(button, summary, [role='button'])[aria-expanded='true'] > .gem-icon--chevron, details[open] > summary > .gem-icon--chevron { rotate: 180deg; }
370
+ @media (prefers-reduced-motion: reduce) { .gem-icon--chevron { transition: none; } }
314
371
  .gem-icon-list {
315
372
  display: grid;
316
373
  gap: var(--gem-space-sm);
@@ -1021,6 +1078,10 @@ a {
1021
1078
  :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); }
1022
1079
  :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); }
1023
1080
  :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); }
1081
+ :root:not(:has([data-gem-canvas])) [data-gem-section-id][data-gem-countdown-ended='true'] { display: none; }
1082
+ :root:has([data-gem-canvas]) [data-gem-section-id][data-gem-countdown-ended='true'] { opacity: .55; }
1083
+ .gem-countdown-ended-note { display: none; }
1084
+ :root:has([data-gem-canvas]) [data-gem-countdown-ended='true'] .gem-countdown-ended-note { display: block; margin: 0; padding: .25rem 0; font-size: .75rem; font-weight: 700; text-align: center; }
1024
1085
  .gem-promo-banner, .gem-promo-tile {
1025
1086
  border: var(--gem-border-subtle, 1px solid var(--gem-color-border));
1026
1087
  border-radius: var(--gem-radius-md);
@@ -1124,6 +1185,11 @@ a {
1124
1185
  inset: 0;
1125
1186
  background: rgba(0, 0, 0, 0.36);
1126
1187
  }
1188
+ @keyframes gem-cart-drawer-panel-in { from { translate: calc(var(--gem-drawer-offset, 24px) * var(--gem-drawer-dir, 1)) 0; opacity: 0; } }
1189
+ @keyframes gem-cart-drawer-overlay-in { from { opacity: 0; } }
1190
+ .gem-cart-drawer__panel:dir(rtl) { --gem-drawer-dir: -1; }
1191
+ .gem-cart-drawer[data-gem-drawer-motion='slide'] .gem-cart-drawer__overlay { animation: gem-cart-drawer-overlay-in var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease); }
1192
+ .gem-cart-drawer[data-gem-drawer-motion='slide'] .gem-cart-drawer__panel { animation: gem-cart-drawer-panel-in var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease); }
1127
1193
  .gem-cart-drawer__panel {
1128
1194
  position: fixed;
1129
1195
  inset-block: 0;
@@ -1136,6 +1202,7 @@ a {
1136
1202
  box-shadow: var(--gem-shadow-md, none);
1137
1203
  padding: var(--gem-space-lg);
1138
1204
  }
1205
+ @media (prefers-reduced-motion: reduce) { .gem-cart-drawer[data-gem-drawer-motion] :is(.gem-cart-drawer__panel, .gem-cart-drawer__overlay) { animation: none; } }
1139
1206
  .gem-cart-drawer__header, .gem-cart-drawer__footer {
1140
1207
  display: flex;
1141
1208
  flex-wrap: wrap;
@@ -1989,8 +2056,8 @@ sticky-header.header-wrapper {
1989
2056
  :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; }
1990
2057
  :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='default'] { --gem-section-bg: var(--gem-color-background); background: transparent; }
1991
2058
  :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='surface'] { --gem-section-bg: var(--gem-color-surface); background: var(--gem-section-bg); padding-block: var(--gem-space-xl); }
1992
- :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='accent'] { --gem-section-bg: var(--gem-color-accent); background: var(--gem-section-bg); color: var(--gem-color-accent-text); padding-block: var(--gem-space-xl); }
1993
- :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='dark'] { --gem-section-bg: var(--gem-color-text); background: var(--gem-section-bg); color: var(--gem-color-background); padding-block: var(--gem-space-xl); }
2059
+ :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='accent'] { --gem-section-bg: var(--gem-color-accent); background: var(--gem-section-bg); color: var(--gem-color-accent-text); --gem-band-bg: var(--gem-color-accent); --gem-focus-ring: 2px solid var(--gem-color-accent-text); padding-block: var(--gem-space-xl); }
2060
+ :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='dark'] { --gem-section-bg: var(--gem-color-text); background: var(--gem-section-bg); color: var(--gem-color-background); --gem-band-bg: var(--gem-color-text); --gem-focus-ring: 2px solid var(--gem-color-background); padding-block: var(--gem-space-xl); }
1994
2061
  :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='light'] { --gem-section-bg: var(--gem-color-background); background: var(--gem-section-bg); color: var(--gem-color-text); padding-block: var(--gem-space-xl); }
1995
2062
  :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='accent'] :is(.gem-section__heading, .gem-section__body, .gem-section__eyebrow), :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='dark'] :is(.gem-section__heading, .gem-section__body, .gem-section__eyebrow) { color: inherit; }
1996
2063
  :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-price-color='accent'] :is(.gem-price, .gem-price__regular, [class*='__price']) { color: var(--gem-color-accent); }
@@ -2151,6 +2218,7 @@ sticky-header.header-wrapper {
2151
2218
  }
2152
2219
  :where(.gem-section[data-gem-eyebrow='show']) .gem-section__eyebrow { display: block; letter-spacing: 0.12em; }
2153
2220
  :where(.gem-section[data-gem-heading='section']) .gem-section__heading { margin: 0; font-family: var(--gem-font-heading); font-size: var(--gem-h2); line-height: 1.1; letter-spacing: -0.01em; text-wrap: balance; }
2221
+ :where(.gem-section[data-gem-body='section']) .gem-section__body { margin: 0; color: var(--gem-color-muted); font-size: var(--gem-body); line-height: var(--gem-font-body-line-height, 1.5); }
2154
2222
  @media (max-width: 760px) {
2155
2223
  :where(.gem-section[data-gem-heading='section']) .gem-section__heading { font-size: min(var(--gem-h2), clamp(1.75rem, 9vw, 2.75rem)); }
2156
2224
  }
@@ -2369,6 +2437,7 @@ main > .gem-section:first-child { padding-top: 0; }
2369
2437
  .gem-shell-footer__localization .gem-select, .gem-shell-footer__localization > .gem-select { display: inline-flex; align-items: center; min-width: 210px; min-height: 45px; border: 1px solid rgb(18 18 18 / 55%); border-radius: 0; background: transparent; padding: 10px 36px 10px 12px; color: rgb(18 18 18); font-size: 13px; line-height: 18px; }
2370
2438
  :where(.gem-section[data-gem-eyebrow='show']) .gem-section__eyebrow { display: block; letter-spacing: 0.12em; }
2371
2439
  :where(.gem-section[data-gem-heading='section']) .gem-section__heading { margin: 0; font-family: var(--gem-font-heading); font-size: var(--gem-h2); line-height: 1.1; letter-spacing: -0.01em; text-wrap: balance; }
2440
+ :where(.gem-section[data-gem-body='section']) .gem-section__body { margin: 0; color: var(--gem-color-muted); font-size: var(--gem-body); line-height: var(--gem-font-body-line-height, 1.5); }
2372
2441
  @media (max-width: 760px) {
2373
2442
  :root { --gem-layout-gutter: 24px; --gem-layout-section: 36px; }
2374
2443
  .gem-shell-header, .gem-shell-header__inner, .header { min-height: 64px; }
@@ -645,32 +645,54 @@
645
645
  const timers = document.querySelectorAll('[data-gem-countdown], .gem-countdown');
646
646
  let bound = 0;
647
647
  const pad = (value) => String(Math.max(0, value)).padStart(2, '0');
648
+ const COUNTDOWN_ISO = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i;
649
+ const countdownEnd = (value) => {
650
+ if (typeof value !== 'string') return NaN;
651
+ const raw = value.trim();
652
+ const m = COUNTDOWN_ISO.exec(raw);
653
+ if (!m) return NaN;
654
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
655
+ const day = new Date(Date.UTC(y, mo - 1, d));
656
+ if (day.getUTCFullYear() !== y || day.getUTCMonth() !== mo - 1 || day.getUTCDate() !== d) return NaN;
657
+ if (m[4] === undefined) return Date.parse(m[1] + '-' + m[2] + '-' + m[3] + 'T23:59:59Z');
658
+ const iso = m[1] + '-' + m[2] + '-' + m[3] + 'T' + raw.slice(11);
659
+ return Date.parse(m[7] ? iso : iso + 'Z');
660
+ };
648
661
  const write = (timer, selector, value) => {
649
662
  const targetNode = timer.querySelector && timer.querySelector(selector);
650
663
  if (targetNode) targetNode.textContent = pad(value);
651
664
  };
652
665
  timers.forEach((timer) => {
653
666
  if (!mark(timer, 'countdown')) return;
654
- const targetIso = timer.getAttribute && timer.getAttribute('data-gem-countdown-target');
655
- const targetTime = targetIso ? Date.parse(targetIso) : NaN;
667
+ const targetTime = countdownEnd(timer.getAttribute && timer.getAttribute('data-gem-countdown-target'));
668
+ const section = timer.closest ? timer.closest('[data-gem-section-id]') : null;
669
+ const band = section && section.hasAttribute && section.hasAttribute('data-gem-countdown-end') ? section : null;
670
+ let handle = null;
656
671
  const update = () => {
657
672
  if (!Number.isFinite(targetTime)) {
658
673
  timer.dataset.gemCountdownState = 'invalid';
659
674
  return;
660
675
  }
661
- const remainingSeconds = Math.max(0, Math.floor((targetTime - Date.now()) / 1000));
676
+ const ms = targetTime - Date.now();
677
+ const remainingSeconds = Math.max(0, Math.floor(ms / 1000));
662
678
  const days = Math.floor(remainingSeconds / 86400);
663
679
  const hours = Math.floor((remainingSeconds % 86400) / 3600);
664
680
  const minutes = Math.floor((remainingSeconds % 3600) / 60);
665
- timer.dataset.gemCountdownState = remainingSeconds > 0 ? 'running' : 'complete';
681
+ timer.dataset.gemCountdownState = ms > 0 ? 'running' : 'complete';
666
682
  timer.dataset.gemCountdownRemainingSeconds = String(remainingSeconds);
667
683
  write(timer, '[data-gem-countdown-days]', days);
668
684
  write(timer, '[data-gem-countdown-hours]', hours);
669
685
  write(timer, '[data-gem-countdown-minutes]', minutes);
670
- api.emit(remainingSeconds > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });
686
+ if (ms <= 0) {
687
+ if (band) band.setAttribute('data-gem-countdown-ended', 'true');
688
+ if (handle !== null && target.clearInterval) { target.clearInterval(handle); handle = null; }
689
+ }
690
+ api.emit(ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });
671
691
  };
672
692
  update();
673
- if (target.setInterval && Number.isFinite(targetTime)) target.setInterval(update, 60000);
693
+ if (target.setInterval && Number.isFinite(targetTime) && targetTime - Date.now() > 0) handle = target.setInterval(update, 60000);
694
+ const left = targetTime - Date.now();
695
+ if (target.setTimeout && Number.isFinite(left) && left > 0 && left < 2147483647) target.setTimeout(update, left);
674
696
  bound += 1;
675
697
  });
676
698
  api.emit('gem:countdown:ready', { source: 'gem-runtime', count: bound });
package/dist/index.js CHANGED
@@ -82,6 +82,27 @@ function gemPlanShapeKnobs(plan) {
82
82
  var gemFrameworkVersion = "0.1.0";
83
83
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
84
84
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
85
+ var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
86
+ var GEM_DERIVED_SPACE_STEPS = { "2xs": 0.125, ms: 0.75, "4xl": 7 };
87
+ var GEM_DERIVED_SPACE_CSS_VARIABLES = Object.keys(GEM_DERIVED_SPACE_STEPS).map((key) => `--gem-space-${slugify(key)}`);
88
+ function gemDerivedSpaceCssVars(spacing) {
89
+ const named = new Set(Object.entries(spacing ?? {}).filter(([, v]) => String(v ?? "").trim()).map(([k]) => slugify(k)));
90
+ const md = gemSpaceMdKind(spacing?.md);
91
+ return Object.entries(GEM_DERIVED_SPACE_STEPS).filter(([key]) => !named.has(slugify(key))).map(([key, factor]) => `--gem-space-${slugify(key)}: ${derivedSpaceValue(md, factor)};`);
92
+ }
93
+ function gemSpaceMdKind(raw) {
94
+ const md = stripImportant(String(raw ?? "")).trim();
95
+ if (md === "") return "absent";
96
+ if (/^[+-]?(\d+\.?\d*|\.\d+)$/.test(md)) return Number(md) === 0 ? "zero" : "invalid";
97
+ if (/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?[a-z%]+$/i.test(md)) return "length";
98
+ if (/^(var|calc|clamp|min|max|env)\(.*\)$/i.test(md)) return "length";
99
+ return "invalid";
100
+ }
101
+ function derivedSpaceValue(md, factor) {
102
+ if (md === "zero") return "0";
103
+ if (md === "invalid") return `calc(${parseFloat(defaultGemTokens.spacing.md) * factor}px * var(--gem-style-space, 1))`;
104
+ return `calc(var(--gem-space-md) * ${factor})`;
105
+ }
85
106
  var GEM_ROW_CSS_VARIABLES = [
86
107
  "--gem-row-cols",
87
108
  "--gem-row-cols-md",
@@ -214,11 +235,12 @@ function validateGemTokens(tokens) {
214
235
  function createGemTokenCss(tokens, selector = ":root") {
215
236
  const declarations = [
216
237
  ...toCssVars("color", tokens.color),
217
- ...toCssVars("font", tokens.typography),
238
+ ...toCssVars("font", Object.fromEntries(Object.entries(tokens.typography ?? {}).filter(([k]) => !isRampKey(k)))),
218
239
  // Space ramp is scaled by the global density knob --gem-style-space (default 1),
219
240
  // so a style pack can make the WHOLE storefront airier or denser without touching
220
241
  // any section — every section already consumes --gem-space-*. Knob unset → identical.
221
242
  ...toScaledCssVars("space", tokens.spacing, "--gem-style-space"),
243
+ ...gemDerivedSpaceCssVars(tokens.spacing),
222
244
  // Radius ramp scaled by --gem-style-radius (default 1): a 'sharp' style sets it to
223
245
  // 0 (square everything), 'pill' rounds further — across every section, no edits.
224
246
  ...toScaledCssVars("radius", tokens.radius, "--gem-style-radius"),
@@ -239,24 +261,39 @@ function createGemTokenCss(tokens, selector = ":root") {
239
261
  // live framework defined them: a rebuild that followed the contract styled its headings with
240
262
  // variables that resolved to nothing. One modular scale, retuned by overriding --gem-type-base
241
263
  // or --gem-type-scale (see gemTypeRampCssVars).
242
- ...gemTypeRampCssVars(),
264
+ ...gemTypeRampCssVars(tokens.typography),
243
265
  // Focus ring (gemfactory request #3, 28/09/2026): 11 sections wrote `outline: 2px solid
244
266
  // var(--gem-color-accent)` by hand. Not a token GROUP (a group would name it --gem-border-focus-ring
245
267
  // and a required key would fail every stored plan/preset without it), so it is emitted here like the
246
268
  // type ramp and registered in validation.ts. Follows the accent, so presets retint it for free.
247
269
  GEM_FOCUS_RING_CSS_VAR,
248
- GEM_TAP_MIN_CSS_VAR
270
+ GEM_TAP_MIN_CSS_VAR,
271
+ GEM_FOCUS_OFFSET_CSS_VAR
249
272
  ];
273
+ const baseMobile = typeRampValue(tokens.typography, "baseMobile");
250
274
  return [
251
275
  `${selector} {`,
252
276
  ...declarations.map((line) => ` ${line}`),
253
- "}"
277
+ "}",
278
+ ...baseMobile ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ` --gem-type-base: ${baseMobile};`, " }", "}"] : []
254
279
  ].join("\n");
255
280
  }
256
- function gemTypeRampCssVars() {
281
+ var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
282
+ var rampKey = (k) => k.toLowerCase().replace(/[^a-z0-9]/g, "");
283
+ var isRampKey = (k) => GEM_TYPE_RAMP_KEYS.some((r) => rampKey(r) === rampKey(k));
284
+ var LENGTH = /^\d*\.?\d+(px|rem|em)$/;
285
+ var NUMBER = /^\d*\.?\d+$/;
286
+ var typeRampValue = (typography, key) => {
287
+ const hit = Object.entries(typography ?? {}).find(([k]) => rampKey(k) === rampKey(key));
288
+ const v = hit == null || hit[1] == null ? "" : stripImportant(hit[1]).trim();
289
+ if (!v) return null;
290
+ return (key === "scale" ? NUMBER.test(v) && Number(v) > 0 : LENGTH.test(v)) ? v : null;
291
+ };
292
+ var GEM_TYPE_MOBILE_MAX = "749px";
293
+ function gemTypeRampCssVars(typography) {
257
294
  return [
258
- "--gem-type-base: 1rem;",
259
- "--gem-type-scale: 1.22;",
295
+ `--gem-type-base: ${typeRampValue(typography, "base") ?? "1rem"};`,
296
+ `--gem-type-scale: ${typeRampValue(typography, "scale") ?? "1.22"};`,
260
297
  "--gem-text-sm: calc(var(--gem-type-base) / var(--gem-type-scale));",
261
298
  "--gem-body: var(--gem-type-base);",
262
299
  "--gem-h6: calc(var(--gem-type-base) * var(--gem-type-scale));",
@@ -333,7 +370,9 @@ var gemFrameworkPrimitiveGroups = [
333
370
  },
334
371
  {
335
372
  group: "surfaces",
336
- classes: ["gem-card", "gem-media", "gem-badge", "gem-accordion", "gem-divider"]
373
+ // `gem-countdown-ended-note`: the editor-canvas line every countdown band carries, styled once in
374
+ // primitives.ts GEM_COUNTDOWN_ENDED_CSS (hidden on the store, shown on an ended band in the canvas).
375
+ classes: ["gem-card", "gem-media", "gem-badge", "gem-accordion", "gem-divider", "gem-countdown-ended-note"]
337
376
  },
338
377
  {
339
378
  group: "forms",
@@ -344,7 +383,9 @@ var gemFrameworkPrimitiveGroups = [
344
383
  "gem-select",
345
384
  "gem-textarea",
346
385
  "gem-submit",
347
- "gem-newsletter-form"
386
+ "gem-newsletter-form",
387
+ // Input + button as one control (primitives.ts, gemfactory request #8).
388
+ "gem-field-join"
348
389
  ]
349
390
  },
350
391
  {
@@ -356,6 +397,12 @@ var gemFrameworkPrimitiveGroups = [
356
397
  // storefront alongside gem-section__ctas.
357
398
  "gem-prose",
358
399
  "gem-icon",
400
+ // Size / colour / rotate hooks (gemfactory request #11); the bare class renders as before.
401
+ "gem-icon--sm",
402
+ "gem-icon--md",
403
+ "gem-icon--lg",
404
+ "gem-icon--current",
405
+ "gem-icon--chevron",
359
406
  "gem-icon-list",
360
407
  "gem-icon-list__item",
361
408
  "gem-video",
@@ -768,6 +815,13 @@ var GEM_CARD_CSS_LINES = [
768
815
  ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-card='filled'] :is(.gem-section__inner, [class*='__inner']) { background: color-mix(in srgb, currentColor 6%, transparent); }"
769
816
  ];
770
817
  var GEM_CARD_CSS = GEM_CARD_CSS_LINES.join("\n");
818
+ var GEM_COUNTDOWN_ENDED_CSS_LINES = [
819
+ ":root:not(:has([data-gem-canvas])) [data-gem-section-id][data-gem-countdown-ended='true'] { display: none; }",
820
+ ":root:has([data-gem-canvas]) [data-gem-section-id][data-gem-countdown-ended='true'] { opacity: .55; }",
821
+ ".gem-countdown-ended-note { display: none; }",
822
+ ":root:has([data-gem-canvas]) [data-gem-countdown-ended='true'] .gem-countdown-ended-note { display: block; margin: 0; padding: .25rem 0; font-size: .75rem; font-weight: 700; text-align: center; }"
823
+ ];
824
+ var GEM_COUNTDOWN_ENDED_CSS = GEM_COUNTDOWN_ENDED_CSS_LINES.join("\n");
771
825
  var GEM_REVEAL_CSS_LINES = [
772
826
  // Longhand, so the per-element duration/delay knobs can reach it. The shorthand hard-coded 600ms,
773
827
  // while `gem_animation_duration` and `gem_animation_delay` are declared on ALL FIFTY kinds in the
@@ -891,9 +945,26 @@ function createGemBaseCss(profile = "dawn-parity") {
891
945
  "a {",
892
946
  " color: inherit;",
893
947
  "}",
948
+ // Vòng focus theo NỀN (gemsite #761, review #655; đo Chromium 03/10/2026).
949
+ // 1. --gem-focus-ring khai ở :root là "2px solid var(--gem-color-accent)" và var() bị thay MỘT LẦN ở :root: section
950
+ // đặt lại accent (bảng màu) vẫn nhận accent của trang. Khai lại trên chính section để var() tính theo section.
951
+ // 2. Núm nền accent / dark tô nền mà KHÔNG đổi accent: vòng accent trên nền accent là không thấy (1:1). Trong băng đó
952
+ // vòng HAI MÀU (WCAG C40): nét màu chữ của băng + quầng màu nền của băng bao quanh. Hai màu tương phản nhau nên
953
+ // trên nền nào cũng có một màu nổi: băng tối thì nét sáng, ô sáng lồng trong băng tối thì quầng tối. Màu chữ của
954
+ // khối chứa KHÔNG lấy được bằng CSS: currentColor giữ nguyên từ khoá tới phần tử dùng nó (kể cả biến có kiểu
955
+ // @property), nên nút / ô nhập ra vòng màu chữ của chính mặt nút (đen trên băng tối, đo).
956
+ // Đặt --gem-focus-ring ở :root không tới được trong section.
957
+ ":where(.gem-section, [data-gem-section-id]) {",
958
+ " --gem-focus-ring: 2px solid var(--gem-color-accent);",
959
+ "}",
894
960
  ":focus-visible {",
895
961
  " outline: var(--gem-focus-ring, 2px solid var(--gem-color-accent));",
896
- " outline-offset: 3px;",
962
+ " outline-offset: var(--gem-focus-offset, 3px);",
963
+ "}",
964
+ // :focus-visible hai lần = độ ưu tiên (0,2,0): thắng `.gem-button { box-shadow: none }` của profile Dawn (0,1,0, nằm
965
+ // sau) đã xoá quầng của nút trên ô sáng lồng trong băng (đo); luật focus riêng của section (≥ 0,3,0) vẫn thắng nó.
966
+ ":where([data-gem-section-background='accent'], [data-gem-section-background='dark']) :focus-visible:focus-visible {",
967
+ " box-shadow: 0 0 0 calc(var(--gem-focus-offset, 3px) + 4px) var(--gem-band-bg);",
897
968
  "}",
898
969
  ".gem-skip-to-content-link:focus {",
899
970
  " position: fixed;",
@@ -1105,6 +1176,56 @@ function createPrimitiveCss() {
1105
1176
  " color: var(--gem-color-text);",
1106
1177
  " padding: 0 var(--gem-space-md);",
1107
1178
  "}",
1179
+ // .gem-field-join (gemfactory request #8, 01/10/2026; 16 stored footers trimmed the touching corners by hand): an
1180
+ // input/select and its button as one block (newsletter, discount code). Only VISIBLE children count: Shopify forms carry hidden inputs (form_type, utf8) and a sr-only
1181
+ // label, and with plain :first-child those would square the visible outer corners and take a 44px slot. A field
1182
+ // may be wrapped in .gem-form-field. The field grows with a floor of min(10rem, 50%); the button sizes to its
1183
+ // label (dropping dawn-parity's 12rem minimum, which at 320px left the field 80px wide) and wraps a long label
1184
+ // instead of squeezing the field or overflowing. Only the corners where two children meet are squared, in logical
1185
+ // properties so it mirrors under RTL, and the seam overlaps by one border width so it is not drawn twice. A child
1186
+ // holding focus is lifted so its ring is not painted under the neighbour. Stays one row at every width.
1187
+ ".gem-field-join {",
1188
+ " display: flex;",
1189
+ " align-items: stretch;",
1190
+ " min-width: 0;",
1191
+ "}",
1192
+ '.gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden) {',
1193
+ " min-height: var(--gem-component-input-height, 44px);",
1194
+ " margin: 0;",
1195
+ "}",
1196
+ ".gem-field-join > :is(.gem-input, .gem-select, .gem-form-field) {",
1197
+ " flex: 1 1 10rem;",
1198
+ " width: auto;",
1199
+ " min-width: min(10rem, 50%);",
1200
+ "}",
1201
+ // A wrapped field gets the join's height too: the dawn-parity profile sets .gem-input { min-height: 4.5rem }, and
1202
+ // only the direct-child rule above outranked it, so a wrapped input stood 72px next to a 44px one (02/10/2026).
1203
+ ".gem-field-join > .gem-form-field > :is(.gem-input, .gem-select) {",
1204
+ " width: 100%;",
1205
+ " height: 100%;",
1206
+ " min-height: var(--gem-component-input-height, 44px);",
1207
+ "}",
1208
+ '.gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(.gem-input, .gem-select, .gem-form-field) {',
1209
+ " flex: 0 1 auto;",
1210
+ " min-width: var(--gem-component-input-height, 44px);",
1211
+ " white-space: normal;",
1212
+ " overflow-wrap: anywhere;",
1213
+ "}",
1214
+ '.gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(:nth-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))), .gem-field-join > .gem-form-field:not(:nth-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))) > :is(.gem-input, .gem-select) {',
1215
+ " border-start-start-radius: 0;",
1216
+ " border-end-start-radius: 0;",
1217
+ "}",
1218
+ '.gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(:nth-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))) {',
1219
+ " margin-inline-start: calc(-1 * var(--gem-input-border-width, 1px));",
1220
+ "}",
1221
+ '.gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(:nth-last-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))), .gem-field-join > .gem-form-field:not(:nth-last-child(1 of :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden))) > :is(.gem-input, .gem-select) {',
1222
+ " border-start-end-radius: 0;",
1223
+ " border-end-end-radius: 0;",
1224
+ "}",
1225
+ ".gem-field-join > :focus-within {",
1226
+ " position: relative;",
1227
+ " z-index: 1;",
1228
+ "}",
1108
1229
  ".gem-textarea {",
1109
1230
  " min-height: calc(var(--gem-component-input-height, 44px) * 2);",
1110
1231
  " padding-block: var(--gem-space-sm);",
@@ -1141,14 +1262,26 @@ function createPrimitiveCss() {
1141
1262
  " color: var(--gem-color-muted, color-mix(in srgb, var(--gem-color-text) 72%, transparent));",
1142
1263
  " line-height: 1.6;",
1143
1264
  "}",
1265
+ // Icon size and colour hooks (gemfactory request #11, 01/10/2026: 33 sections reset colour to inherit and wrote
1266
+ // 22/14/16/20px by hand). Additive only: with no modifier and no --gem-icon-size an icon renders exactly as before
1267
+ // (the spacing step, the accent). Changing those DEFAULTS is a design decision left open on #11.
1144
1268
  ".gem-icon {",
1145
1269
  " display: inline-flex;",
1146
1270
  " align-items: center;",
1147
1271
  " justify-content: center;",
1148
- " inline-size: var(--gem-space-lg);",
1149
- " block-size: var(--gem-space-lg);",
1272
+ " inline-size: var(--gem-icon-size, var(--gem-space-lg));",
1273
+ " block-size: var(--gem-icon-size, var(--gem-space-lg));",
1150
1274
  " color: var(--gem-color-accent);",
1151
1275
  "}",
1276
+ ".gem-icon--sm { --gem-icon-size: 16px; }",
1277
+ ".gem-icon--md { --gem-icon-size: 20px; }",
1278
+ ".gem-icon--lg { --gem-icon-size: 24px; }",
1279
+ ".gem-icon--current { color: currentColor; }",
1280
+ ".gem-icon--chevron { transition: rotate var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease); }",
1281
+ // Only the TOGGLE's own state: the runtime also sets aria-expanded on containers (the open cart drawer, the
1282
+ // header drawer), and a descendant match would flip every chevron inside them (review #650).
1283
+ ":is(button, summary, [role='button'])[aria-expanded='true'] > .gem-icon--chevron, details[open] > summary > .gem-icon--chevron { rotate: 180deg; }",
1284
+ "@media (prefers-reduced-motion: reduce) { .gem-icon--chevron { transition: none; } }",
1152
1285
  ".gem-icon-list {",
1153
1286
  " display: grid;",
1154
1287
  " gap: var(--gem-space-sm);",
@@ -1856,6 +1989,7 @@ function createPrimitiveCss() {
1856
1989
  ...GEM_AMBIENT_CSS_LINES,
1857
1990
  ...GEM_DIVIDER_CSS_LINES,
1858
1991
  ...GEM_CARD_CSS_LINES,
1992
+ ...GEM_COUNTDOWN_ENDED_CSS_LINES,
1859
1993
  ".gem-promo-banner, .gem-promo-tile {",
1860
1994
  " border: var(--gem-border-subtle, 1px solid var(--gem-color-border));",
1861
1995
  " border-radius: var(--gem-radius-md);",
@@ -1959,6 +2093,18 @@ function createPrimitiveCss() {
1959
2093
  " inset: 0;",
1960
2094
  " background: rgba(0, 0, 0, 0.36);",
1961
2095
  "}",
2096
+ // Opening motion (gemfactory request #13, 01/10/2026): 28 stored drawers each declared their own
2097
+ // `@keyframes …-in { from { transform: translateX(24px); opacity: 0 } }`. OPT-IN on the drawer root,
2098
+ // data-gem-drawer-motion="slide", so no existing drawer changes (review #650: as a default the overlay fade
2099
+ // reached all 28): the panel slides in by --gem-drawer-offset (24px) and the overlay fades. A drawer drops its own
2100
+ // keyframes by adding the attribute. The panel sits at inline-end, so under RTL it comes from the left.
2101
+ // :dir(rtl) follows the HTML dir attribute through inheritance, so a dir="ltr" island inside an rtl page stays
2102
+ // ltr (a `[dir='rtl'] .x` descendant rule got that wrong). CSS `direction` alone is invisible to selectors. Off under prefers-reduced-motion.
2103
+ "@keyframes gem-cart-drawer-panel-in { from { translate: calc(var(--gem-drawer-offset, 24px) * var(--gem-drawer-dir, 1)) 0; opacity: 0; } }",
2104
+ "@keyframes gem-cart-drawer-overlay-in { from { opacity: 0; } }",
2105
+ ".gem-cart-drawer__panel:dir(rtl) { --gem-drawer-dir: -1; }",
2106
+ ".gem-cart-drawer[data-gem-drawer-motion='slide'] .gem-cart-drawer__overlay { animation: gem-cart-drawer-overlay-in var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease); }",
2107
+ ".gem-cart-drawer[data-gem-drawer-motion='slide'] .gem-cart-drawer__panel { animation: gem-cart-drawer-panel-in var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease); }",
1962
2108
  ".gem-cart-drawer__panel {",
1963
2109
  " position: fixed;",
1964
2110
  " inset-block: 0;",
@@ -1971,6 +2117,9 @@ function createPrimitiveCss() {
1971
2117
  " box-shadow: var(--gem-shadow-md, none);",
1972
2118
  " padding: var(--gem-space-lg);",
1973
2119
  "}",
2120
+ // AFTER the opt-in rules and at THEIR specificity: measured 02/10/2026, a reduced-motion rule placed before the
2121
+ // animation rule (or weaker than it) left the panel animating.
2122
+ "@media (prefers-reduced-motion: reduce) { .gem-cart-drawer[data-gem-drawer-motion] :is(.gem-cart-drawer__panel, .gem-cart-drawer__overlay) { animation: none; } }",
1974
2123
  ".gem-cart-drawer__header, .gem-cart-drawer__footer {",
1975
2124
  " display: flex;",
1976
2125
  " flex-wrap: wrap;",
@@ -2346,6 +2495,7 @@ function createGemSectionRuntimeContractCss(selector = "[data-gem-section-librar
2346
2495
  }
2347
2496
  var OPT_IN_EYEBROW = ":where(.gem-section[data-gem-eyebrow='show']) .gem-section__eyebrow { display: block; letter-spacing: 0.12em; }";
2348
2497
  var OPT_IN_HEADING = ":where(.gem-section[data-gem-heading='section']) .gem-section__heading { margin: 0; font-family: var(--gem-font-heading); font-size: var(--gem-h2); line-height: 1.1; letter-spacing: -0.01em; text-wrap: balance; }";
2498
+ var OPT_IN_BODY = ":where(.gem-section[data-gem-body='section']) .gem-section__body { margin: 0; color: var(--gem-color-muted); font-size: var(--gem-body); line-height: var(--gem-font-body-line-height, 1.5); }";
2349
2499
  var OPT_IN_HEADING_MOBILE = " :where(.gem-section[data-gem-heading='section']) .gem-section__heading { font-size: min(var(--gem-h2), clamp(1.75rem, 9vw, 2.75rem)); }";
2350
2500
  function createSectionCss() {
2351
2501
  return [
@@ -2492,8 +2642,8 @@ function createSectionCss() {
2492
2642
  // rather than re-deriving the swatch in every divider rule.
2493
2643
  ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='default'] { --gem-section-bg: var(--gem-color-background); background: transparent; }",
2494
2644
  ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='surface'] { --gem-section-bg: var(--gem-color-surface); background: var(--gem-section-bg); padding-block: var(--gem-space-xl); }",
2495
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='accent'] { --gem-section-bg: var(--gem-color-accent); background: var(--gem-section-bg); color: var(--gem-color-accent-text); padding-block: var(--gem-space-xl); }",
2496
- ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='dark'] { --gem-section-bg: var(--gem-color-text); background: var(--gem-section-bg); color: var(--gem-color-background); padding-block: var(--gem-space-xl); }",
2645
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='accent'] { --gem-section-bg: var(--gem-color-accent); background: var(--gem-section-bg); color: var(--gem-color-accent-text); --gem-band-bg: var(--gem-color-accent); --gem-focus-ring: 2px solid var(--gem-color-accent-text); padding-block: var(--gem-space-xl); }",
2646
+ ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='dark'] { --gem-section-bg: var(--gem-color-text); background: var(--gem-section-bg); color: var(--gem-color-background); --gem-band-bg: var(--gem-color-text); --gem-focus-ring: 2px solid var(--gem-color-background); padding-block: var(--gem-space-xl); }",
2497
2647
  ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='light'] { --gem-section-bg: var(--gem-color-background); background: var(--gem-section-bg); color: var(--gem-color-text); padding-block: var(--gem-space-xl); }",
2498
2648
  // On accent/dark bands, keep muted sub-copy + headings legible against the inverted ground.
2499
2649
  ":is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='accent'] :is(.gem-section__heading, .gem-section__body, .gem-section__eyebrow), :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow, .gem-footer)[data-gem-section-background='dark'] :is(.gem-section__heading, .gem-section__body, .gem-section__eyebrow) { color: inherit; }",
@@ -2667,6 +2817,7 @@ function createSectionCss() {
2667
2817
  "}",
2668
2818
  OPT_IN_EYEBROW,
2669
2819
  OPT_IN_HEADING,
2820
+ OPT_IN_BODY,
2670
2821
  "@media (max-width: 760px) {",
2671
2822
  OPT_IN_HEADING_MOBILE,
2672
2823
  "}",
@@ -2917,6 +3068,7 @@ function createDawnParityCss() {
2917
3068
  // Opt-ins again, AFTER the Dawn rules above (see OPT_IN_EYEBROW).
2918
3069
  OPT_IN_EYEBROW,
2919
3070
  OPT_IN_HEADING,
3071
+ OPT_IN_BODY,
2920
3072
  createDawnParityMobileCss(),
2921
3073
  // The row again, after every Dawn rule including its phone block (see GEM_ROW_CSS_LINES).
2922
3074
  ...GEM_ROW_CSS_LINES
@@ -4335,37 +4487,64 @@ var beforeAfterPluginRegistrationLines = [
4335
4487
  " api.emit('gem:before-after:ready', { source: 'gem-runtime', count: bound });",
4336
4488
  " });"
4337
4489
  ];
4490
+ var COUNTDOWN_END_RUNTIME_LINES = [
4491
+ " const COUNTDOWN_ISO = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/i;",
4492
+ " const countdownEnd = (value) => {",
4493
+ " if (typeof value !== 'string') return NaN;",
4494
+ " const raw = value.trim();",
4495
+ " const m = COUNTDOWN_ISO.exec(raw);",
4496
+ " if (!m) return NaN;",
4497
+ " const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);",
4498
+ " const day = new Date(Date.UTC(y, mo - 1, d));",
4499
+ " if (day.getUTCFullYear() !== y || day.getUTCMonth() !== mo - 1 || day.getUTCDate() !== d) return NaN;",
4500
+ " if (m[4] === undefined) return Date.parse(m[1] + '-' + m[2] + '-' + m[3] + 'T23:59:59Z');",
4501
+ " const iso = m[1] + '-' + m[2] + '-' + m[3] + 'T' + raw.slice(11);",
4502
+ " return Date.parse(m[7] ? iso : iso + 'Z');",
4503
+ " };"
4504
+ ];
4338
4505
  var countdownPluginRegistrationLines = [
4339
4506
  " registerPlugin('countdown', ({ api }) => {",
4340
4507
  " const timers = document.querySelectorAll('[data-gem-countdown], .gem-countdown');",
4341
4508
  " let bound = 0;",
4342
4509
  " const pad = (value) => String(Math.max(0, value)).padStart(2, '0');",
4510
+ ...COUNTDOWN_END_RUNTIME_LINES.map((line) => " " + line),
4343
4511
  " const write = (timer, selector, value) => {",
4344
4512
  " const targetNode = timer.querySelector && timer.querySelector(selector);",
4345
4513
  " if (targetNode) targetNode.textContent = pad(value);",
4346
4514
  " };",
4347
4515
  " timers.forEach((timer) => {",
4348
4516
  " if (!mark(timer, 'countdown')) return;",
4349
- " const targetIso = timer.getAttribute && timer.getAttribute('data-gem-countdown-target');",
4350
- " const targetTime = targetIso ? Date.parse(targetIso) : NaN;",
4517
+ " const targetTime = countdownEnd(timer.getAttribute && timer.getAttribute('data-gem-countdown-target'));",
4518
+ " const section = timer.closest ? timer.closest('[data-gem-section-id]') : null;",
4519
+ " const band = section && section.hasAttribute && section.hasAttribute('data-gem-countdown-end') ? section : null;",
4520
+ " let handle = null;",
4351
4521
  " const update = () => {",
4352
4522
  " if (!Number.isFinite(targetTime)) {",
4353
4523
  " timer.dataset.gemCountdownState = 'invalid';",
4354
4524
  " return;",
4355
4525
  " }",
4356
- " const remainingSeconds = Math.max(0, Math.floor((targetTime - Date.now()) / 1000));",
4526
+ " const ms = targetTime - Date.now();",
4527
+ " const remainingSeconds = Math.max(0, Math.floor(ms / 1000));",
4357
4528
  " const days = Math.floor(remainingSeconds / 86400);",
4358
4529
  " const hours = Math.floor((remainingSeconds % 86400) / 3600);",
4359
4530
  " const minutes = Math.floor((remainingSeconds % 3600) / 60);",
4360
- " timer.dataset.gemCountdownState = remainingSeconds > 0 ? 'running' : 'complete';",
4531
+ " timer.dataset.gemCountdownState = ms > 0 ? 'running' : 'complete';",
4361
4532
  " timer.dataset.gemCountdownRemainingSeconds = String(remainingSeconds);",
4362
4533
  " write(timer, '[data-gem-countdown-days]', days);",
4363
4534
  " write(timer, '[data-gem-countdown-hours]', hours);",
4364
4535
  " write(timer, '[data-gem-countdown-minutes]', minutes);",
4365
- " api.emit(remainingSeconds > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });",
4536
+ " if (ms <= 0) {",
4537
+ " if (band) band.setAttribute('data-gem-countdown-ended', 'true');",
4538
+ " if (handle !== null && target.clearInterval) { target.clearInterval(handle); handle = null; }",
4539
+ " }",
4540
+ " api.emit(ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });",
4366
4541
  " };",
4367
4542
  " update();",
4368
- " if (target.setInterval && Number.isFinite(targetTime)) target.setInterval(update, 60000);",
4543
+ " if (target.setInterval && Number.isFinite(targetTime) && targetTime - Date.now() > 0) handle = target.setInterval(update, 60000);",
4544
+ // The readout moves once a minute, but the END is not allowed to wait for the next minute: one timer
4545
+ // for the exact instant (while it fits a setTimeout, ~24.8 days; past that the minute tick gets there).
4546
+ " const left = targetTime - Date.now();",
4547
+ " if (target.setTimeout && Number.isFinite(left) && left > 0 && left < 2147483647) target.setTimeout(update, left);",
4369
4548
  " bound += 1;",
4370
4549
  " });",
4371
4550
  " api.emit('gem:countdown:ready', { source: 'gem-runtime', count: bound });",
@@ -4717,6 +4896,12 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
4717
4896
  // --gem-focus-ring: emitted with the tokens, not a group (tokens.ts GEM_FOCUS_RING_CSS_VAR).
4718
4897
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
4719
4898
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
4899
+ GEM_FOCUS_OFFSET_CSS_VAR.split(":")[0].trim(),
4900
+ // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
4901
+ ...GEM_DERIVED_SPACE_CSS_VARIABLES,
4902
+ // Extension points a section SETS (gemtheme #650): icon size, drawer slide distance.
4903
+ "--gem-icon-size",
4904
+ "--gem-drawer-offset",
4720
4905
  // The row primitive's variables (primitives.ts GEM_ROW_CSS). Sections SET these on their grid.
4721
4906
  ...GEM_ROW_CSS_VARIABLES
4722
4907
  ]);
@@ -5997,7 +6182,9 @@ function stringSetting(value) {
5997
6182
  return typeof value === "string" && value.length > 0 ? value : void 0;
5998
6183
  }
5999
6184
  export {
6185
+ COUNTDOWN_END_RUNTIME_LINES,
6000
6186
  GEM_AMBIENT_CSS,
6187
+ GEM_COUNTDOWN_ENDED_CSS,
6001
6188
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
6002
6189
  GEM_PART_CLASS,
6003
6190
  GEM_REVEAL_CSS,
@@ -1,6 +1,9 @@
1
1
  // ../../packages/gem-framework/src/tokens.ts
2
2
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
3
3
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
4
+ var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
5
+ var GEM_DERIVED_SPACE_STEPS = { "2xs": 0.125, ms: 0.75, "4xl": 7 };
6
+ var GEM_DERIVED_SPACE_CSS_VARIABLES = Object.keys(GEM_DERIVED_SPACE_STEPS).map((key) => `--gem-space-${slugify(key)}`);
4
7
  var GEM_ROW_CSS_VARIABLES = [
5
8
  "--gem-row-cols",
6
9
  "--gem-row-cols-md",
@@ -23,10 +26,19 @@ var requiredGemTokenKeys = {
23
26
  component: ["headerHeight", "cardPadding", "buttonPaddingInline", "buttonPaddingBlock", "inputHeight"],
24
27
  morphology: ["campaignDensity", "mediaRhythm", "proofIntensity", "commerceEmphasis", "policyTone"]
25
28
  };
26
- function gemTypeRampCssVars() {
29
+ var rampKey = (k) => k.toLowerCase().replace(/[^a-z0-9]/g, "");
30
+ var LENGTH = /^\d*\.?\d+(px|rem|em)$/;
31
+ var NUMBER = /^\d*\.?\d+$/;
32
+ var typeRampValue = (typography, key) => {
33
+ const hit = Object.entries(typography ?? {}).find(([k]) => rampKey(k) === rampKey(key));
34
+ const v = hit == null || hit[1] == null ? "" : stripImportant(hit[1]).trim();
35
+ if (!v) return null;
36
+ return (key === "scale" ? NUMBER.test(v) && Number(v) > 0 : LENGTH.test(v)) ? v : null;
37
+ };
38
+ function gemTypeRampCssVars(typography) {
27
39
  return [
28
- "--gem-type-base: 1rem;",
29
- "--gem-type-scale: 1.22;",
40
+ `--gem-type-base: ${typeRampValue(typography, "base") ?? "1rem"};`,
41
+ `--gem-type-scale: ${typeRampValue(typography, "scale") ?? "1.22"};`,
30
42
  "--gem-text-sm: calc(var(--gem-type-base) / var(--gem-type-scale));",
31
43
  "--gem-body: var(--gem-type-base);",
32
44
  "--gem-h6: calc(var(--gem-type-base) * var(--gem-type-scale));",
@@ -37,6 +49,11 @@ function gemTypeRampCssVars() {
37
49
  "--gem-h1: calc(var(--gem-h2) * var(--gem-type-scale));"
38
50
  ];
39
51
  }
52
+ function stripImportant(value) {
53
+ const s = String(value);
54
+ if (!/!\s*important/i.test(s)) return s;
55
+ return s.replace(/!\s*important/gi, " ").replace(/\s+/g, " ").replace(/\s+,/g, ",").trim();
56
+ }
40
57
  function slugify(value) {
41
58
  return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
42
59
  }
@@ -60,7 +77,9 @@ var gemFrameworkPrimitiveGroups = [
60
77
  },
61
78
  {
62
79
  group: "surfaces",
63
- classes: ["gem-card", "gem-media", "gem-badge", "gem-accordion", "gem-divider"]
80
+ // `gem-countdown-ended-note`: the editor-canvas line every countdown band carries, styled once in
81
+ // primitives.ts GEM_COUNTDOWN_ENDED_CSS (hidden on the store, shown on an ended band in the canvas).
82
+ classes: ["gem-card", "gem-media", "gem-badge", "gem-accordion", "gem-divider", "gem-countdown-ended-note"]
64
83
  },
65
84
  {
66
85
  group: "forms",
@@ -71,7 +90,9 @@ var gemFrameworkPrimitiveGroups = [
71
90
  "gem-select",
72
91
  "gem-textarea",
73
92
  "gem-submit",
74
- "gem-newsletter-form"
93
+ "gem-newsletter-form",
94
+ // Input + button as one control (primitives.ts, gemfactory request #8).
95
+ "gem-field-join"
75
96
  ]
76
97
  },
77
98
  {
@@ -83,6 +104,12 @@ var gemFrameworkPrimitiveGroups = [
83
104
  // storefront alongside gem-section__ctas.
84
105
  "gem-prose",
85
106
  "gem-icon",
107
+ // Size / colour / rotate hooks (gemfactory request #11); the bare class renders as before.
108
+ "gem-icon--sm",
109
+ "gem-icon--md",
110
+ "gem-icon--lg",
111
+ "gem-icon--current",
112
+ "gem-icon--chevron",
86
113
  "gem-icon-list",
87
114
  "gem-icon-list__item",
88
115
  "gem-video",
@@ -356,6 +383,13 @@ var GEM_CARD_CSS_LINES = [
356
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); }"
357
384
  ];
358
385
  var GEM_CARD_CSS = GEM_CARD_CSS_LINES.join("\n");
386
+ var GEM_COUNTDOWN_ENDED_CSS_LINES = [
387
+ ":root:not(:has([data-gem-canvas])) [data-gem-section-id][data-gem-countdown-ended='true'] { display: none; }",
388
+ ":root:has([data-gem-canvas]) [data-gem-section-id][data-gem-countdown-ended='true'] { opacity: .55; }",
389
+ ".gem-countdown-ended-note { display: none; }",
390
+ ":root:has([data-gem-canvas]) [data-gem-countdown-ended='true'] .gem-countdown-ended-note { display: block; margin: 0; padding: .25rem 0; font-size: .75rem; font-weight: 700; text-align: center; }"
391
+ ];
392
+ var GEM_COUNTDOWN_ENDED_CSS = GEM_COUNTDOWN_ENDED_CSS_LINES.join("\n");
359
393
  var GEM_REVEAL_CSS_LINES = [
360
394
  // Longhand, so the per-element duration/delay knobs can reach it. The shorthand hard-coded 600ms,
361
395
  // while `gem_animation_duration` and `gem_animation_delay` are declared on ALL FIFTY kinds in the
@@ -463,6 +497,69 @@ function offerEngineRuntimeScript() {
463
497
  }
464
498
 
465
499
  // ../../packages/gem-framework/src/runtime.ts
500
+ var COUNTDOWN_END_RUNTIME_LINES = [
501
+ " const COUNTDOWN_ISO = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/i;",
502
+ " const countdownEnd = (value) => {",
503
+ " if (typeof value !== 'string') return NaN;",
504
+ " const raw = value.trim();",
505
+ " const m = COUNTDOWN_ISO.exec(raw);",
506
+ " if (!m) return NaN;",
507
+ " const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);",
508
+ " const day = new Date(Date.UTC(y, mo - 1, d));",
509
+ " if (day.getUTCFullYear() !== y || day.getUTCMonth() !== mo - 1 || day.getUTCDate() !== d) return NaN;",
510
+ " if (m[4] === undefined) return Date.parse(m[1] + '-' + m[2] + '-' + m[3] + 'T23:59:59Z');",
511
+ " const iso = m[1] + '-' + m[2] + '-' + m[3] + 'T' + raw.slice(11);",
512
+ " return Date.parse(m[7] ? iso : iso + 'Z');",
513
+ " };"
514
+ ];
515
+ var countdownPluginRegistrationLines = [
516
+ " registerPlugin('countdown', ({ api }) => {",
517
+ " const timers = document.querySelectorAll('[data-gem-countdown], .gem-countdown');",
518
+ " let bound = 0;",
519
+ " const pad = (value) => String(Math.max(0, value)).padStart(2, '0');",
520
+ ...COUNTDOWN_END_RUNTIME_LINES.map((line) => " " + line),
521
+ " const write = (timer, selector, value) => {",
522
+ " const targetNode = timer.querySelector && timer.querySelector(selector);",
523
+ " if (targetNode) targetNode.textContent = pad(value);",
524
+ " };",
525
+ " timers.forEach((timer) => {",
526
+ " if (!mark(timer, 'countdown')) return;",
527
+ " const targetTime = countdownEnd(timer.getAttribute && timer.getAttribute('data-gem-countdown-target'));",
528
+ " const section = timer.closest ? timer.closest('[data-gem-section-id]') : null;",
529
+ " const band = section && section.hasAttribute && section.hasAttribute('data-gem-countdown-end') ? section : null;",
530
+ " let handle = null;",
531
+ " const update = () => {",
532
+ " if (!Number.isFinite(targetTime)) {",
533
+ " timer.dataset.gemCountdownState = 'invalid';",
534
+ " return;",
535
+ " }",
536
+ " const ms = targetTime - Date.now();",
537
+ " const remainingSeconds = Math.max(0, Math.floor(ms / 1000));",
538
+ " const days = Math.floor(remainingSeconds / 86400);",
539
+ " const hours = Math.floor((remainingSeconds % 86400) / 3600);",
540
+ " const minutes = Math.floor((remainingSeconds % 3600) / 60);",
541
+ " timer.dataset.gemCountdownState = ms > 0 ? 'running' : 'complete';",
542
+ " timer.dataset.gemCountdownRemainingSeconds = String(remainingSeconds);",
543
+ " write(timer, '[data-gem-countdown-days]', days);",
544
+ " write(timer, '[data-gem-countdown-hours]', hours);",
545
+ " write(timer, '[data-gem-countdown-minutes]', minutes);",
546
+ " if (ms <= 0) {",
547
+ " if (band) band.setAttribute('data-gem-countdown-ended', 'true');",
548
+ " if (handle !== null && target.clearInterval) { target.clearInterval(handle); handle = null; }",
549
+ " }",
550
+ " api.emit(ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });",
551
+ " };",
552
+ " 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
555
+ // for the exact instant (while it fits a setTimeout, ~24.8 days; past that the minute tick gets there).
556
+ " const left = targetTime - Date.now();",
557
+ " if (target.setTimeout && Number.isFinite(left) && left > 0 && left < 2147483647) target.setTimeout(update, left);",
558
+ " bound += 1;",
559
+ " });",
560
+ " api.emit('gem:countdown:ready', { source: 'gem-runtime', count: bound });",
561
+ " });"
562
+ ];
466
563
  var offerEnginePluginRegistrationLines = [
467
564
  " registerPlugin('offer-engine', ({ api }) => {",
468
565
  " " + offerEngineRuntimeScript(),
@@ -488,6 +585,12 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
488
585
  // --gem-focus-ring: emitted with the tokens, not a group (tokens.ts GEM_FOCUS_RING_CSS_VAR).
489
586
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
490
587
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
588
+ GEM_FOCUS_OFFSET_CSS_VAR.split(":")[0].trim(),
589
+ // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
590
+ ...GEM_DERIVED_SPACE_CSS_VARIABLES,
591
+ // Extension points a section SETS (gemtheme #650): icon size, drawer slide distance.
592
+ "--gem-icon-size",
593
+ "--gem-drawer-offset",
491
594
  // The row primitive's variables (primitives.ts GEM_ROW_CSS). Sections SET these on their grid.
492
595
  ...GEM_ROW_CSS_VARIABLES
493
596
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/gem-framework",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "gem-framework của GemTheme (CSS, runtime, token, bộ kiểm) cộng đúng bộ kiểm section gemtheme dùng khi duyệt. Build từ gemtheme, không sửa ở đây.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",