@gemboss/gem-framework 0.9.8 → 0.10.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/index.js CHANGED
@@ -191,7 +191,7 @@ function gemPlanShapeKnobs(plan) {
191
191
  }
192
192
 
193
193
  // ../../packages/gem-framework/src/tokens.ts
194
- var gemFrameworkVersion = "0.1.0";
194
+ var gemFrameworkVersion = "0.10.0";
195
195
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
196
196
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
197
197
  var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
@@ -225,6 +225,73 @@ var GEM_ROW_CSS_VARIABLES = [
225
225
  "--gem-row-gap",
226
226
  "--gem-row-gap-y"
227
227
  ];
228
+ var GEM_BUTTON_TOKEN_KEYS = {
229
+ radius: ["button"],
230
+ typography: ["buttonWeight", "buttonTransform", "buttonLetterSpacing"]
231
+ };
232
+ var GEM_BUTTON_TOKEN_SHAPES = {
233
+ "radius.button": /^(0|\d*\.?\d+(px|rem|em|%)|var\(--gem-radius-[a-z0-9-]+\))$/i,
234
+ "typography.buttonWeight": /^(normal|bold|\d{1,4})$/i,
235
+ "typography.buttonTransform": /^(none|uppercase|lowercase|capitalize)$/i,
236
+ "typography.buttonLetterSpacing": /^(normal|0|-?\d*\.?\d+(em|px|rem))$/i
237
+ };
238
+ var buttonKeyNorm = (k) => k.toLowerCase().replace(/[_-]/g, "");
239
+ function buttonTokenValue(group, key, value) {
240
+ const shape = GEM_BUTTON_TOKEN_SHAPES[`${group}.${key}`];
241
+ if (!shape) return null;
242
+ const v = typeof value === "number" ? String(value) : typeof value === "string" ? stripImportant(value).trim() : "";
243
+ if (!shape.test(v)) return null;
244
+ if (key === "buttonWeight" && /^\d+$/.test(v) && (Number(v) < 1 || Number(v) > 1e3)) return null;
245
+ if (key === "button" && v === "0") return "0px";
246
+ return /^[a-z]+$/i.test(v) ? v.toLowerCase() : v.replace(/(px|rem|em)$/i, (u) => u.toLowerCase());
247
+ }
248
+ function readButtonTokens(tokens) {
249
+ const out = { groups: {}, values: {}, invalid: [] };
250
+ for (const [group, keys] of Object.entries(GEM_BUTTON_TOKEN_KEYS)) {
251
+ const values = tokens?.[group];
252
+ if (!values || typeof values !== "object" || Array.isArray(values)) continue;
253
+ const byNorm = new Map(keys.map((k) => [buttonKeyNorm(k), k]));
254
+ let copy = null;
255
+ for (const [rawKey, raw] of Object.entries(values)) {
256
+ const key = byNorm.get(buttonKeyNorm(rawKey));
257
+ if (!key) continue;
258
+ copy ??= { ...values };
259
+ delete copy[rawKey];
260
+ const v = buttonTokenValue(group, key, raw);
261
+ if (v === null) out.invalid.push({ path: `${group}.${rawKey}`, value: raw });
262
+ else {
263
+ copy[key] = v;
264
+ out.values[key] = v;
265
+ }
266
+ }
267
+ if (copy) out.groups[group] = copy;
268
+ }
269
+ return out;
270
+ }
271
+ function splitSelectorList(selector) {
272
+ const parts = [];
273
+ let depth = 0, cur = "";
274
+ for (const ch of selector) {
275
+ if (ch === "(" || ch === "[") depth++;
276
+ else if (ch === ")" || ch === "]") depth--;
277
+ if (ch === "," && depth === 0) {
278
+ parts.push(cur.trim());
279
+ cur = "";
280
+ } else cur += ch;
281
+ }
282
+ if (cur.trim()) parts.push(cur.trim());
283
+ return parts;
284
+ }
285
+ function gemButtonCaseCss(tokens, selector = ":root") {
286
+ const { values } = readButtonTokens(tokens);
287
+ const decls = [
288
+ values.buttonTransform && "text-transform: var(--gem-font-button-transform);",
289
+ values.buttonLetterSpacing && "letter-spacing: var(--gem-font-button-letter-spacing);"
290
+ ].filter(Boolean);
291
+ if (!decls.length) return "";
292
+ const scope = splitSelectorList(selector).map((sel) => `${sel} .gem-button:not(.gem-button--link)`).join(", ");
293
+ return `:where(${scope}) { ${decls.join(" ")} }`;
294
+ }
228
295
  var requiredGemTokenKeys = {
229
296
  color: ["background", "surface", "text", "muted", "accent", "accentText", "border", "success", "warning", "danger"],
230
297
  typography: ["body", "heading", "mono"],
@@ -237,6 +304,52 @@ var requiredGemTokenKeys = {
237
304
  component: ["headerHeight", "cardPadding", "buttonPaddingInline", "buttonPaddingBlock", "inputHeight"],
238
305
  morphology: ["campaignDensity", "mediaRhythm", "proofIntensity", "commerceEmphasis", "policyTone"]
239
306
  };
307
+ var GEM_PHONE_TOKEN_TARGETS = {
308
+ spacing: [...requiredGemTokenKeys.spacing, ...Object.keys(GEM_DERIVED_SPACE_STEPS)],
309
+ layout: ["section"]
310
+ };
311
+ var tokenKeyNorm = (k) => k.toLowerCase().replace(/[^a-z0-9]/g, "");
312
+ var PHONE_TARGET_BY_KEY = Object.fromEntries(
313
+ Object.entries(GEM_PHONE_TOKEN_TARGETS).map(([group, targets]) => [group, new Map(targets.map((t) => [`${tokenKeyNorm(t)}mobile`, t]))])
314
+ );
315
+ function gemPhoneTokenTarget(group, key) {
316
+ return PHONE_TARGET_BY_KEY[group]?.get(tokenKeyNorm(key)) ?? null;
317
+ }
318
+ function readPhoneTokens(tokens) {
319
+ const out = { spacing: {}, layout: {}, invalid: [] };
320
+ for (const group of ["spacing", "layout"]) {
321
+ const ownGroup = group === "spacing" ? /var\(\s*--gem-space-/i : /var\(\s*--gem-layout-/i;
322
+ for (const [key, raw] of Object.entries(tokens?.[group] ?? {})) {
323
+ const target = gemPhoneTokenTarget(group, key);
324
+ if (target == null) continue;
325
+ const v = stripImportant(String(raw ?? "")).trim();
326
+ const kind = gemSpaceMdKind(v);
327
+ if (kind === "absent") continue;
328
+ if (kind === "zero") out[group][target] = "0px";
329
+ else if (kind === "length" && !ownGroup.test(v)) out[group][target] = v;
330
+ else out.invalid.push({ path: `${group}.${key}`, value: raw, why: kind === "length" ? `it reads a ${group} variable, which re-declared on :root is a cycle` : "not a length (a number needs a unit, e.g. 70px)" });
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+ var GEM_LINE_HEIGHT_TOKEN_KEYS = ["headingLineHeight", "bodyLineHeight"];
336
+ var LINE_HEIGHT_SHAPE = /^(normal|\d*\.?\d+(px|rem|em|%)?|(var|calc|clamp|min|max)\(.*\))$/i;
337
+ var isLineHeightKey = (k) => GEM_LINE_HEIGHT_TOKEN_KEYS.some((l) => tokenKeyNorm(l) === tokenKeyNorm(k));
338
+ var badLineHeight = (k, v) => isLineHeightKey(k) && !LINE_HEIGHT_SHAPE.test(stripImportant(String(v ?? "")).trim());
339
+ var desktopEntries = (group, values) => values ? Object.fromEntries(Object.entries(values).filter(([key]) => gemPhoneTokenTarget(group, key) == null)) : values;
340
+ var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
341
+ var optionalGemTokenKeys = {
342
+ typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
343
+ spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
344
+ layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
345
+ radius: [...GEM_BUTTON_TOKEN_KEYS.radius]
346
+ };
347
+ var GEM_HEADING_SIZE_CSS_VARS = [
348
+ "--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
349
+ "--gem-hero-heading-size: calc(var(--gem-h1) * var(--gem-typography-heading-scale, 1));",
350
+ "--gem-section-heading-size-mobile: min(var(--gem-section-heading-size), var(--gem-heading-phone-cap, clamp(1.75rem, 9vw, 2.75rem)));",
351
+ "--gem-hero-heading-size-mobile: min(var(--gem-hero-heading-size), var(--gem-heading-phone-cap, clamp(1.75rem, 9vw, 2.75rem)));"
352
+ ];
240
353
  var defaultGemTokens = {
241
354
  color: {
242
355
  background: "#f6f6f3",
@@ -342,23 +455,37 @@ function validateGemTokens(tokens) {
342
455
  }
343
456
  }
344
457
  }
458
+ for (const bad of readButtonTokens(tokens).invalid) {
459
+ const shape = GEM_BUTTON_TOKEN_SHAPES[bad.path.replace(/\.[^.]+$/, (k) => "." + (Object.values(GEM_BUTTON_TOKEN_KEYS).flat().find((c) => buttonKeyNorm(c) === buttonKeyNorm(k.slice(1))) ?? k.slice(1)))];
460
+ issues.push(tokenIssue("warn", "invalid_button_token", `Gem token ${bad.path} = ${JSON.stringify(bad.value)} is not a valid value (${shape}); the button keeps its default.`, bad.path));
461
+ }
462
+ for (const bad of readPhoneTokens(tokens).invalid) {
463
+ issues.push(tokenIssue("warn", "invalid_phone_token", `Gem token ${bad.path} = ${JSON.stringify(bad.value)} is not used: ${bad.why}. Phones keep the desktop value.`, bad.path));
464
+ }
465
+ for (const [key, value] of Object.entries(tokens.typography ?? {})) {
466
+ if (badLineHeight(key, value)) issues.push(tokenIssue("warn", "invalid_line_height_token", `Gem token typography.${key} = ${JSON.stringify(value)} is not a line-height (1.2, 120%, 24px, normal); headings keep their default.`, `typography.${key}`));
467
+ }
345
468
  return {
346
469
  valid: !issues.some((issue) => issue.severity === "error"),
347
470
  issues
348
471
  };
349
472
  }
350
473
  function createGemTokenCss(tokens, selector = ":root") {
474
+ const button = readButtonTokens(tokens);
475
+ const phone = readPhoneTokens(tokens);
476
+ const spacing = desktopEntries("spacing", tokens.spacing);
351
477
  const declarations = [
352
478
  ...toCssVars("color", tokens.color),
353
- ...toCssVars("font", Object.fromEntries(Object.entries(tokens.typography ?? {}).filter(([k]) => !isRampKey(k)))),
479
+ ...toCssVars("font", Object.fromEntries(Object.entries(button.groups.typography ?? tokens.typography ?? {}).filter(([k, v]) => !isRampKey(k) && !badLineHeight(k, v)))),
354
480
  // Space ramp is scaled by the global density knob --gem-style-space (default 1),
355
481
  // so a style pack can make the WHOLE storefront airier or denser without touching
356
482
  // any section — every section already consumes --gem-space-*. Knob unset → identical.
357
- ...toScaledCssVars("space", tokens.spacing, "--gem-style-space"),
358
- ...gemDerivedSpaceCssVars(tokens.spacing),
483
+ // Phone keys (`3xlMobile`) are not desktop variables: they go to the phone block below (GEM_PHONE_TOKEN_TARGETS).
484
+ ...toScaledCssVars("space", spacing, "--gem-style-space"),
485
+ ...gemDerivedSpaceCssVars(spacing),
359
486
  // Radius ramp scaled by --gem-style-radius (default 1): a 'sharp' style sets it to
360
487
  // 0 (square everything), 'pill' rounds further — across every section, no edits.
361
- ...toScaledCssVars("radius", tokens.radius, "--gem-style-radius"),
488
+ ...toScaledCssVars("radius", button.groups.radius ?? tokens.radius, "--gem-style-radius"),
362
489
  // Border widths get the --gem-style-border knob injected into their leading length,
363
490
  // whatever value the plan provides (e.g. "1px solid …" → "calc(1px * var(...)) solid …").
364
491
  ...toBorderCssVars("border", tokens.border, "--gem-style-border"),
@@ -367,7 +494,8 @@ function createGemTokenCss(tokens, selector = ":root") {
367
494
  // and marquee primitives consume; unrecognised keys fall back to the --gem-motion-* namespace,
368
495
  // so duration/easing keep emitting --gem-motion-duration/easing exactly as before (back-compat).
369
496
  ...toMotionCssVars(tokens.motion),
370
- ...toCssVars("layout", tokens.layout),
497
+ ...toCssVars("layout", desktopEntries("layout", tokens.layout)),
498
+ ...phone.layout.section ? [`--gem-layout-section-mobile: ${phone.layout.section};`] : [],
371
499
  ...toCssVars("component", tokens.component),
372
500
  ...toCssVars("morphology", tokens.morphology),
373
501
  // THE GOVERNED TYPE RAMP — --gem-h1..h6 / --gem-body / --gem-text-sm.
@@ -377,6 +505,8 @@ function createGemTokenCss(tokens, selector = ":root") {
377
505
  // variables that resolved to nothing. One modular scale, retuned by overriding --gem-type-base
378
506
  // or --gem-type-scale (see gemTypeRampCssVars).
379
507
  ...gemTypeRampCssVars(tokens.typography),
508
+ // Heading sizes from the ramp, only for a plan that chose a ramp (GEM_HEADING_SIZE_CSS_VARS).
509
+ ...typeRampValue(tokens.typography, "base") || typeRampValue(tokens.typography, "scale") ? GEM_HEADING_SIZE_CSS_VARS : [],
380
510
  // Focus ring (gemfactory request #3, 28/09/2026): 11 sections wrote `outline: 2px solid
381
511
  // var(--gem-color-accent)` by hand. Not a token GROUP (a group would name it --gem-border-focus-ring
382
512
  // and a required key would fail every stored plan/preset without it), so it is emitted here like the
@@ -386,16 +516,25 @@ function createGemTokenCss(tokens, selector = ":root") {
386
516
  GEM_FOCUS_OFFSET_CSS_VAR
387
517
  ];
388
518
  const formBlock = gemFormCssBlock(tokens.form);
389
- const baseMobile = typeRampValue(tokens.typography, "baseMobile");
519
+ const buttonCase = gemButtonCaseCss(tokens, selector);
520
+ const mobile = gemMobileTokenCssVars(tokens, phone);
390
521
  return [
391
522
  `${selector} {`,
392
523
  ...declarations.map((line) => ` ${line}`),
393
524
  "}",
394
- ...baseMobile ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ` --gem-type-base: ${baseMobile};`, " }", "}"] : [],
395
- ...formBlock ? [formBlock] : []
525
+ ...mobile.length ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ...mobile.map((line) => ` ${line}`), " }", "}"] : [],
526
+ ...formBlock ? [formBlock] : [],
527
+ ...buttonCase ? [buttonCase] : []
396
528
  ].join("\n");
397
529
  }
398
- var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
530
+ function gemMobileTokenCssVars(tokens, phone = readPhoneTokens(tokens)) {
531
+ const baseMobile = typeRampValue(tokens.typography, "baseMobile");
532
+ return [
533
+ ...baseMobile ? [`--gem-type-base: ${baseMobile};`] : [],
534
+ ...toScaledCssVars("space", phone.spacing, "--gem-style-space"),
535
+ ...toCssVars("layout", phone.layout)
536
+ ];
537
+ }
399
538
  var rampKey = (k) => k.toLowerCase().replace(/[^a-z0-9]/g, "");
400
539
  var isRampKey = (k) => GEM_TYPE_RAMP_KEYS.some((r) => rampKey(r) === rampKey(k));
401
540
  var LENGTH = /^\d*\.?\d+(px|rem|em)$/;
@@ -879,10 +1018,10 @@ function createGemFrameworkManifest() {
879
1018
  package_name: "@gemtheme/gem-framework",
880
1019
  framework_version: gemFrameworkVersion,
881
1020
  namespace: "gem",
882
- token_groups: Object.entries(requiredGemTokenKeys).map(([group, keys]) => ({
883
- group,
884
- keys: [...keys]
885
- })),
1021
+ token_groups: Object.entries(requiredGemTokenKeys).map(([group, keys]) => {
1022
+ const optional = [...optionalGemTokenKeys[group] ?? []];
1023
+ return { group, keys: [...keys], ...optional.length ? { optional_keys: optional } : {} };
1024
+ }),
886
1025
  primitive_groups: gemFrameworkPrimitiveGroups.map((group) => ({
887
1026
  group: group.group,
888
1027
  classes: [...group.classes]
@@ -1186,7 +1325,6 @@ var GEM_AMBIENT_CSS_LINES = [
1186
1325
  "@media (prefers-reduced-motion: reduce) {",
1187
1326
  " [data-gem-ambient] { --gem-ambient-amp: 2px; animation-duration: 12s; }",
1188
1327
  " [data-gem-reveal] { transition: none; opacity: 1; transform: none; }",
1189
- " .gem-marquee__track { animation-duration: calc(var(--gem-marquee-duration, 40s) * 2.5); }",
1190
1328
  "}"
1191
1329
  ];
1192
1330
  var GEM_AMBIENT_CSS = GEM_AMBIENT_CSS_LINES.join("\n");
@@ -1376,9 +1514,13 @@ function createPrimitiveCss() {
1376
1514
  " justify-content: center;",
1377
1515
  " min-height: var(--gem-component-input-height, 44px);",
1378
1516
  " border: var(--gem-button-border-width, 1px) solid var(--gem-color-border);",
1379
- " border-radius: var(--gem-radius-md);",
1517
+ // Button tokens (GEM_BUTTON_TOKEN_KEYS, optional): unset, radius and weight fall back to exactly what this rule always
1518
+ // drew. --gem-radius-button is the button's own radius, so a pill button does not turn every card into a pill
1519
+ // (radius-md is shared). Case and tracking are NOT declared here: <button> gets `none` / `normal` from the UA sheet
1520
+ // while <a> inherits, so createGemTokenCss adds them only when a token sets them (gemButtonCaseCss).
1521
+ " border-radius: var(--gem-radius-button, var(--gem-radius-md));",
1380
1522
  " padding: var(--gem-component-button-padding-block, 12px) var(--gem-component-button-padding-inline, 18px);",
1381
- " font-weight: 700;",
1523
+ " font-weight: var(--gem-font-button-weight, 700);",
1382
1524
  " text-decoration: none;",
1383
1525
  " transition: background var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease), color var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease);",
1384
1526
  "}",
@@ -1641,28 +1783,6 @@ function createPrimitiveCss() {
1641
1783
  " border-radius: var(--gem-radius-md);",
1642
1784
  " background: color-mix(in srgb, var(--gem-color-text) 8%, var(--gem-color-background));",
1643
1785
  "}",
1644
- ".gem-marquee {",
1645
- " overflow: hidden;",
1646
- " border-block: var(--gem-border-subtle, 1px solid var(--gem-color-border));",
1647
- "}",
1648
- // Consumer contract: render the track's children TWICE (like the site's `.mq-row`) so the
1649
- // -50% translate lands on an identical second copy and the loop is seamless with no visible jump.
1650
- // The animation reuses the gem-marquee-scroll keyframe defined with the ambient primitive.
1651
- ".gem-marquee__track {",
1652
- " display: flex;",
1653
- " gap: var(--gem-space-lg);",
1654
- " min-width: max-content;",
1655
- " animation: gem-marquee-scroll var(--gem-marquee-duration, 40s) linear infinite;",
1656
- " will-change: transform;",
1657
- "}",
1658
- // Pause-on-hover lets a shopper read a badge/logo they want to inspect.
1659
- ".gem-marquee:hover .gem-marquee__track {",
1660
- " animation-play-state: paused;",
1661
- "}",
1662
- // Opt-in reverse direction (e.g. a second row scrolling the other way).
1663
- ".gem-marquee[data-gem-marquee-reverse] .gem-marquee__track {",
1664
- " animation-direction: reverse;",
1665
- "}",
1666
1786
  ".gem-carousel {",
1667
1787
  " display: grid;",
1668
1788
  " gap: var(--gem-space-md);",
@@ -1798,9 +1918,20 @@ function createPrimitiveCss() {
1798
1918
  " overflow: hidden;",
1799
1919
  " color: var(--gem-color-accent);",
1800
1920
  "}",
1801
- // Marquee primitive — CSS-only and deliberately so (no JS to init, works on every render path).
1802
- // The one requirement is a DUPLICATED track: a single track scrolls off and leaves a gap.
1803
- ".gem-marquee { overflow: hidden; }",
1921
+ // Marquee primitive. CSS-only and deliberately so (no JS to init, works on every render path).
1922
+ // Contract: `.gem-marquee > .gem-marquee__viewport > .gem-marquee__track` TWICE (the second copy
1923
+ // aria-hidden); each track is at least the viewport wide and moves -100% of itself, so the second
1924
+ // copy is what makes the loop seamless. gemcapture's clone kit and GemFactory build exactly this.
1925
+ // ONE definition (0.9.11): until then a second, earlier one (gemtheme #124: one track with its
1926
+ // children doubled, -50%) sat above this and lost every property both set, so its contract never
1927
+ // worked. What it set that still took effect (the border, will-change, pause on hover of the whole
1928
+ // marquee, the data-attribute reverse) is kept here, so a marquee built to either contract computes
1929
+ // the same style as before (Chromium, 08/10/2026). The one exception would be a track that also
1930
+ // carries data-gem-ambient, which nothing renders.
1931
+ ".gem-marquee {",
1932
+ " overflow: hidden;",
1933
+ " border-block: var(--gem-border-subtle, 1px solid var(--gem-color-border));",
1934
+ "}",
1804
1935
  ".gem-marquee__viewport { display: flex; overflow: hidden; }",
1805
1936
  ".gem-marquee__track {",
1806
1937
  " flex: 0 0 auto;",
@@ -1811,9 +1942,12 @@ function createPrimitiveCss() {
1811
1942
  " gap: var(--gem-space-lg);",
1812
1943
  " white-space: nowrap;",
1813
1944
  " animation: gem-marquee var(--gem-marquee-duration, 24s) linear infinite;",
1945
+ " will-change: transform;",
1814
1946
  "}",
1815
- ".gem-marquee__viewport:hover .gem-marquee__track { animation-play-state: paused; }",
1816
- ".gem-marquee--reverse .gem-marquee__track { animation-direction: reverse; }",
1947
+ // Pause-on-hover lets a shopper read a badge/logo they want to inspect.
1948
+ ".gem-marquee:hover .gem-marquee__track, .gem-marquee__viewport:hover .gem-marquee__track { animation-play-state: paused; }",
1949
+ // Opt-in reverse direction (e.g. a second row scrolling the other way).
1950
+ ".gem-marquee--reverse .gem-marquee__track, .gem-marquee[data-gem-marquee-reverse] .gem-marquee__track { animation-direction: reverse; }",
1817
1951
  "@keyframes gem-marquee { from { transform: translateX(0); } to { transform: translateX(-100%); } }",
1818
1952
  "@media (prefers-reduced-motion: reduce) {",
1819
1953
  " .gem-marquee__track { animation: none; }",
@@ -2831,10 +2965,10 @@ function createGemSectionRuntimeContractCss(selector = "[data-gem-section-librar
2831
2965
  "}"
2832
2966
  ].join("\n");
2833
2967
  }
2834
- var OPT_IN_EYEBROW = ":where(.gem-section[data-gem-eyebrow='show']) .gem-section__eyebrow { display: block; letter-spacing: 0.12em; }";
2835
- 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; }";
2968
+ var OPT_IN_EYEBROW = ":where(.gem-section[data-gem-eyebrow='show']) .gem-section__eyebrow { display: block; font-size: var(--gem-text-sm); letter-spacing: 0.12em; }";
2969
+ 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-section-heading-size, var(--gem-h2)); line-height: var(--gem-font-heading-line-height, 1.1); letter-spacing: -0.01em; text-wrap: balance; }";
2836
2970
  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); }";
2837
- 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)); }";
2971
+ var OPT_IN_HEADING_MOBILE = " :where(.gem-section[data-gem-heading='section']) .gem-section__heading { font-size: var(--gem-section-heading-size-mobile, min(var(--gem-h2), clamp(1.75rem, 9vw, 2.75rem))); }";
2838
2972
  function createSectionCss() {
2839
2973
  return [
2840
2974
  ".gem-section {",
@@ -3026,8 +3160,8 @@ function createSectionCss() {
3026
3160
  ".gem-section__heading {",
3027
3161
  " margin: 0;",
3028
3162
  " font-family: var(--gem-font-heading);",
3029
- " font-size: clamp(calc(2rem * var(--gem-typography-heading-scale, 1)), calc(4vw * var(--gem-typography-heading-scale, 1)), calc(4rem * var(--gem-typography-heading-scale, 1)));",
3030
- " line-height: 1.04;",
3163
+ " font-size: var(--gem-section-heading-size, clamp(calc(2rem * var(--gem-typography-heading-scale, 1)), calc(4vw * var(--gem-typography-heading-scale, 1)), calc(4rem * var(--gem-typography-heading-scale, 1))));",
3164
+ " line-height: var(--gem-font-heading-line-height, 1.04);",
3031
3165
  "}",
3032
3166
  ".gem-section__body {",
3033
3167
  " margin: 0;",
@@ -3144,9 +3278,10 @@ function createSectionCss() {
3144
3278
  " }",
3145
3279
  "}",
3146
3280
  "@media (max-width: 760px) {",
3147
- " .gem-section { padding-block: calc(var(--gem-layout-section, 64px) * 0.72); }",
3281
+ // A declared layout.sectionMobile is final (tokens.ts GEM_PHONE_TOKEN_TARGETS); otherwise the desktop value × 0.72.
3282
+ " .gem-section { padding-block: var(--gem-layout-section-mobile, calc(var(--gem-layout-section, 64px) * 0.72)); }",
3148
3283
  " .gem-section__inner, .gem-container { width: min(100% - 32px, var(--gem-layout-page, 1180px)); }",
3149
- " .gem-section__heading { font-size: clamp(1.75rem, 9vw, 2.75rem); }",
3284
+ " .gem-section__heading { font-size: var(--gem-section-heading-size-mobile, clamp(1.75rem, 9vw, 2.75rem)); }",
3150
3285
  " .gem-section__grid { grid-template-columns: minmax(0, 1fr); }",
3151
3286
  " .gem-section:is([data-gem-composition-mode='split-collage'], [data-gem-editorial-band-mode='media-story']) .gem-section__grid { grid-template-columns: minmax(0, 1fr); }",
3152
3287
  " .gem-product-main[data-gem-product-main-layout='dawn-main'] .gem-product-main__inner { grid-template-columns: minmax(0, 1fr); }",
@@ -3166,7 +3301,7 @@ function createSectionCss() {
3166
3301
  function createDawnParityCss() {
3167
3302
  return [
3168
3303
  "/* Dawn parity profile: locked default baseline; morph presets must opt out before applying Gem visual identity. */",
3169
- ":root { --gem-profile-mode: dawn-parity; --gem-layout-page: 1100px; --gem-layout-gutter: 32px; --gem-layout-section: 52px; --gem-radius-md: 0px; --gem-card-radius: 0px; --gem-media-radius: 0px; --gem-grid-column-gap: 8px; --gem-grid-row-gap: 8px; }",
3304
+ ":root { --gem-profile-mode: dawn-parity; --gem-layout-page: 1100px; --gem-layout-gutter: 32px; --gem-layout-section: 52px; --gem-radius-md: 0px; --gem-card-radius: 0px; --gem-media-radius: 0px; --gem-grid-column-gap: 8px; --gem-grid-row-gap: 8px; --gem-heading-phone-cap: clamp(2rem, 11vw, 3.6rem); }",
3170
3305
  ".utility-bar, .announcement-bar, .utility-bar__grid { min-height: 38px; }",
3171
3306
  ".announcement-bar__message { margin: 0; min-height: 38px; padding-block: 10px; font-size: 0.75rem; font-weight: 400; letter-spacing: 0.04em; }",
3172
3307
  // The pair of rules here used to blank the real message (font-size: 0) and inject a FIXED
@@ -3242,7 +3377,12 @@ function createDawnParityCss() {
3242
3377
  ".gem-hero-banner--split-proof .gem-hero-banner__media, .gem-hero-banner--editorial .gem-hero-banner__media { position: relative; inset: auto; min-height: 26rem; height: 100%; overflow: hidden; border-radius: 6px; background: rgb(244 244 244); }",
3243
3378
  ".gem-hero-banner--split-proof .gem-hero-banner__image, .gem-hero-banner--split-proof .gem-hero-banner__placeholder, .gem-hero-banner--editorial .gem-hero-banner__image, .gem-hero-banner--editorial .gem-hero-banner__placeholder { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }",
3244
3379
  ".gem-hero-banner .gem-section__content { max-width: 78rem; margin-inline: auto; }",
3245
- ".gem-hero-banner h1, .gem-hero-banner h2, .gem-section__heading { margin: 0 0 1.5rem; font-size: clamp(calc(2.5rem * var(--gem-typography-heading-scale, 1)), calc(4vw * var(--gem-typography-heading-scale, 1)), calc(5.2rem * var(--gem-typography-heading-scale, 1))); font-weight: 400; line-height: 1.08; letter-spacing: 0; }",
3380
+ ".gem-hero-banner h1, .gem-hero-banner h2, .gem-section__heading { margin: 0 0 1.5rem; font-weight: 400; line-height: var(--gem-font-heading-line-height, 1.08); letter-spacing: 0; }",
3381
+ // Font size has ONE owner per selector (one-owner-per-selector.test.ts): section headings take H2 of the plan's ramp,
3382
+ // the hero's own title H1 (tokens.ts GEM_HEADING_SIZE_CSS_VARS). Same position and specificity as the shared rule above,
3383
+ // so with no ramp both resolve to the clamp that rule used to set.
3384
+ ".gem-section__heading { font-size: var(--gem-section-heading-size, clamp(calc(2.5rem * var(--gem-typography-heading-scale, 1)), calc(4vw * var(--gem-typography-heading-scale, 1)), calc(5.2rem * var(--gem-typography-heading-scale, 1)))); }",
3385
+ ".gem-hero-banner h1, .gem-hero-banner h2 { font-size: var(--gem-hero-heading-size, clamp(calc(2.5rem * var(--gem-typography-heading-scale, 1)), calc(4vw * var(--gem-typography-heading-scale, 1)), calc(5.2rem * var(--gem-typography-heading-scale, 1)))); }",
3246
3386
  ".gem-section__eyebrow { display: none; }",
3247
3387
  ".gem-hero-banner--compact-collection { min-height: auto; display: block; text-align: left; padding-block: 25px; }",
3248
3388
  ".gem-hero-banner--compact-collection .gem-section__content, .gem-product-card-grid--quick-view-grid > .gem-section__inner { width: min(var(--gem-layout-page, 1200px), calc(100% - var(--gem-layout-gutter, 32px))); margin-inline: auto; }",
@@ -3257,7 +3397,13 @@ function createDawnParityCss() {
3257
3397
  ".gem-rich-text--dawn-intro .gem-section__body { width: 100%; max-width: none; margin: 20px 0 0; font-size: 16px; line-height: 28.8px; }",
3258
3398
  ".gem-rich-text--dawn-intro .gem-section__body p { margin: 0; }",
3259
3399
  ".gem-rich-text--dawn-intro .gem-rich-text__blocks { display: block; width: 100%; }",
3260
- ".gem-button { min-width: 12rem; border-radius: 0; font-weight: 400; box-shadow: none; }",
3400
+ // Dawn's square, regular-weight button stays the default; the button tokens (radius.button, typography.buttonWeight)
3401
+ // override it, or this rule (same specificity, later) would pin every button square at 400 whatever the plan says.
3402
+ // The weight sits in its own rule that skips link buttons, which keep today's 400 here: the rule above it is what
3403
+ // overrides `.gem-button--link { font-weight: inherit }` in this profile, and a weight token must not reach a link.
3404
+ // `:where()` keeps both at one class of specificity, so a section's own CTA rule still wins.
3405
+ ".gem-button { min-width: 12rem; border-radius: var(--gem-radius-button, 0); font-weight: 400; box-shadow: none; }",
3406
+ ".gem-button:where(:not(.gem-button--link)) { font-weight: var(--gem-font-button-weight, 400); }",
3261
3407
  ".gem-product-card-wrapper { display: block; min-width: 0; }",
3262
3408
  ".gem-card, .gem-product-card { border: 0; border-radius: 0; box-shadow: none; background: transparent; padding: 0; }",
3263
3409
  ".gem-product-card__media, .gem-media { border: 0; border-radius: 0; box-shadow: none; background: rgb(244 244 244); }",
@@ -3453,7 +3599,9 @@ function createDawnParityMobileCss() {
3453
3599
  " .gem-product-card__content { padding-top: 8px; gap: 4px; }",
3454
3600
  " .gem-product-card__title { font-size: 15px; line-height: 24px; }",
3455
3601
  " .gem-product-card__price, .gem-product-card__price .gem-price { font-size: 16px; line-height: 24px; }",
3456
- " .gem-hero-banner h1, .gem-hero-banner h2, .gem-section__heading { font-size: clamp(2rem, 11vw, 3.6rem); }",
3602
+ // Fallback = the profile's own phone heading, declared once as --gem-heading-phone-cap on the :root above.
3603
+ " .gem-section__heading { font-size: var(--gem-section-heading-size-mobile, var(--gem-heading-phone-cap)); }",
3604
+ " .gem-hero-banner h1, .gem-hero-banner h2 { font-size: var(--gem-hero-heading-size-mobile, var(--gem-heading-phone-cap)); }",
3457
3605
  " .gem-featured-collection--editorial-grid .gem-section__grid, .gem-product-card-grid--quick-view-grid .gem-section__grid, .gem-main-search .gem-section__grid { grid-template-columns: repeat(var(--gem-product-card-grid-mobile-columns, 2), minmax(0, 1fr)); }",
3458
3606
  " .gem-product-main[data-gem-product-main-layout='dawn-main'] > .gem-section__inner { width: calc(100% - 30px); }",
3459
3607
  " .gem-product-main[data-gem-product-main-layout='dawn-main'] .gem-product-media-gallery__frame { display: flex; gap: 0; overflow-x: auto; scroll-snap-type: x mandatory; }",
@@ -5727,7 +5875,10 @@ function gemFrameworkPublicCssVariables() {
5727
5875
  createPrimitiveCss(),
5728
5876
  String(createSectionCss()),
5729
5877
  String(createShellCss()),
5730
- String(createGemSectionRuntimeContractCss())
5878
+ String(createGemSectionRuntimeContractCss()),
5879
+ // The button case rule only exists when a token sets it; a section may still read its variables (a CTA rule
5880
+ // beats the zero-specificity rule, so opting in means `text-transform: var(--gem-font-button-transform, none)`).
5881
+ gemButtonCaseCss({ typography: { buttonTransform: "none", buttonLetterSpacing: "normal" } })
5731
5882
  ].join("\n").replace(/\/\*[\s\S]*?\*\//g, " ");
5732
5883
  frameworkPublicVariables = gemPublicCssVariablesFrom(css);
5733
5884
  }
@@ -6898,6 +7049,8 @@ function stringSetting(value) {
6898
7049
  export {
6899
7050
  COUNTDOWN_END_RUNTIME_LINES,
6900
7051
  GEM_AMBIENT_CSS,
7052
+ GEM_BUTTON_TOKEN_KEYS,
7053
+ GEM_BUTTON_TOKEN_SHAPES,
6901
7054
  GEM_COUNTDOWN_ENDED_CSS,
6902
7055
  GEM_FIELD_JOINS,
6903
7056
  GEM_FIELD_SHAPES,
@@ -6908,6 +7061,8 @@ export {
6908
7061
  GEM_FORM_TOKEN_KEYS,
6909
7062
  GEM_FORM_VAR_SCOPE,
6910
7063
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
7064
+ GEM_HEADING_SIZE_CSS_VARS,
7065
+ GEM_LINE_HEIGHT_TOKEN_KEYS,
6911
7066
  GEM_PART_CLASS,
6912
7067
  GEM_POPUP_SHELL_CSS,
6913
7068
  GEM_REVEAL_CSS,
@@ -6939,6 +7094,7 @@ export {
6939
7094
  createThemeProfileCss,
6940
7095
  defaultGemTokens,
6941
7096
  defaultSelection,
7097
+ gemButtonCaseCss,
6942
7098
  gemFormAttributes,
6943
7099
  gemFormCssBlock,
6944
7100
  gemFormCssVars,
@@ -6947,7 +7103,9 @@ export {
6947
7103
  gemFrameworkRuntimePlugins,
6948
7104
  gemFrameworkValidationRules,
6949
7105
  gemFrameworkVersion,
7106
+ gemMobileTokenCssVars,
6950
7107
  gemPartClass,
7108
+ gemPhoneTokenTarget,
6951
7109
  gemPlanShapeKnobs,
6952
7110
  gemRuntimePluginModuleKeys,
6953
7111
  gemRuntimePluginSources,
@@ -6958,6 +7116,7 @@ export {
6958
7116
  motionCssVarAliases,
6959
7117
  normalizeGemTokens,
6960
7118
  offerEngineRuntimeScript,
7119
+ optionalGemTokenKeys,
6961
7120
  planToRuntimeBootstrapConfig,
6962
7121
  renderGemFrameworkManifest,
6963
7122
  renderGemPreviewDocument,
@@ -93,9 +93,9 @@ a {
93
93
  justify-content: center;
94
94
  min-height: var(--gem-component-input-height, 44px);
95
95
  border: var(--gem-button-border-width, 1px) solid var(--gem-color-border);
96
- border-radius: var(--gem-radius-md);
96
+ border-radius: var(--gem-radius-button, var(--gem-radius-md));
97
97
  padding: var(--gem-component-button-padding-block, 12px) var(--gem-component-button-padding-inline, 18px);
98
- font-weight: 700;
98
+ font-weight: var(--gem-font-button-weight, 700);
99
99
  text-decoration: none;
100
100
  transition: background var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease), color var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease);
101
101
  }
@@ -373,23 +373,6 @@ a {
373
373
  border-radius: var(--gem-radius-md);
374
374
  background: color-mix(in srgb, var(--gem-color-text) 8%, var(--gem-color-background));
375
375
  }
376
- .gem-marquee {
377
- overflow: hidden;
378
- border-block: var(--gem-border-subtle, 1px solid var(--gem-color-border));
379
- }
380
- .gem-marquee__track {
381
- display: flex;
382
- gap: var(--gem-space-lg);
383
- min-width: max-content;
384
- animation: gem-marquee-scroll var(--gem-marquee-duration, 40s) linear infinite;
385
- will-change: transform;
386
- }
387
- .gem-marquee:hover .gem-marquee__track {
388
- animation-play-state: paused;
389
- }
390
- .gem-marquee[data-gem-marquee-reverse] .gem-marquee__track {
391
- animation-direction: reverse;
392
- }
393
376
  .gem-carousel {
394
377
  display: grid;
395
378
  gap: var(--gem-space-md);
@@ -515,7 +498,10 @@ a {
515
498
  overflow: hidden;
516
499
  color: var(--gem-color-accent);
517
500
  }
518
- .gem-marquee { overflow: hidden; }
501
+ .gem-marquee {
502
+ overflow: hidden;
503
+ border-block: var(--gem-border-subtle, 1px solid var(--gem-color-border));
504
+ }
519
505
  .gem-marquee__viewport { display: flex; overflow: hidden; }
520
506
  .gem-marquee__track {
521
507
  flex: 0 0 auto;
@@ -526,9 +512,10 @@ a {
526
512
  gap: var(--gem-space-lg);
527
513
  white-space: nowrap;
528
514
  animation: gem-marquee var(--gem-marquee-duration, 24s) linear infinite;
515
+ will-change: transform;
529
516
  }
530
- .gem-marquee__viewport:hover .gem-marquee__track { animation-play-state: paused; }
531
- .gem-marquee--reverse .gem-marquee__track { animation-direction: reverse; }
517
+ .gem-marquee:hover .gem-marquee__track, .gem-marquee__viewport:hover .gem-marquee__track { animation-play-state: paused; }
518
+ .gem-marquee--reverse .gem-marquee__track, .gem-marquee[data-gem-marquee-reverse] .gem-marquee__track { animation-direction: reverse; }
532
519
  @keyframes gem-marquee { from { transform: translateX(0); } to { transform: translateX(-100%); } }
533
520
  @media (prefers-reduced-motion: reduce) {
534
521
  .gem-marquee__track { animation: none; }
@@ -1042,7 +1029,6 @@ a {
1042
1029
  @media (prefers-reduced-motion: reduce) {
1043
1030
  [data-gem-ambient] { --gem-ambient-amp: 2px; animation-duration: 12s; }
1044
1031
  [data-gem-reveal] { transition: none; opacity: 1; transform: none; }
1045
- .gem-marquee__track { animation-duration: calc(var(--gem-marquee-duration, 40s) * 2.5); }
1046
1032
  }
1047
1033
  :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top], :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom] { position: relative; overflow: clip; overflow-clip-margin: var(--gem-divider-height, 48px); }
1048
1034
  :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='wave']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='slope']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='spikes']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='arch']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-bottom='blob']::after, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='wave']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='slope']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='spikes']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='arch']::before, :is(.gem-section, .gem-hero, .gem-hero-split, .gem-hero-collage, .gem-hero-triptych, .gem-slideshow)[data-gem-divider-top='blob']::before { content: ''; position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; height: var(--gem-divider-height, 48px); background: var(--gem-section-bg, var(--gem-color-background)); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; }