@gemboss/gem-framework 0.4.0 → 0.5.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/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.0",
4
+ "gemtheme_sha": "02d4924e5c672fd4ae6f21f4ead858dcfa08e4e3",
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 {
@@ -95,7 +99,7 @@ a {
95
99
  }
96
100
  :focus-visible {
97
101
  outline: var(--gem-focus-ring, 2px solid var(--gem-color-accent));
98
- outline-offset: 3px;
102
+ outline-offset: var(--gem-focus-offset, 3px);
99
103
  }
100
104
  .gem-skip-to-content-link:focus {
101
105
  position: fixed;
@@ -267,6 +271,46 @@ a {
267
271
  color: var(--gem-color-text);
268
272
  padding: 0 var(--gem-space-md);
269
273
  }
274
+ .gem-field-join {
275
+ display: flex;
276
+ align-items: stretch;
277
+ min-width: 0;
278
+ }
279
+ .gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden) {
280
+ min-height: var(--gem-component-input-height, 44px);
281
+ margin: 0;
282
+ }
283
+ .gem-field-join > :is(.gem-input, .gem-select, .gem-form-field) {
284
+ flex: 1 1 10rem;
285
+ width: auto;
286
+ min-width: min(10rem, 50%);
287
+ }
288
+ .gem-field-join > .gem-form-field > :is(.gem-input, .gem-select) {
289
+ width: 100%;
290
+ height: 100%;
291
+ min-height: var(--gem-component-input-height, 44px);
292
+ }
293
+ .gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(.gem-input, .gem-select, .gem-form-field) {
294
+ flex: 0 1 auto;
295
+ min-width: var(--gem-component-input-height, 44px);
296
+ white-space: normal;
297
+ overflow-wrap: anywhere;
298
+ }
299
+ .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) {
300
+ border-start-start-radius: 0;
301
+ border-end-start-radius: 0;
302
+ }
303
+ .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))) {
304
+ margin-inline-start: calc(-1 * var(--gem-input-border-width, 1px));
305
+ }
306
+ .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) {
307
+ border-start-end-radius: 0;
308
+ border-end-end-radius: 0;
309
+ }
310
+ .gem-field-join > :focus-within {
311
+ position: relative;
312
+ z-index: 1;
313
+ }
270
314
  .gem-textarea {
271
315
  min-height: calc(var(--gem-component-input-height, 44px) * 2);
272
316
  padding-block: var(--gem-space-sm);
@@ -307,10 +351,17 @@ a {
307
351
  display: inline-flex;
308
352
  align-items: center;
309
353
  justify-content: center;
310
- inline-size: var(--gem-space-lg);
311
- block-size: var(--gem-space-lg);
354
+ inline-size: var(--gem-icon-size, var(--gem-space-lg));
355
+ block-size: var(--gem-icon-size, var(--gem-space-lg));
312
356
  color: var(--gem-color-accent);
313
357
  }
358
+ .gem-icon--sm { --gem-icon-size: 16px; }
359
+ .gem-icon--md { --gem-icon-size: 20px; }
360
+ .gem-icon--lg { --gem-icon-size: 24px; }
361
+ .gem-icon--current { color: currentColor; }
362
+ .gem-icon--chevron { transition: rotate var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease); }
363
+ :is(button, summary, [role='button'])[aria-expanded='true'] > .gem-icon--chevron, details[open] > summary > .gem-icon--chevron { rotate: 180deg; }
364
+ @media (prefers-reduced-motion: reduce) { .gem-icon--chevron { transition: none; } }
314
365
  .gem-icon-list {
315
366
  display: grid;
316
367
  gap: var(--gem-space-sm);
@@ -1021,6 +1072,10 @@ a {
1021
1072
  :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
1073
  :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
1074
  :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); }
