@gemboss/gem-framework 0.9.8 → 0.10.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/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.1";
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",
@@ -250,7 +363,10 @@ var defaultGemTokens = {
250
363
  accentText: "#ffffff",
251
364
  border: "#d2d5d8",
252
365
  success: "#008060",
253
- warning: "#b7791f",
366
+ // AA as TEXT on both defaults (gem-framework 0.10.1): #b7791f measured 3.36:1 on the background and 3.64:1 on white, and
367
+ // GemFactory sections set `color: var(--gem-color-warning)` on words; white text on a warning badge was 3.64:1 too.
368
+ // #9a661a keeps the hue at 4.52:1 / 4.90:1. default-colours-meet-aa.test.ts holds every foreground default to 4.5:1.
369
+ warning: "#9a661a",
254
370
  danger: "#d72c0d"
255
371
  },
256
372
  typography: {
@@ -342,23 +458,37 @@ function validateGemTokens(tokens) {
342
458
  }
343
459
  }
344
460
  }
461
+ for (const bad of readButtonTokens(tokens).invalid) {
462
+ 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)))];
463
+ 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));
464
+ }
465
+ for (const bad of readPhoneTokens(tokens).invalid) {
466
+ 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));
467
+ }
468
+ for (const [key, value] of Object.entries(tokens.typography ?? {})) {
469
+ 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}`));
470
+ }
345
471
  return {
346
472
  valid: !issues.some((issue) => issue.severity === "error"),
347
473
  issues
348
474
  };
349
475
  }
350
476
  function createGemTokenCss(tokens, selector = ":root") {
477
+ const button = readButtonTokens(tokens);
478
+ const phone = readPhoneTokens(tokens);
479
+ const spacing = desktopEntries("spacing", tokens.spacing);
351
480
  const declarations = [
352
481
  ...toCssVars("color", tokens.color),
353
- ...toCssVars("font", Object.fromEntries(Object.entries(tokens.typography ?? {}).filter(([k]) => !isRampKey(k)))),
482
+ ...toCssVars("font", Object.fromEntries(Object.entries(button.groups.typography ?? tokens.typography ?? {}).filter(([k, v]) => !isRampKey(k) && !badLineHeight(k, v)))),
354
483
  // Space ramp is scaled by the global density knob --gem-style-space (default 1),
355
484
  // so a style pack can make the WHOLE storefront airier or denser without touching
356
485
  // any section — every section already consumes --gem-space-*. Knob unset → identical.
357
- ...toScaledCssVars("space", tokens.spacing, "--gem-style-space"),
358
- ...gemDerivedSpaceCssVars(tokens.spacing),
486
+ // Phone keys (`3xlMobile`) are not desktop variables: they go to the phone block below (GEM_PHONE_TOKEN_TARGETS).
487
+ ...toScaledCssVars("space", spacing, "--gem-style-space"),
488
+ ...gemDerivedSpaceCssVars(spacing),
359
489
  // Radius ramp scaled by --gem-style-radius (default 1): a 'sharp' style sets it to
360
490
  // 0 (square everything), 'pill' rounds further — across every section, no edits.
361
- ...toScaledCssVars("radius", tokens.radius, "--gem-style-radius"),
491
+ ...toScaledCssVars("radius", button.groups.radius ?? tokens.radius, "--gem-style-radius"),
362
492
  // Border widths get the --gem-style-border knob injected into their leading length,
363
493
  // whatever value the plan provides (e.g. "1px solid …" → "calc(1px * var(...)) solid …").
364
494
  ...toBorderCssVars("border", tokens.border, "--gem-style-border"),
@@ -367,7 +497,8 @@ function createGemTokenCss(tokens, selector = ":root") {
367
497
  // and marquee primitives consume; unrecognised keys fall back to the --gem-motion-* namespace,
368
498
  // so duration/easing keep emitting --gem-motion-duration/easing exactly as before (back-compat).
369
499
  ...toMotionCssVars(tokens.motion),
370
- ...toCssVars("layout", tokens.layout),
500
+ ...toCssVars("layout", desktopEntries("layout", tokens.layout)),
501
+ ...phone.layout.section ? [`--gem-layout-section-mobile: ${phone.layout.section};`] : [],
371
502
  ...toCssVars("component", tokens.component),
372
503
  ...toCssVars("morphology", tokens.morphology),
373
504
  // THE GOVERNED TYPE RAMP — --gem-h1..h6 / --gem-body / --gem-text-sm.
@@ -377,6 +508,8 @@ function createGemTokenCss(tokens, selector = ":root") {
377
508
  // variables that resolved to nothing. One modular scale, retuned by overriding --gem-type-base
378
509
  // or --gem-type-scale (see gemTypeRampCssVars).
379
510
  ...gemTypeRampCssVars(tokens.typography),
511
+ // Heading sizes from the ramp, only for a plan that chose a ramp (GEM_HEADING_SIZE_CSS_VARS).
512
+ ...typeRampValue(tokens.typography, "base") || typeRampValue(tokens.typography, "scale") ? GEM_HEADING_SIZE_CSS_VARS : [],
380
513
  // Focus ring (gemfactory request #3, 28/09/2026): 11 sections wrote `outline: 2px solid
