@panphora/hyper-cms 0.11.6 → 0.12.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/src/templates.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { ruleAttrIndex } from 'hyper-html-api/engine'
2
2
  import { getRuleAtPath } from './path.js'
3
3
 
4
+ // --font-* names that hold something other than a family (Tailwind 4 uses
5
+ // --font-weight-*, many pages use --font-size-*), so they never get the font menu.
6
+ const NOT_A_FAMILY = /^--font-(size|weight|style|stretch|variant|feature-settings|variation-settings|optical-sizing|kerning|synthesis|smoothing|palette|size-adjust|display)(-|$)/
7
+
4
8
  export function humanize(key) {
5
9
  return key
6
10
  .replace(/([a-z])([A-Z])/g, '$1 $2')
@@ -194,6 +198,59 @@ const DEFAULT_TEMPLATES = {
194
198
  <div class="hcms-error" hidden></div>
195
199
  </label>
196
200
  `,
201
+ // @color: the hidden input is the field (the form commits it like any
202
+ // scalar); swatches, the current chip and the custom inputs are built and
203
+ // painted by color-control.js.
204
+ '@color': `
205
+ <div class="hcms-field hcms-color" data-hcms-shape="scalar">
206
+ <span class="hcms-label" data-hcms-label></span>
207
+ <input type="hidden" data-hcms-field />
208
+ <div class="hcms-color-row">
209
+ <button type="button" class="hcms-color-current" data-hcms-action="color-panel" aria-expanded="false">
210
+ <span class="hcms-color-chip"></span>
211
+ <span class="hcms-color-name"></span>
212
+ </button>
213
+ <button type="button" class="hcms-color-clear" data-hcms-action="color-clear" aria-label="Clear color">${CLOSE_ICON}</button>
214
+ </div>
215
+ <div class="hcms-color-panel" hidden>
216
+ <div class="hcms-color-grid"></div>
217
+ <div class="hcms-color-custom">
218
+ <input type="color" class="hcms-color-native" data-hcms-color-input="native" aria-label="Pick a custom color" />
219
+ <input class="mirk-input hcms-color-text" data-hcms-color-input="text" spellcheck="false" autocomplete="off" placeholder="Any CSS color" aria-label="Custom color" />
220
+ </div>
221
+ </div>
222
+ <div class="hcms-error" hidden></div>
223
+ </div>
224
+ `,
225
+ '@font': `
226
+ <div class="hcms-field hcms-font" data-hcms-shape="scalar">
227
+ <span class="hcms-label" data-hcms-label></span>
228
+ <input type="hidden" data-hcms-field />
229
+ <button type="button" class="hcms-font-current" data-hcms-action="font-panel" aria-haspopup="listbox" aria-expanded="false">
230
+ <span class="hcms-font-name"></span>
231
+ <span aria-hidden="true" class="hcms-font-chevron">›</span>
232
+ </button>
233
+ <div class="hcms-font-panel" hidden>
234
+ <div class="hcms-font-list" role="listbox"></div>
235
+ </div>
236
+ <div class="hcms-error" hidden></div>
237
+ </div>
238
+ `,
239
+ // @variant: a radio group of layout choices, each an icon plus a label; the
240
+ // page's own CSS keys on the attribute the choice writes.
241
+ '@variant': `
242
+ <div class="hcms-field hcms-variant" data-hcms-shape="scalar">
243
+ <span class="hcms-label" data-hcms-label></span>
244
+ <div class="hcms-variant-row" role="radiogroup">
245
+ <label class="hcms-variant-option">
246
+ <input type="radio" class="mirk-sr-only" data-hcms-field />
247
+ <span class="hcms-variant-icon"></span>
248
+ <span class="hcms-variant-label"></span>
249
+ </label>
250
+ </div>
251
+ <div class="hcms-error" hidden></div>
252
+ </div>
253
+ `,
197
254
  '@chips': `
198
255
  <div class="hcms-field hcms-chips" data-hcms-shape="scalar-array">
199
256
  <span class="hcms-label" data-hcms-label></span>
@@ -247,7 +304,9 @@ export function injectComponentTemplate(doc, key) {
247
304
  // "true" helps nobody). @href is NOT inferred: editing a link/URL is far more
248
305
  // common than uploading a file, so an a@href rule stays a plain URL field and
249
306
  // the file upload is opt-in. Suffix is split like the engine (ruleAttrIndex,
250
- // see engine/extract.js). Returns a DEFAULT_TEMPLATES key.
307
+ // see engine/extract.js). A custom property named --color or --color-* infers
308
+ // the color picker; --font / --font-* infers the font menu. Returns a
309
+ // DEFAULT_TEMPLATES key.
251
310
  const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox', innerHTML: '@richtext' }
252
311
  // data-hcms-component values for scalar rules. "chips" is array-only (below).
253
312
  const SCALAR_COMPONENT_BY_NAME = {
@@ -260,6 +319,9 @@ const SCALAR_COMPONENT_BY_NAME = {
260
319
  textarea: '@textarea',
261
320
  number: '@number',
262
321
  richtext: '@richtext',
322
+ color: '@color',
323
+ font: '@font',
324
+ variant: '@variant',
263
325
  }
264
326
  // Every opt-in component injects on demand; only the 6 shape templates are
265
327
  // always present.
@@ -273,7 +335,7 @@ export function componentForScalarRule(rule, doc, pathArr, pageRules) {
273
335
  if (typeof rule !== 'string') return '@scalar'
274
336
  const at = ruleAttrIndex(rule)
275
337
  const host = hostSelectorOf(rule, at, pathArr, pageRules)
276
- const override = readElementAttr(host, doc, 'data-hcms-component')
338
+ const override = readRuleAttr(rule, at, pathArr, pageRules, doc, 'data-hcms-component')
277
339
  if (override && SCALAR_COMPONENT_BY_NAME[override]) {
278
340
  const key = SCALAR_COMPONENT_BY_NAME[override]
279
341
  // Value guards check exactly the elements the engine binds: under an array
@@ -296,6 +358,8 @@ export function componentForScalarRule(rule, doc, pathArr, pageRules) {
296
358
  if (at >= 0) {
297
359
  const prop = rule.slice(at + 1)
298
360
  if (PROP_TO_COMPONENT[prop]) return PROP_TO_COMPONENT[prop]
361
+ if (/^--color(-|$)/.test(prop) && prop !== '--color-scheme') return '@color'
362
+ if (/^--font(-|$)/.test(prop) && !NOT_A_FAMILY.test(prop)) return '@font'
299
363
  }
300
364
  return '@scalar'
301
365
  }
@@ -307,10 +371,24 @@ export function componentForScalarRule(rule, doc, pathArr, pageRules) {
307
371
  export function declaredComponentKey(rule, doc, pathArr, pageRules) {
308
372
  if (typeof rule !== 'string') return null
309
373
  const at = ruleAttrIndex(rule)
310
- const name = readElementAttr(hostSelectorOf(rule, at, pathArr, pageRules), doc, 'data-hcms-component')
374
+ const name = readRuleAttr(rule, at, pathArr, pageRules, doc, 'data-hcms-component')
311
375
  return (name && SCALAR_COMPONENT_BY_NAME[name]) || null
312
376
  }
313
377
 
378
+ // Style fields (color, font, variant) are grouped apart from content. One bound
379
+ // to html, body or :root outside any list styles the whole page ('page'); any
380
+ // other styles its own element ('section'). Content fields return null.
381
+ const STYLE_COMPONENTS = new Set(['@color', '@font', '@variant'])
382
+ const PAGE_HOSTS = new Set(['html', 'body', ':root'])
383
+
384
+ export function styleRole(rule, doc, pathArr, pageRules) {
385
+ if (typeof rule !== 'string' || rule.endsWith('[]')) return null
386
+ if (!STYLE_COMPONENTS.has(componentForScalarRule(rule, doc, pathArr, pageRules))) return null
387
+ const inList = Array.isArray(pathArr) && pathArr.some((s) => s === '*' || typeof s === 'number')
388
+ const selector = scalarSelectorOf(rule, ruleAttrIndex(rule)).trim()
389
+ return !inList && PAGE_HOSTS.has(selector) ? 'page' : 'section'
390
+ }
391
+
314
392
  // A number input physically rejects any value that isn't a plain number — it
315
393
  // reads back '' — and the whole-form commit would then blank the page value on
316
394
  // the next edit of ANY field. When any bound value isn't number-shaped, fall
@@ -416,7 +494,7 @@ export function winningScalarArrayComponent(rule, pathArr, doc) {
416
494
  export function readOptionsOverride(rule, doc, pathArr, pageRules) {
417
495
  if (typeof rule !== 'string') return null
418
496
  const at = ruleAttrIndex(rule)
419
- const raw = readElementAttr(hostSelectorOf(rule, at, pathArr, pageRules), doc, 'data-hcms-options')
497
+ const raw = readRuleAttr(rule, at, pathArr, pageRules, doc, 'data-hcms-options')
420
498
  if (raw == null) return null
421
499
  const tokens = raw.trim().split(/\s+/).filter(Boolean)
422
500
  return tokens.length ? tokens : null
@@ -428,7 +506,7 @@ export function readOptionsOverride(rule, doc, pathArr, pageRules) {
428
506
  export function readCropOverride(rule, doc, pathArr, pageRules) {
429
507
  if (typeof rule !== 'string') return null
430
508
  const at = ruleAttrIndex(rule)
431
- return readElementAttr(hostSelectorOf(rule, at, pathArr, pageRules), doc, 'data-hcms-crop')
509
+ return readRuleAttr(rule, at, pathArr, pageRules, doc, 'data-hcms-crop')
432
510
  }
433
511
 
434
512
  function scalarSelectorOf(rule, at) {
@@ -493,6 +571,22 @@ function readElementAttr(selector, doc, attrName) {
493
571
  return el && el.getAttribute ? el.getAttribute(attrName) : null
494
572
  }
495
573
 
574
+ // The authoring attribute for ONE rule. An element can carry several rules
575
+ // (<html> holds every theme property), so data-hcms-component:--color-accent
576
+ // names the control for the @--color-accent rule alone and wins over the plain
577
+ // attribute. The plain attribute never applies to a @--* rule: it describes the
578
+ // element's own value, not a CSS variable stored on it.
579
+ function readRuleAttr(rule, at, pathArr, pageRules, doc, attrName) {
580
+ const host = hostSelectorOf(rule, at, pathArr, pageRules)
581
+ const prop = at >= 0 ? rule.slice(at + 1) : null
582
+ if (prop) {
583
+ const scoped = readElementAttr(host, doc, `${attrName}:${prop}`)
584
+ if (scoped != null) return scoped
585
+ if (prop.startsWith('--')) return null
586
+ }
587
+ return readElementAttr(host, doc, attrName)
588
+ }
589
+
496
590
  // Pre-inject the component templates this page's rules actually select, so
497
591
  // both deriveFormRules (selectors) and buildForm (DOM) resolve them. Walks the
498
592
  // same rule shapes as buildTemplateMap; idempotent and author-template-safe.
@@ -604,7 +698,7 @@ export function buildTemplateMap(pageRules, doc) {
604
698
  const winningComp = shape === 'scalar-array' ? winningScalarArrayComponent(rule, pathArr, doc) : null
605
699
  const defaultKey =
606
700
  shape === 'scalar'
607
- ? componentForScalarRule(rule, doc, pathArr)
701
+ ? componentForScalarRule(rule, doc, pathArr, pageRules)
608
702
  : (arrayComp && arrayComp.array) || SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
609
703
 
610
704
  let tpl =
@@ -458,6 +458,94 @@ body.hcms-open.hcms-overlay { overflow: hidden; --hcms-toggle-display: none; }
458
458
  color: var(--mirk-placeholder-color);
459
459
  }
460
460
 
461
+ /* ---------- COLOR (@color) ----------
462
+ Swatch colors arrive as --hcms-swatch on each element's style, so a value
463
+ never becomes a selector or a class. The full palette is an 11-column grid
464
+ (one row per hue); a narrowed set wraps. */
465
+ .hcms-color-row { display: flex; align-items: center; gap: 8px; }
466
+ .hcms-color-current {
467
+ display: inline-flex; align-items: center; gap: 8px;
468
+ padding: 4px 12px 4px 4px; border: 1px solid var(--mirk-input-border); border-radius: 999px;
469
+ background: var(--mirk-input-bg); color: var(--mirk-fg); font: inherit; cursor: pointer;
470
+ }
471
+ .hcms-color--inline .hcms-color-current { cursor: default; }
472
+ .hcms-color-chip, .hcms-color-swatch { background: var(--hcms-swatch, transparent); }
473
+ .hcms-color-chip {
474
+ width: 20px; height: 20px; border-radius: 50%;
475
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mirk-fg) 20%, transparent);
476
+ }
477
+ .hcms-color[data-hcms-color-empty] .hcms-color-chip {
478
+ background: repeating-conic-gradient(var(--mirk-input-border) 0 25%, transparent 0 50%) 50% / 8px 8px;
479
+ }
480
+ .hcms-color-clear {
481
+ display: inline-flex; padding: 2px; border: 0; background: transparent;
482
+ color: var(--mirk-placeholder-color); cursor: pointer;
483
+ }
484
+ .hcms-color-clear:hover { color: var(--mirk-fg); }
485
+ .hcms-color[data-hcms-color-empty] .hcms-color-clear { display: none; }
486
+ .hcms-color-panel { margin-top: 8px; }
487
+ .hcms-color-panel[hidden] { display: none; }
488
+ .hcms-color-grid { display: flex; flex-wrap: wrap; gap: 4px; }
489
+ .hcms-color-grid--full { display: grid; grid-template-columns: repeat(11, minmax(0, 1fr)); gap: 3px; }
490
+ .hcms-color-swatch {
491
+ width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; cursor: pointer;
492
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mirk-fg) 15%, transparent);
493
+ }
494
+ .hcms-color-grid--full .hcms-color-swatch { width: auto; height: auto; aspect-ratio: 1; }
495
+ .hcms-color-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--mirk-bg), 0 0 0 4px var(--mirk-fg); }
496
+ .hcms-color-swatch:focus-visible { outline: 2px solid var(--mirk-focus-color); outline-offset: 2px; }
497
+ .hcms-color-custom { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
498
+ .hcms-color-native {
499
+ width: 32px; height: 28px; padding: 0; border: 1px solid var(--mirk-input-border);
500
+ border-radius: 4px; background: none; cursor: pointer;
501
+ }
502
+ .hcms-color-text { flex: 1; min-width: 0; }
503
+
504
+ /* ---------- FONT (@font) ----------
505
+ Each option sets its own font-family inline, so the list previews every
506
+ family in its face once the editor-only preview stylesheet arrives. */
507
+ .hcms-font-current {
508
+ display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%;
509
+ padding: 6px 10px; border: 1px solid var(--mirk-input-border); border-radius: 4px;
510
+ background: var(--mirk-input-bg); color: var(--mirk-fg); font: inherit; cursor: pointer; text-align: left;
511
+ }
512
+ .hcms-font-name { font-size: 15px; }
513
+ .hcms-font-chevron { transform: rotate(90deg); color: var(--mirk-placeholder-color); }
514
+ .hcms-font-panel { margin-top: 6px; }
515
+ .hcms-font-panel[hidden] { display: none; }
516
+ .hcms-font-list {
517
+ display: flex; flex-direction: column; max-height: 320px; overflow-y: auto;
518
+ border: 1px solid var(--mirk-input-border); border-radius: 4px; background: var(--mirk-input-bg);
519
+ }
520
+ .hcms-font-group {
521
+ padding: 8px 10px 4px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
522
+ color: var(--mirk-placeholder-color);
523
+ }
524
+ .hcms-font-option {
525
+ padding: 6px 10px; border: 0; background: transparent; color: var(--mirk-fg);
526
+ font-size: 16px; text-align: left; cursor: pointer;
527
+ }
528
+ .hcms-font-option:hover { background: color-mix(in oklab, var(--mirk-fg) 8%, transparent); }
529
+ .hcms-font-option[aria-selected="true"] { background: color-mix(in oklab, var(--mirk-fg) 14%, transparent); }
530
+ .hcms-font-option:focus-visible { outline: 2px solid var(--mirk-focus-color); outline-offset: -2px; }
531
+
532
+ /* ---------- VARIANT (@variant) ----------
533
+ Native radios behind icon tiles: arrow keys move the choice, the tile with
534
+ the checked radio is the selected one. */
535
+ .hcms-variant-row { display: flex; flex-wrap: wrap; gap: 8px; }
536
+ .hcms-variant-option {
537
+ position: relative; display: inline-flex; flex-direction: column; align-items: center; gap: 4px;
538
+ min-width: 64px; padding: 8px 10px; border: 1px solid var(--mirk-input-border); border-radius: 6px;
539
+ background: var(--mirk-input-bg); color: var(--mirk-placeholder-color); font-size: 12px; cursor: pointer;
540
+ }
541
+ .hcms-variant-option:hover { color: var(--mirk-fg); }
542
+ .hcms-variant-option:has(input:checked) {
543
+ color: var(--mirk-fg); border-color: var(--mirk-fg);
544
+ box-shadow: inset 0 0 0 1px var(--mirk-fg);
545
+ }
546
+ .hcms-variant-option:has(input:focus-visible) { outline: 2px solid var(--mirk-focus-color); outline-offset: 2px; }
547
+ .hcms-variant-icon { display: inline-flex; }
548
+
461
549
  /* ---------- UPLOADING (spec §9) ----------
462
550
  One attribute, [data-hcms-uploading], set on the field for as long as the host
463
551
  has the bytes, plus --hcms-upload-progress carrying the percent. No markup of
@@ -826,6 +914,9 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
826
914
  .hcms-inline .hcms-form [data-hcms-path] { display: none; }
827
915
  .hcms-inline .hcms-form [data-hcms-path].is-hcms-inline-onpath,
828
916
  .hcms-inline .hcms-form [data-hcms-path].is-hcms-inline-active { display: block; }
917
+ /* The Page style group has no data path of its own: hide it like a path until
918
+ a reveal marks it on-path. */
919
+ .hcms-inline .hcms-form [data-hcms-page-style]:not(.is-hcms-inline-onpath) { display: none; }
829
920
 
830
921
  .hcms-inline .hcms-form .is-hcms-inline-onpath,
831
922
  .hcms-inline .hcms-form {
@@ -1469,6 +1469,94 @@ body.hcms-open.hcms-overlay { overflow: hidden; --hcms-toggle-display: none; }
1469
1469
  color: var(--mirk-placeholder-color);
1470
1470
  }
1471
1471
 
1472
+ /* ---------- COLOR (@color) ----------
1473
+ Swatch colors arrive as --hcms-swatch on each element's style, so a value
1474
+ never becomes a selector or a class. The full palette is an 11-column grid
1475
+ (one row per hue); a narrowed set wraps. */
1476
+ .hcms-color-row { display: flex; align-items: center; gap: 8px; }
1477
+ .hcms-color-current {
1478
+ display: inline-flex; align-items: center; gap: 8px;
1479
+ padding: 4px 12px 4px 4px; border: 1px solid var(--mirk-input-border); border-radius: 999px;
1480
+ background: var(--mirk-input-bg); color: var(--mirk-fg); font: inherit; cursor: pointer;
1481
+ }
1482
+ .hcms-color--inline .hcms-color-current { cursor: default; }
1483
+ .hcms-color-chip, .hcms-color-swatch { background: var(--hcms-swatch, transparent); }
1484
+ .hcms-color-chip {
1485
+ width: 20px; height: 20px; border-radius: 50%;
1486
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mirk-fg) 20%, transparent);
1487
+ }
1488
+ .hcms-color[data-hcms-color-empty] .hcms-color-chip {
1489
+ background: repeating-conic-gradient(var(--mirk-input-border) 0 25%, transparent 0 50%) 50% / 8px 8px;
1490
+ }
1491
+ .hcms-color-clear {
1492
+ display: inline-flex; padding: 2px; border: 0; background: transparent;
1493
+ color: var(--mirk-placeholder-color); cursor: pointer;
1494
+ }
1495
+ .hcms-color-clear:hover { color: var(--mirk-fg); }
1496
+ .hcms-color[data-hcms-color-empty] .hcms-color-clear { display: none; }
1497
+ .hcms-color-panel { margin-top: 8px; }
1498
+ .hcms-color-panel[hidden] { display: none; }
1499
+ .hcms-color-grid { display: flex; flex-wrap: wrap; gap: 4px; }
1500
+ .hcms-color-grid--full { display: grid; grid-template-columns: repeat(11, minmax(0, 1fr)); gap: 3px; }
1501
+ .hcms-color-swatch {
1502
+ width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; cursor: pointer;
1503
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mirk-fg) 15%, transparent);
1504
+ }
1505
+ .hcms-color-grid--full .hcms-color-swatch { width: auto; height: auto; aspect-ratio: 1; }
1506
+ .hcms-color-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--mirk-bg), 0 0 0 4px var(--mirk-fg); }
1507
+ .hcms-color-swatch:focus-visible { outline: 2px solid var(--mirk-focus-color); outline-offset: 2px; }
1508
+ .hcms-color-custom { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
1509
+ .hcms-color-native {
1510
+ width: 32px; height: 28px; padding: 0; border: 1px solid var(--mirk-input-border);
1511
+ border-radius: 4px; background: none; cursor: pointer;
1512
+ }
1513
+ .hcms-color-text { flex: 1; min-width: 0; }
1514
+
1515
+ /* ---------- FONT (@font) ----------
1516
+ Each option sets its own font-family inline, so the list previews every
1517
+ family in its face once the editor-only preview stylesheet arrives. */
1518
+ .hcms-font-current {
1519
+ display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%;
1520
+ padding: 6px 10px; border: 1px solid var(--mirk-input-border); border-radius: 4px;
1521
+ background: var(--mirk-input-bg); color: var(--mirk-fg); font: inherit; cursor: pointer; text-align: left;
1522
+ }
1523
+ .hcms-font-name { font-size: 15px; }
1524
+ .hcms-font-chevron { transform: rotate(90deg); color: var(--mirk-placeholder-color); }
1525
+ .hcms-font-panel { margin-top: 6px; }
1526
+ .hcms-font-panel[hidden] { display: none; }
1527
+ .hcms-font-list {
1528
+ display: flex; flex-direction: column; max-height: 320px; overflow-y: auto;
1529
+ border: 1px solid var(--mirk-input-border); border-radius: 4px; background: var(--mirk-input-bg);
1530
+ }
1531
+ .hcms-font-group {
1532
+ padding: 8px 10px 4px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
1533
+ color: var(--mirk-placeholder-color);
1534
+ }
1535
+ .hcms-font-option {
1536
+ padding: 6px 10px; border: 0; background: transparent; color: var(--mirk-fg);
1537
+ font-size: 16px; text-align: left; cursor: pointer;
1538
+ }
1539
+ .hcms-font-option:hover { background: color-mix(in oklab, var(--mirk-fg) 8%, transparent); }
1540
+ .hcms-font-option[aria-selected="true"] { background: color-mix(in oklab, var(--mirk-fg) 14%, transparent); }
1541
+ .hcms-font-option:focus-visible { outline: 2px solid var(--mirk-focus-color); outline-offset: -2px; }
1542
+
1543
+ /* ---------- VARIANT (@variant) ----------
1544
+ Native radios behind icon tiles: arrow keys move the choice, the tile with
1545
+ the checked radio is the selected one. */
1546
+ .hcms-variant-row { display: flex; flex-wrap: wrap; gap: 8px; }
1547
+ .hcms-variant-option {
1548
+ position: relative; display: inline-flex; flex-direction: column; align-items: center; gap: 4px;
1549
+ min-width: 64px; padding: 8px 10px; border: 1px solid var(--mirk-input-border); border-radius: 6px;
1550
+ background: var(--mirk-input-bg); color: var(--mirk-placeholder-color); font-size: 12px; cursor: pointer;
1551
+ }
1552
+ .hcms-variant-option:hover { color: var(--mirk-fg); }
1553
+ .hcms-variant-option:has(input:checked) {
1554
+ color: var(--mirk-fg); border-color: var(--mirk-fg);
1555
+ box-shadow: inset 0 0 0 1px var(--mirk-fg);
1556
+ }
1557
+ .hcms-variant-option:has(input:focus-visible) { outline: 2px solid var(--mirk-focus-color); outline-offset: 2px; }
1558
+ .hcms-variant-icon { display: inline-flex; }
1559
+
1472
1560
  /* ---------- UPLOADING (spec §9) ----------
1473
1561
  One attribute, [data-hcms-uploading], set on the field for as long as the host
1474
1562
  has the bytes, plus --hcms-upload-progress carrying the percent. No markup of
@@ -1837,6 +1925,9 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
1837
1925
  .hcms-inline .hcms-form [data-hcms-path] { display: none; }
1838
1926
  .hcms-inline .hcms-form [data-hcms-path].is-hcms-inline-onpath,
1839
1927
  .hcms-inline .hcms-form [data-hcms-path].is-hcms-inline-active { display: block; }
1928
+ /* The Page style group has no data path of its own: hide it like a path until
1929
+ a reveal marks it on-path. */
1930
+ .hcms-inline .hcms-form [data-hcms-page-style]:not(.is-hcms-inline-onpath) { display: none; }
1840
1931
 
1841
1932
  .hcms-inline .hcms-form .is-hcms-inline-onpath,
1842
1933
  .hcms-inline .hcms-form {