1075
+ :root:not(:has([data-gem-canvas])) [data-gem-section-id][data-gem-countdown-ended='true'] { display: none; }
1076
+ :root:has([data-gem-canvas]) [data-gem-section-id][data-gem-countdown-ended='true'] { opacity: .55; }
1077
+ .gem-countdown-ended-note { display: none; }
1078
+ :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
1079
  .gem-promo-banner, .gem-promo-tile {
1025
1080
  border: var(--gem-border-subtle, 1px solid var(--gem-color-border));
1026
1081
  border-radius: var(--gem-radius-md);
@@ -1124,6 +1179,11 @@ a {
1124
1179
  inset: 0;
1125
1180
  background: rgba(0, 0, 0, 0.36);
1126
1181
  }
1182
+ @keyframes gem-cart-drawer-panel-in { from { translate: calc(var(--gem-drawer-offset, 24px) * var(--gem-drawer-dir, 1)) 0; opacity: 0; } }
1183
+ @keyframes gem-cart-drawer-overlay-in { from { opacity: 0; } }
1184
+ .gem-cart-drawer__panel:dir(rtl) { --gem-drawer-dir: -1; }
1185
+ .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); }
1186
+ .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
1187
  .gem-cart-drawer__panel {
1128
1188
  position: fixed;
1129
1189
  inset-block: 0;
@@ -1136,6 +1196,7 @@ a {
1136
1196
  box-shadow: var(--gem-shadow-md, none);
1137
1197
  padding: var(--gem-space-lg);
1138
1198
  }
1199
+ @media (prefers-reduced-motion: reduce) { .gem-cart-drawer[data-gem-drawer-motion] :is(.gem-cart-drawer__panel, .gem-cart-drawer__overlay) { animation: none; } }
1139
1200
  .gem-cart-drawer__header, .gem-cart-drawer__footer {
1140
1201
  display: flex;
1141
1202
  flex-wrap: wrap;
@@ -2151,6 +2212,7 @@ sticky-header.header-wrapper {
2151
2212
  }
2152
2213
  :where(.gem-section[data-gem-eyebrow='show']) .gem-section__eyebrow { display: block; letter-spacing: 0.12em; }
2153
2214
  :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; }
2215
+ :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
2216
  @media (max-width: 760px) {
2155
2217
  :where(.gem-section[data-gem-heading='section']) .gem-section__heading { font-size: min(var(--gem-h2), clamp(1.75rem, 9vw, 2.75rem)); }
2156
2218
  }
@@ -2369,6 +2431,7 @@ main > .gem-section:first-child { padding-top: 0; }
2369
2431
  .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
2432
  :where(.gem-section[data-gem-eyebrow='show']) .gem-section__eyebrow { display: block; letter-spacing: 0.12em; }
2371
2433
  :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; }