381
514
  // var(--gem-color-accent)` by hand. Not a token GROUP (a group would name it --gem-border-focus-ring
382
515
  // and a required key would fail every stored plan/preset without it), so it is emitted here like the
@@ -386,16 +519,25 @@ function createGemTokenCss(tokens, selector = ":root") {
386
519
  GEM_FOCUS_OFFSET_CSS_VAR
387
520
  ];
388
521
  const formBlock = gemFormCssBlock(tokens.form);
389
- const baseMobile = typeRampValue(tokens.typography, "baseMobile");
522
+ const buttonCase = gemButtonCaseCss(tokens, selector);
523
+ const mobile = gemMobileTokenCssVars(tokens, phone);
390
524
  return [
391
525
  `${selector} {`,
392
526
  ...declarations.map((line) => ` ${line}`),
393
527
  "}",
394
- ...baseMobile ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ` --gem-type-base: ${baseMobile};`, " }", "}"] : [],
395
- ...formBlock ? [formBlock] : []
528
+ ...mobile.length ? [`@media (max-width: ${GEM_TYPE_MOBILE_MAX}) {`, ` ${selector} {`, ...mobile.map((line) => ` ${line}`), " }", "}"] : [],
529
+ ...formBlock ? [formBlock] : [],
530
+ ...buttonCase ? [buttonCase] : []
396
531
  ].join("\n");
397
532
  }
398
- var GEM_TYPE_RAMP_KEYS = ["base", "scale", "baseMobile"];
533
+ function gemMobileTokenCssVars(tokens, phone = readPhoneTokens(tokens)) {
534
+ const baseMobile = typeRampValue(tokens.typography, "baseMobile");
535
+ return [
536
+ ...baseMobile ? [`--gem-type-base: ${baseMobile};`] : [],
537
+ ...toScaledCssVars("space", phone.spacing, "--gem-style-space"),
538
+ ...toCssVars("layout", phone.layout)
539
+ ];
540
+ }
399
541
  var rampKey = (k) => k.toLowerCase().replace(/[^a-z0-9]/g, "");
400
542
  var isRampKey = (k) => GEM_TYPE_RAMP_KEYS.some((r) => rampKey(r) === rampKey(k));
401
543
  var LENGTH = /^\d*\.?\d+(px|rem|em)$/;
@@ -879,10 +1021,10 @@ function createGemFrameworkManifest() {
879
1021
  package_name: "@gemtheme/gem-framework",
880
1022
  framework_version: gemFrameworkVersion,
881
1023
  namespace: "gem",
882
- token_groups: Object.entries(requiredGemTokenKeys).map(([group, keys]) => ({
883
- group,
884
- keys: [...keys]
885
- })),
1024
+ token_groups: Object.entries(requiredGemTokenKeys).map(([group, keys]) => {
1025
+ const optional = [...optionalGemTokenKeys[group] ?? []];
1026
+ return { group, keys: [...keys], ...optional.length ? { optional_keys: optional } : {} };
1027
+ }),
886
1028
  primitive_groups: gemFrameworkPrimitiveGroups.map((group) => ({
887
1029
  group: group.group,
888
1030
  classes: [...group.classes]
@@ -1186,7 +1328,6 @@ var GEM_AMBIENT_CSS_LINES = [
1186
1328
  "@media (prefers-reduced-motion: reduce) {",
1187
1329
  " [data-gem-ambient] { --gem-ambient-amp: 2px; animation-duration: 12s; }",
1188
1330
  " [data-gem-reveal] { transition: none; opacity: 1; transform: none; }",
1189
- " .gem-marquee__track { animation-duration: calc(var(--gem-marquee-duration, 40s) * 2.5); }",
1190
1331
  "}"
1191
1332
  ];
1192
1333
  var GEM_AMBIENT_CSS = GEM_AMBIENT_CSS_LINES.join("\n");
@@ -1376,9 +1517,13 @@ function createPrimitiveCss() {
1376
1517
  " justify-content: center;",
1377
1518
  " min-height: var(--gem-component-input-height, 44px);",
1378
1519
  " border: var(--gem-button-border-width, 1px) solid var(--gem-color-border);",
1379
- " border-radius: var(--gem-radius-md);",
1520
+ // Button tokens (GEM_BUTTON_TOKEN_KEYS, optional): unset, radius and weight fall back to exactly what this rule always
1521
+ // drew. --gem-radius-button is the button's own radius, so a pill button does not turn every card into a pill
1522
+ // (radius-md is shared). Case and tracking are NOT declared here: <button> gets `none` / `normal` from the UA sheet
1523
+ // while <a> inherits, so createGemTokenCss adds them only when a token sets them (gemButtonCaseCss).
1524
+ " border-radius: var(--gem-radius-button, var(--gem-radius-md));",
1380
1525
  " padding: var(--gem-component-button-padding-block, 12px) var(--gem-component-button-padding-inline, 18px);",
1381
- " font-weight: 700;",
1526
+ " font-weight: var(--gem-font-button-weight, 700);",
1382
1527
  " text-decoration: none;",
1383
1528
  " transition: background var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease), color var(--gem-motion-duration, 160ms) var(--gem-motion-easing, ease);",
1384
1529
  "}",
@@ -1641,28 +1786,6 @@ function createPrimitiveCss() {
1641
1786
  " border-radius: var(--gem-radius-md);",
1642
1787
  " background: color-mix(in srgb, var(--gem-color-text) 8%, var(--gem-color-background));",
1643
1788
  "}",
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
1789
  ".gem-carousel {",
1667
1790
  " display: grid;",
1668
1791
  " gap: var(--gem-space-md);",
@@ -1798,9 +1921,20 @@ function createPrimitiveCss() {
1798
1921
  " overflow: hidden;",
1799
1922
  " color: var(--gem-color-accent);",
1800
1923
  "}",
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; }",
1924
+ // Marquee primitive. CSS-only and deliberately so (no JS to init, works on every render path).
1925
+ // Contract: `.gem-marquee > .gem-marquee__viewport > .gem-marquee__track` TWICE (the second copy
1926
+ // aria-hidden); each track is at least the viewport wide and moves -100% of itself, so the second
1927
+ // copy is what makes the loop seamless. gemcapture's clone kit and GemFactory build exactly this.
1928
+ // ONE definition (0.9.11): until then a second, earlier one (gemtheme #124: one track with its
1929
+ // children doubled, -50%) sat above this and lost every property both set, so its contract never
1930
+ // worked. What it set that still took effect (the border, will-change, pause on hover of the whole
1931
+ // marquee, the data-attribute reverse) is kept here, so a marquee built to either contract computes
1932
+ // the same style as before (Chromium, 08/10/2026). The one exception would be a track that also
1933
+ // carries data-gem-ambient, which nothing renders.
1934
+ ".gem-marquee {",
1935
+ " overflow: hidden;",
1936
+ " border-block: var(--gem-border-subtle, 1px solid var(--gem-color-border));",
1937
+ "}",
1804
1938
  ".gem-marquee__viewport { display: flex; overflow: hidden; }",
1805
1939
  ".gem-marquee__track {",
1806
1940
  " flex: 0 0 auto;",
@@ -1811,9 +1945,12 @@ function createPrimitiveCss() {
1811
1945
  " gap: var(--gem-space-lg);",
1812
1946
  " white-space: nowrap;",
1813
1947
  " animation: gem-marquee var(--gem-marquee-duration, 24s) linear infinite;",
1948
+ " will-change: transform;",
1814
1949
  "}",
1815
- ".gem-marquee__viewport:hover .gem-marquee__track { animation-play-state: paused; }",
1816
- ".gem-marquee--reverse .gem-marquee__track { animation-direction: reverse; }",
1950
+ // Pause-on-hover lets a shopper read a badge/logo they want to inspect.
1951
+ ".gem-marquee:hover .gem-marquee__track, .gem-marquee__viewport:hover .gem-marquee__track { animation-play-state: paused; }",
1952
+ // Opt-in reverse direction (e.g. a second row scrolling the other way).
1953
+ ".gem-marquee--reverse .gem-marquee__track, .gem-marquee[data-gem-marquee-reverse] .gem-marquee__track { animation-direction: reverse; }",
1817
1954
  "@keyframes gem-marquee { from { transform: translateX(0); } to { transform: translateX(-100%); } }",
1818
1955
  "@media (prefers-reduced-motion: reduce) {",
1819
1956
  " .gem-marquee__track { animation: none; }",
@@ -2831,10 +2968,10 @@ function createGemSectionRuntimeContractCss(selector = "[data-gem-section-librar
2831
2968
  "}"
2832
2969
  ].join("\n");
2833
2970
  }
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; }";
2971
+ 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; }";
2972
+ 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
2973
  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)); }";
2974
+ 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
2975
  function createSectionCss() {
2839
2976
  return [
2840
2977
  ".gem-section {",
@@ -3026,8 +3163,8 @@ function createSectionCss() {
3026
3163
  ".gem-section__heading {",
3027
3164
  " margin: 0;",
3028
3165
  " 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;",
3166
+ " 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))));",
3167
+ " line-height: var(--gem-font-heading-line-height, 1.04);",
3031
3168
  "}",
3032
3169
  ".gem-section__body {",
3033
3170
  " margin: 0;",
@@ -3144,9 +3281,10 @@ function createSectionCss() {
3144
3281
  " }",
3145
3282
  "}",
3146
3283
  "@media (max-width: 760px) {",
3147
- " .gem-section { padding-block: calc(var(--gem-layout-section, 64px) * 0.72); }",
3284
+ // A declared layout.sectionMobile is final (tokens.ts GEM_PHONE_TOKEN_TARGETS); otherwise the desktop value × 0.72.
3285
+ " .gem-section { padding-block: var(--gem-layout-section-mobile, calc(var(--gem-layout-section, 64px) * 0.72)); }",
3148
3286
  " .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); }",
3287
+ " .gem-section__heading { font-size: var(--gem-section-heading-size-mobile, clamp(1.75rem, 9vw, 2.75rem)); }",
3150
3288
  " .gem-section__grid { grid-template-columns: minmax(0, 1fr); }",
3151
3289
  " .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
3290
  " .gem-product-main[data-gem-product-main-layout='dawn-main'] .gem-product-main__inner { grid-template-columns: minmax(0, 1fr); }",
@@ -3166,7 +3304,7 @@ function createSectionCss() {
3166
3304
  function createDawnParityCss() {
3167
3305
  return [
3168
3306
  "/* 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; }",
3307
+ ":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
3308
  ".utility-bar, .announcement-bar, .utility-bar__grid { min-height: 38px; }",
3171
3309
  ".announcement-bar__message { margin: 0; min-height: 38px; padding-block: 10px; font-size: 0.75rem; font-weight: 400; letter-spacing: 0.04em; }",
3172
3310
  // The pair of rules here used to blank the real message (font-size: 0) and inject a FIXED
@@ -3242,7 +3380,12 @@ function createDawnParityCss() {
3242
3380
  ".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
3381
  ".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
3382
  ".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; }",
3383
+ ".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; }",
3384
+ // Font size has ONE owner per selector (one-owner-per-selector.test.ts): section headings take H2 of the plan's ramp,
3385
+ // the hero's own title H1 (tokens.ts GEM_HEADING_SIZE_CSS_VARS). Same position and specificity as the shared rule above,
3386
+ // so with no ramp both resolve to the clamp that rule used to set.
3387
+ ".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)))); }",
3388
+ ".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
3389
  ".gem-section__eyebrow { display: none; }",
3247
3390
  ".gem-hero-banner--compact-collection { min-height: auto; display: block; text-align: left; padding-block: 25px; }",
3248
3391
  ".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 +3400,13 @@ function createDawnParityCss() {
3257
3400
  ".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
3401
  ".gem-rich-text--dawn-intro .gem-section__body p { margin: 0; }",
3259
3402
  ".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; }",
3403
+ // Dawn's square, regular-weight button stays the default; the button tokens (radius.button, typography.buttonWeight)
3404
+ // override it, or this rule (same specificity, later) would pin every button square at 400 whatever the plan says.
3405
+ // The weight sits in its own rule that skips link buttons, which keep today's 400 here: the rule above it is what
3406
+ // overrides `.gem-button--link { font-weight: inherit }` in this profile, and a weight token must not reach a link.
3407
+ // `:where()` keeps both at one class of specificity, so a section's own CTA rule still wins.
3408
+ ".gem-button { min-width: 12rem; border-radius: var(--gem-radius-button, 0); font-weight: 400; box-shadow: none; }",
3409
+ ".gem-button:where(:not(.gem-button--link)) { font-weight: var(--gem-font-button-weight, 400); }",
3261
3410
  ".gem-product-card-wrapper { display: block; min-width: 0; }",
3262
3411
  ".gem-card, .gem-product-card { border: 0; border-radius: 0; box-shadow: none; background: transparent; padding: 0; }",
3263
3412
  ".gem-product-card__media, .gem-media { border: 0; border-radius: 0; box-shadow: none; background: rgb(244 244 244); }",
@@ -3453,7 +3602,9 @@ function createDawnParityMobileCss() {
3453
3602
  " .gem-product-card__content { padding-top: 8px; gap: 4px; }",
3454
3603
  " .gem-product-card__title { font-size: 15px; line-height: 24px; }",
3455
3604
  " .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); }",
3605
+ // Fallback = the profile's own phone heading, declared once as --gem-heading-phone-cap on the :root above.
3606
+ " .gem-section__heading { font-size: var(--gem-section-heading-size-mobile, var(--gem-heading-phone-cap)); }",
3607
+ " .gem-hero-banner h1, .gem-hero-banner h2 { font-size: var(--gem-hero-heading-size-mobile, var(--gem-heading-phone-cap)); }",
3457
3608
  " .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
3609
  " .gem-product-main[data-gem-product-main-layout='dawn-main'] > .gem-section__inner { width: calc(100% - 30px); }",
3459
3610
  " .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 +5878,10 @@ function gemFrameworkPublicCssVariables() {
5727
5878
  createPrimitiveCss(),
5728
5879
  String(createSectionCss()),
5729
5880
  String(createShellCss()),
5730
- String(createGemSectionRuntimeContractCss())
5881
+ String(createGemSectionRuntimeContractCss()),
5882
+ // The button case rule only exists when a token sets it; a section may still read its variables (a CTA rule
5883
+ // beats the zero-specificity rule, so opting in means `text-transform: var(--gem-font-button-transform, none)`).
5884
+ gemButtonCaseCss({ typography: { buttonTransform: "none", buttonLetterSpacing: "normal" } })
5731
5885
  ].join("\n").replace(/\/\*[\s\S]*?\*\//g, " ");
5732
5886
  frameworkPublicVariables = gemPublicCssVariablesFrom(css);
5733
5887
  }
@@ -6898,6 +7052,8 @@ function stringSetting(value) {
6898
7052
  export {
6899
7053
  COUNTDOWN_END_RUNTIME_LINES,
6900
7054
  GEM_AMBIENT_CSS,
7055
+ GEM_BUTTON_TOKEN_KEYS,
7056
+ GEM_BUTTON_TOKEN_SHAPES,
6901
7057
  GEM_COUNTDOWN_ENDED_CSS,
6902
7058
  GEM_FIELD_JOINS,
6903
7059
  GEM_FIELD_SHAPES,
@@ -6908,6 +7064,8 @@ export {
6908
7064
  GEM_FORM_TOKEN_KEYS,
6909
7065
  GEM_FORM_VAR_SCOPE,
6910
7066
  GEM_GRID_COLUMNS_ROW_KNOB_CSS,
7067
+ GEM_HEADING_SIZE_CSS_VARS,
7068
+ GEM_LINE_HEIGHT_TOKEN_KEYS,
6911
7069
  GEM_PART_CLASS,
6912
7070
  GEM_POPUP_SHELL_CSS,
6913
7071
  GEM_REVEAL_CSS,
@@ -6939,6 +7097,7 @@ export {
6939
7097
  createThemeProfileCss,
6940
7098
  defaultGemTokens,
6941
7099
  defaultSelection,
7100
+ gemButtonCaseCss,
6942
7101
  gemFormAttributes,
6943
7102
  gemFormCssBlock,
6944
7103
  gemFormCssVars,
@@ -6947,7 +7106,9 @@ export {
6947
7106
  gemFrameworkRuntimePlugins,
6948
7107
  gemFrameworkValidationRules,
6949
7108
  gemFrameworkVersion,
7109
+ gemMobileTokenCssVars,
6950
7110
  gemPartClass,
7111
+ gemPhoneTokenTarget,
6951
7112
  gemPlanShapeKnobs,
6952
7113
  gemRuntimePluginModuleKeys,
6953
7114
  gemRuntimePluginSources,
@@ -6958,6 +7119,7 @@ export {
6958
7119
  motionCssVarAliases,
6959
7120
  normalizeGemTokens,
6960
7121
  offerEngineRuntimeScript,
7122
+ optionalGemTokenKeys,
6961
7123
  planToRuntimeBootstrapConfig,
6962
7124
  renderGemFrameworkManifest,
6963
7125
  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%; }