2434
+ :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
2435
  @media (max-width: 760px) {
2373
2436
  :root { --gem-layout-gutter: 24px; --gem-layout-section: 36px; }
2374
2437
  .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,15 @@ 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 = String(spacing?.md ?? "").trim();
91
+ const mdUnitless = md !== "" && !/\d\s*[a-z%]/i.test(stripImportant(md));
92
+ return Object.entries(GEM_DERIVED_SPACE_STEPS).filter(([key]) => !named.has(slugify(key))).map(([key, factor]) => `--gem-space-${slugify(key)}: ${mdUnitless ? "0" : `calc(var(--gem-space-md) * ${factor})`};`);
93
+ }
85
94
  var GEM_ROW_CSS_VARIABLES = [
86
95
  "--gem-row-cols",
87
96
  "--gem-row-cols-md",
@@ -214,11 +223,12 @@ function validateGemTokens(tokens) {
214
223
  function createGemTokenCss(tokens, selector = ":root") {
215
224
  const declarations = [
216
225
  ...toCssVars("color", tokens.color),
217
- ...toCssVars("font", tokens.typography),
226
+ ...toCssVars("font", Object.fromEntries(Object.entries(tokens.typography ?? {}).filter(([k]) => !isRampKey(k)))),
218
227
  // Space ramp is scaled by the global density knob --gem-style-space (default 1),
219
228
  // so a style pack can make the WHOLE storefront airier or denser without touching
220
229
  // any section — every section already consumes --gem-space-*. Knob unset → identical.
221
230
  ...toScaledCssVars("space", tokens.spacing, "--gem-style-space"),
231
+ ...gemDerivedSpaceCssVars(tokens.spacing),
222
232
  // Radius ramp scaled by --gem-style-radius (default 1): a 'sharp' style sets it to
223
233
  // 0 (square everything), 'pill' rounds further — across every section, no edits.
224
234
  ...toScaledCssVars("radius", tokens.radius, "--gem-style-radius"),
@@ -239,24 +249,39 @@ function createGemTokenCss(tokens, selector = ":root") {
239
249
  // live framework defined them: a rebuild that followed the contract styled its headings with
240
250
  // variables that resolved to nothing. One modular scale, retuned by overriding --gem-type-base
241
251
  // or --gem-type-scale (see gemTypeRampCssVars).
242
- ...gemTypeRampCssVars(),
252
+ ...gemTypeRampCssVars(tokens.typography),
243
253
  // Focus ring (gemfactory request #3, 28/09/2026): 11 sections wrote `outline: 2px solid
244
254
  // var(--gem-color-accent)` by hand. Not a token GROUP (a group would name it --gem-border-focus-ring
245
255
  // and a required key would fail every stored plan/preset without it), so it is emitted here like the
246
256
  // type ramp and registered in validation.ts. Follows the accent, so presets retint it for free.
247
257
  GEM_FOCUS_RING_CSS_VAR,
248
- GEM_TAP_MIN_CSS_VAR
258
+ GEM_TAP_MIN_CSS_VAR,
259
+ GEM_FOCUS_OFFSET_CSS_VAR
249
260
  ];
261
+ const baseMobile = typeRampValue(tokens.typography, "baseMobile");
250
262
  return [
251
263
  `${selector} {`,
252
264
  ...declarations.map((line) => ` ${line}`),
253
- "}"
265
+ "}",
266
+ ...baseMobile ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ` --gem-type-base: ${baseMobile};`, " }", "}"] : []
254
267
  ].join("\n");
255
268
  }
256
- function gemTypeRampCssVars() {
269
+ var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
270
+ var rampKey = (k) => k.toLowerCase().replace(/[^a-z0-9]/g, "");
271
+ var isRampKey = (k) => GEM_TYPE_RAMP_KEYS.some((r) => rampKey(r) === rampKey(k));
272
+ var LENGTH = /^\d*\.?\d+(px|rem|em)$/;
273
+ var NUMBER = /^\d*\.?\d+$/;
274
+ var typeRampValue = (typography, key) => {
275
+ const hit = Object.entries(typography ?? {}).find(([k]) => rampKey(k) === rampKey(key));
276
+ const v = hit == null || hit[1] == null ? "" : stripImportant(hit[1]).trim();
277
+ if (!v) return null;
278
+ return (key === "scale" ? NUMBER.test(v) && Number(v) > 0 : LENGTH.test(v)) ? v : null;
279
+ };
280
+ var GEM_TYPE_MOBILE_MAX = "749px";
281
+ function gemTypeRampCssVars(typography) {
257
282
  return [
258
- "--gem-type-base: 1rem;",
259
- "--gem-type-scale: 1.22;",
283
+ `--gem-type-base: ${typeRampValue(typography, "base") ?? "1rem"};`,
284
+ `--gem-type-scale: ${typeRampValue(typography, "scale") ?? "1.22"};`,
260
285
  "--gem-text-sm: calc(var(--gem-type-base) / var(--gem-type-scale));",
261
286
  "--gem-body: var(--gem-type-base);",
262
287
  "--gem-h6: calc(var(--gem-type-base) * var(--gem-type-scale));",
@@ -333,7 +358,9 @@ var gemFrameworkPrimitiveGroups = [
333
358
  },
334
359
  {
335
360
  group: "surfaces",
336
- classes: ["gem-card", "gem-media", "gem-badge", "gem-accordion", "gem-divider"]
361
+ // `gem-countdown-ended-note`: the editor-canvas line every countdown band carries, styled once in
362
+ // primitives.ts GEM_COUNTDOWN_ENDED_CSS (hidden on the store, shown on an ended band in the canvas).
363
+ classes: ["gem-card", "gem-media", "gem-badge", "gem-accordion", "gem-divider", "gem-countdown-ended-note"]
337
364
  },
338
365
  {
339
366
  group: "forms",
@@ -344,7 +371,9 @@ var gemFrameworkPrimitiveGroups = [
344
371
  "gem-select",
345
372
  "gem-textarea",
346
373
  "gem-submit",
347
- "gem-newsletter-form"
374
+ "gem-newsletter-form",
375
+ // Input + button as one control (primitives.ts, gemfactory request #8).
376
+ "gem-field-join"
348
377
  ]
349
378
  },
350
379
  {
@@ -356,6 +385,12 @@ var gemFrameworkPrimitiveGroups = [
356
385
  // storefront alongside gem-section__ctas.
357
386
  "gem-prose",
358
387
  "gem-icon",
388
+ // Size / colour / rotate hooks (gemfactory request #11); the bare class renders as before.
389
+ "gem-icon--sm",
390
+ "gem-icon--md",
391
+ "gem-icon--lg",
392
+ "gem-icon--current",
393
+ "gem-icon--chevron",
359
394
  "gem-icon-list",
360
395
  "gem-icon-list__item",
361
396
  "gem-video",
@@ -768,6 +803,13 @@ var GEM_CARD_CSS_LINES = [
768
803
  ":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
804
  ];
770
805
  var GEM_CARD_CSS = GEM_CARD_CSS_LINES.join("\n");
806
+ var GEM_COUNTDOWN_ENDED_CSS_LINES = [
807
+ ":root:not(:has([data-gem-canvas])) [data-gem-section-id][data-gem-countdown-ended='true'] { display: none; }",
808
+ ":root:has([data-gem-canvas]) [data-gem-section-id][data-gem-countdown-ended='true'] { opacity: .55; }",
809
+ ".gem-countdown-ended-note { display: none; }",
810
+ ":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; }"
811
+ ];
812
+ var GEM_COUNTDOWN_ENDED_CSS = GEM_COUNTDOWN_ENDED_CSS_LINES.join("\n");
771
813
  var GEM_REVEAL_CSS_LINES = [
772
814
  // Longhand, so the per-element duration/delay knobs can reach it. The shorthand hard-coded 600ms,
773
815
  // while `gem_animation_duration` and `gem_animation_delay` are declared on ALL FIFTY kinds in the
@@ -893,7 +935,7 @@ function createGemBaseCss(profile = "dawn-parity") {
893
935
  "}",
894
936
  ":focus-visible {",
895
937
  " outline: var(--gem-focus-ring, 2px solid var(--gem-color-accent));",
896
- " outline-offset: 3px;",
938
+ " outline-offset: var(--gem-focus-offset, 3px);",
897
939
  "}",
898
940
  ".gem-skip-to-content-link:focus {",
899
941
  " position: fixed;",
@@ -1105,6 +1147,56 @@ function createPrimitiveCss() {
1105
1147
  " color: var(--gem-color-text);",
1106
1148
  " padding: 0 var(--gem-space-md);",
1107
1149
  "}",
1150
+ // .gem-field-join (gemfactory request #8, 01/10/2026; 16 stored footers trimmed the touching corners by hand): an
1151
+ // 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
1152
+ // label, and with plain :first-child those would square the visible outer corners and take a 44px slot. A field
1153
+ // may be wrapped in .gem-form-field. The field grows with a floor of min(10rem, 50%); the button sizes to its
1154
+ // label (dropping dawn-parity's 12rem minimum, which at 320px left the field 80px wide) and wraps a long label
1155
+ // instead of squeezing the field or overflowing. Only the corners where two children meet are squared, in logical
1156
+ // properties so it mirrors under RTL, and the seam overlaps by one border width so it is not drawn twice. A child
1157
+ // holding focus is lifted so its ring is not painted under the neighbour. Stays one row at every width.
1158
+ ".gem-field-join {",
1159
+ " display: flex;",
1160
+ " align-items: stretch;",
1161
+ " min-width: 0;",
1162
+ "}",
1163
+ '.gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden) {',
1164
+ " min-height: var(--gem-component-input-height, 44px);",
1165
+ " margin: 0;",
1166
+ "}",
1167
+ ".gem-field-join > :is(.gem-input, .gem-select, .gem-form-field) {",
1168
+ " flex: 1 1 10rem;",
1169
+ " width: auto;",
1170
+ " min-width: min(10rem, 50%);",
1171
+ "}",
1172
+ // A wrapped field gets the join's height too: the dawn-parity profile sets .gem-input { min-height: 4.5rem }, and
1173
+ // only the direct-child rule above outranked it, so a wrapped input stood 72px next to a 44px one (02/10/2026).
1174
+ ".gem-field-join > .gem-form-field > :is(.gem-input, .gem-select) {",
1175
+ " width: 100%;",
1176
+ " height: 100%;",
1177
+ " min-height: var(--gem-component-input-height, 44px);",
1178
+ "}",
1179
+ '.gem-field-join > :not([type="hidden"]):not([hidden]):not(.gem-u-visually-hidden):not(.gem-input, .gem-select, .gem-form-field) {',
1180
+ " flex: 0 1 auto;",
1181
+ " min-width: var(--gem-component-input-height, 44px);",
1182
+ " white-space: normal;",
1183
+ " overflow-wrap: anywhere;",
1184
+ "}",
1185
+ '.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) {',
1186
+ " border-start-start-radius: 0;",
1187
+ " border-end-start-radius: 0;",
1188
+ "}",
1189
+ '.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))) {',
1190
+ " margin-inline-start: calc(-1 * var(--gem-input-border-width, 1px));",
1191
+ "}",
1192
+ '.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) {',
1193
+ " border-start-end-radius: 0;",
1194
+ " border-end-end-radius: 0;",
1195
+ "}",
1196
+ ".gem-field-join > :focus-within {",
1197
+ " position: relative;",
1198
+ " z-index: 1;",
1199
+ "}",
1108
1200
  ".gem-textarea {",
1109
1201
  " min-height: calc(var(--gem-component-input-height, 44px) * 2);",
1110
1202
  " padding-block: var(--gem-space-sm);",
@@ -1141,14 +1233,26 @@ function createPrimitiveCss() {
1141
1233
  " color: var(--gem-color-muted, color-mix(in srgb, var(--gem-color-text) 72%, transparent));",
1142
1234
  " line-height: 1.6;",
1143
1235
  "}",
1236
+ // Icon size and colour hooks (gemfactory request #11, 01/10/2026: 33 sections reset colour to inherit and wrote
1237
+ // 22/14/16/20px by hand). Additive only: with no modifier and no --gem-icon-size an icon renders exactly as before
1238
+ // (the spacing step, the accent). Changing those DEFAULTS is a design decision left open on #11.
1144
1239
  ".gem-icon {",
1145
1240
  " display: inline-flex;",
1146
1241
  " align-items: center;",
1147
1242
  " justify-content: center;",
1148
- " inline-size: var(--gem-space-lg);",
1149
- " block-size: var(--gem-space-lg);",
1243
+ " inline-size: var(--gem-icon-size, var(--gem-space-lg));",
1244
+ " block-size: var(--gem-icon-size, var(--gem-space-lg));",
1150
1245
  " color: var(--gem-color-accent);",
1151
1246
  "}",
1247
+ ".gem-icon--sm { --gem-icon-size: 16px; }",
1248
+ ".gem-icon--md { --gem-icon-size: 20px; }",
1249
+ ".gem-icon--lg { --gem-icon-size: 24px; }",
1250
+ ".gem-icon--current { color: currentColor; }",
1251
+ ".gem-icon--chevron { transition: rotate var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease); }",
1252
+ // Only the TOGGLE's own state: the runtime also sets aria-expanded on containers (the open cart drawer, the
1253
+ // header drawer), and a descendant match would flip every chevron inside them (review #650).
1254
+ ":is(button, summary, [role='button'])[aria-expanded='true'] > .gem-icon--chevron, details[open] > summary > .gem-icon--chevron { rotate: 180deg; }",
1255
+ "@media (prefers-reduced-motion: reduce) { .gem-icon--chevron { transition: none; } }",
1152
1256
  ".gem-icon-list {",
1153
1257
  " display: grid;",
1154
1258
  " gap: var(--gem-space-sm);",
@@ -1856,6 +1960,7 @@ function createPrimitiveCss() {
1856
1960
  ...GEM_AMBIENT_CSS_LINES,
1857
1961
  ...GEM_DIVIDER_CSS_LINES,
1858
1962
  ...GEM_CARD_CSS_LINES,
1963
+ ...GEM_COUNTDOWN_ENDED_CSS_LINES,
1859
1964
  ".gem-promo-banner, .gem-promo-tile {",
1860
1965
  " border: var(--gem-border-subtle, 1px solid var(--gem-color-border));",
1861
1966
  " border-radius: var(--gem-radius-md);",
@@ -1959,6 +2064,18 @@ function createPrimitiveCss() {
1959
2064
  " inset: 0;",
1960
2065
  " background: rgba(0, 0, 0, 0.36);",
1961
2066
  "}",
2067
+ // Opening motion (gemfactory request #13, 01/10/2026): 28 stored drawers each declared their own
2068
+ // `@keyframes …-in { from { transform: translateX(24px); opacity: 0 } }`. OPT-IN on the drawer root,
2069
+ // data-gem-drawer-motion="slide", so no existing drawer changes (review #650: as a default the overlay fade
2070
+ // reached all 28): the panel slides in by --gem-drawer-offset (24px) and the overlay fades. A drawer drops its own
2071
+ // keyframes by adding the attribute. The panel sits at inline-end, so under RTL it comes from the left.
2072
+ // :dir(rtl) follows the HTML dir attribute through inheritance, so a dir="ltr" island inside an rtl page stays
2073
+ // ltr (a `[dir='rtl'] .x` descendant rule got that wrong). CSS `direction` alone is invisible to selectors. Off under prefers-reduced-motion.
2074
+ "@keyframes gem-cart-drawer-panel-in { from { translate: calc(var(--gem-drawer-offset, 24px) * var(--gem-drawer-dir, 1)) 0; opacity: 0; } }",
2075
+ "@keyframes gem-cart-drawer-overlay-in { from { opacity: 0; } }",
2076
+ ".gem-cart-drawer__panel:dir(rtl) { --gem-drawer-dir: -1; }",
2077
+ ".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); }",
2078
+ ".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
2079
  ".gem-cart-drawer__panel {",
1963
2080
  " position: fixed;",
1964
2081
  " inset-block: 0;",
@@ -1971,6 +2088,9 @@ function createPrimitiveCss() {
1971
2088
  " box-shadow: var(--gem-shadow-md, none);",
1972
2089
  " padding: var(--gem-space-lg);",
1973
2090
  "}",
2091
+ // AFTER the opt-in rules and at THEIR specificity: measured 02/10/2026, a reduced-motion rule placed before the
2092
+ // animation rule (or weaker than it) left the panel animating.
2093
+ "@media (prefers-reduced-motion: reduce) { .gem-cart-drawer[data-gem-drawer-motion] :is(.gem-cart-drawer__panel, .gem-cart-drawer__overlay) { animation: none; } }",
1974
2094
  ".gem-cart-drawer__header, .gem-cart-drawer__footer {",
1975
2095
  " display: flex;",
1976
2096
  " flex-wrap: wrap;",
@@ -2346,6 +2466,7 @@ function createGemSectionRuntimeContractCss(selector = "[data-gem-section-librar
2346
2466
  }
2347
2467
  var OPT_IN_EYEBROW = ":where(.gem-section[data-gem-eyebrow='show']) .gem-section__eyebrow { display: block; letter-spacing: 0.12em; }";
2348
2468
  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; }";
2469
+ 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
2470
  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
2471
  function createSectionCss() {
2351
2472
  return [
@@ -2667,6 +2788,7 @@ function createSectionCss() {
2667
2788
  "}",
2668
2789
  OPT_IN_EYEBROW,
2669
2790
  OPT_IN_HEADING,
2791
+ OPT_IN_BODY,
2670
2792
  "@media (max-width: 760px) {",
2671
2793
  OPT_IN_HEADING_MOBILE,
2672
2794
  "}",
@@ -2917,6 +3039,7 @@ function createDawnParityCss() {
2917
3039
  // Opt-ins again, AFTER the Dawn rules above (see OPT_IN_EYEBROW).
2918
3040
  OPT_IN_EYEBROW,
2919
3041
  OPT_IN_HEADING,
3042
+ OPT_IN_BODY,
2920
3043
  createDawnParityMobileCss(),
2921
3044
  // The row again, after every Dawn rule including its phone block (see GEM_ROW_CSS_LINES).
2922
3045
  ...GEM_ROW_CSS_LINES
@@ -4335,37 +4458,64 @@ var beforeAfterPluginRegistrationLines = [
4335
4458
  " api.emit('gem:before-after:ready', { source: 'gem-runtime', count: bound });",
4336
4459
  " });"
4337
4460
  ];
4461
+ var COUNTDOWN_END_RUNTIME_LINES = [
4462
+ " const COUNTDOWN_ISO = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/i;",
4463
+ " const countdownEnd = (value) => {",
4464
+ " if (typeof value !== 'string') return NaN;",
4465
+ " const raw = value.trim();",
4466
+ " const m = COUNTDOWN_ISO.exec(raw);",
4467
+ " if (!m) return NaN;",
4468
+ " const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);",
4469
+ " const day = new Date(Date.UTC(y, mo - 1, d));",
4470
+ " if (day.getUTCFullYear() !== y || day.getUTCMonth() !== mo - 1 || day.getUTCDate() !== d) return NaN;",
4471
+ " if (m[4] === undefined) return Date.parse(m[1] + '-' + m[2] + '-' + m[3] + 'T23:59:59Z');",
4472
+ " const iso = m[1] + '-' + m[2] + '-' + m[3] + 'T' + raw.slice(11);",
4473
+ " return Date.parse(m[7] ? iso : iso + 'Z');",
4474
+ " };"
4475
+ ];
4338
4476
  var countdownPluginRegistrationLines = [
4339
4477
  " registerPlugin('countdown', ({ api }) => {",
4340
4478
  " const timers = document.querySelectorAll('[data-gem-countdown], .gem-countdown');",
4341
4479
  " let bound = 0;",
4342
4480
  " const pad = (value) => String(Math.max(0, value)).padStart(2, '0');",
4481
+ ...COUNTDOWN_END_RUNTIME_LINES.map((line) => " " + line),
4343
4482
  " const write = (timer, selector, value) => {",
4344
4483
  " const targetNode = timer.querySelector && timer.querySelector(selector);",
4345
4484
  " if (targetNode) targetNode.textContent = pad(value);",
4346
4485
  " };",
4347
4486
  " timers.forEach((timer) => {",
4348
4487
  " if (!mark(timer, 'countdown')) return;",
4349
- " const targetIso = timer.getAttribute && timer.getAttribute('data-gem-countdown-target');",
4350
- " const targetTime = targetIso ? Date.parse(targetIso) : NaN;",
4488
+ " const targetTime = countdownEnd(timer.getAttribute && timer.getAttribute('data-gem-countdown-target'));",
4489
+ " const section = timer.closest ? timer.closest('[data-gem-section-id]') : null;",
4490
+ " const band = section && section.hasAttribute && section.hasAttribute('data-gem-countdown-end') ? section : null;",
4491
+ " let handle = null;",
4351
4492
  " const update = () => {",
4352
4493
  " if (!Number.isFinite(targetTime)) {",
4353
4494
  " timer.dataset.gemCountdownState = 'invalid';",
4354
4495
  " return;",
4355
4496
  " }",
4356
- " const remainingSeconds = Math.max(0, Math.floor((targetTime - Date.now()) / 1000));",
4497
+ " const ms = targetTime - Date.now();",
4498
+ " const remainingSeconds = Math.max(0, Math.floor(ms / 1000));",
4357
4499
  " const days = Math.floor(remainingSeconds / 86400);",
4358
4500
  " const hours = Math.floor((remainingSeconds % 86400) / 3600);",
4359
4501
  " const minutes = Math.floor((remainingSeconds % 3600) / 60);",
4360
- " timer.dataset.gemCountdownState = remainingSeconds > 0 ? 'running' : 'complete';",
4502
+ " timer.dataset.gemCountdownState = ms > 0 ? 'running' : 'complete';",
4361
4503
  " timer.dataset.gemCountdownRemainingSeconds = String(remainingSeconds);",
4362
4504
  " write(timer, '[data-gem-countdown-days]', days);",
4363
4505
  " write(timer, '[data-gem-countdown-hours]', hours);",
4364
4506
  " 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 });",
4507
+ " if (ms <= 0) {",
4508
+ " if (band) band.setAttribute('data-gem-countdown-ended', 'true');",
4509
+ " if (handle !== null && target.clearInterval) { target.clearInterval(handle); handle = null; }",
4510
+ " }",
4511
+ " api.emit(ms > 0 ? 'gem:countdown:start' : 'gem:countdown:complete', { source: 'gem-runtime', id: timer.id || undefined, remainingSeconds });",
4366
4512
  " };",
4367
4513
  " update();",
4368
- " if (target.setInterval && Number.isFinite(targetTime)) target.setInterval(update, 60000);",
4514
+ " if (target.setInterval && Number.isFinite(targetTime) && targetTime - Date.now() > 0) handle = target.setInterval(update, 60000);",
4515
+ // The readout moves once a minute, but the END is not allowed to wait for the next minute: one timer
4516
+ // for the exact instant (while it fits a setTimeout, ~24.8 days; past that the minute tick gets there).
4517
+ " const left = targetTime - Date.now();",
4518
+ " if (target.setTimeout && Number.isFinite(left) && left > 0 && left < 2147483647) target.setTimeout(update, left);",
4369
4519
  " bound += 1;",
4370
4520
  " });",
4371
4521
  " api.emit('gem:countdown:ready', { source: 'gem-runtime', count: bound });",
@@ -4717,6 +4867,12 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
4717
4867
  // --gem-focus-ring: emitted with the tokens, not a group (tokens.ts GEM_FOCUS_RING_CSS_VAR).
4718
4868
  GEM_FOCUS_RING_CSS_VAR.split(":")[0].trim(),
4719
4869
  GEM_TAP_MIN_CSS_VAR.split(":")[0].trim(),
4870
+ GEM_FOCUS_OFFSET_CSS_VAR.split(":")[0].trim(),
4871
+ // The derived spacing steps (tokens.ts GEM_DERIVED_SPACE_STEPS): always emitted, never required.
4872
+ ...GEM_DERIVED_SPACE_CSS_VARIABLES,
4873
+ // Extension points a section SETS (gemtheme #650): icon size, drawer slide distance.
4874
+ "--gem-icon-size",
4875
+ "--gem-drawer-offset",
4720
4876
  // The row primitive's variables (primitives.ts GEM_ROW_CSS). Sections SET these on their grid.
4721
4877
  ...GEM_ROW_CSS_VARIABLES
4722
4878
  ]);
@@ -5997,7 +6153,9 @@ function stringSetting(value) {
5997
6153
  return typeof value === "string" && value.length > 0 ? value : void 0;
5998
6154
  }
5999
6155
  export {
6156
+ COUNTDOWN_END_RUNTIME_LINES,
6000
6157
  GEM_AMBIENT_CSS,
6158
+ GEM_COUNTDOWN_ENDED_CSS,
6001
6159
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
6002
6160
  GEM_PART_CLASS,
6003
6161
  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.0",
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",