@panphora/hyper-cms 0.11.6 → 0.13.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 =
@@ -140,20 +140,14 @@
140
140
  box-shadow: 16px 0 48px -28px rgba(43, 36, 27, 0.45);
141
141
  }
142
142
 
143
- /* Push the page over so docked content is never hidden underneath, and publish
144
- the geometry the floating toggle has to compose with. --hcms-toggle-shift is
145
- the distance the toggle moves left, which is the shell width only while the
146
- shell is docked on the right; docked left, as an overlay, or full-viewport it
147
- is zero. */
143
+ /* Push the page over so docked content is never hidden underneath. */
148
144
  body.hcms-open { --hcms-shell-width: 380px; }
149
145
  body.hcms-open:not(.hcms-overlay) { padding-right: var(--hcms-shell-width); }
150
- body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) { --hcms-toggle-shift: var(--hcms-shell-width); }
151
146
  body.hcms-open.hcms-side-left:not(.hcms-overlay) { padding-right: 0; padding-left: var(--hcms-shell-width); }
152
- body.hcms-open.hcms-overlay { overflow: hidden; --hcms-toggle-display: none; }
147
+ body.hcms-open.hcms-overlay { overflow: hidden; }
153
148
 
154
149
  @media (max-width: 799px) {
155
150
  body.hcms-open { --hcms-shell-width: 100vw; }
156
- body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) { --hcms-toggle-shift: 0px; }
157
151
  body.hcms-open:not(.hcms-overlay),
158
152
  body.hcms-open.hcms-side-left:not(.hcms-overlay) { padding-right: 0; padding-left: 0; }
159
153
  body.hcms-open { overflow: hidden; }
@@ -458,6 +452,94 @@ body.hcms-open.hcms-overlay { overflow: hidden; --hcms-toggle-display: none; }
458
452
  color: var(--mirk-placeholder-color);
459
453
  }
460
454
 
455
+ /* ---------- COLOR (@color) ----------
456
+ Swatch colors arrive as --hcms-swatch on each element's style, so a value
457
+ never becomes a selector or a class. The full palette is an 11-column grid
458
+ (one row per hue); a narrowed set wraps. */
459
+ .hcms-color-row { display: flex; align-items: center; gap: 8px; }
460
+ .hcms-color-current {
461
+ display: inline-flex; align-items: center; gap: 8px;
462
+ padding: 4px 12px 4px 4px; border: 1px solid var(--mirk-input-border); border-radius: 999px;
463
+ background: var(--mirk-input-bg); color: var(--mirk-fg); font: inherit; cursor: pointer;
464
+ }
465
+ .hcms-color--inline .hcms-color-current { cursor: default; }
466
+ .hcms-color-chip, .hcms-color-swatch { background: var(--hcms-swatch, transparent); }
467
+ .hcms-color-chip {
468
+ width: 20px; height: 20px; border-radius: 50%;
469
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mirk-fg) 20%, transparent);
470
+ }
471
+ .hcms-color[data-hcms-color-empty] .hcms-color-chip {
472
+ background: repeating-conic-gradient(var(--mirk-input-border) 0 25%, transparent 0 50%) 50% / 8px 8px;
473
+ }
474
+ .hcms-color-clear {
475
+ display: inline-flex; padding: 2px; border: 0; background: transparent;
476
+ color: var(--mirk-placeholder-color); cursor: pointer;
477
+ }
478
+ .hcms-color-clear:hover { color: var(--mirk-fg); }
479
+ .hcms-color[data-hcms-color-empty] .hcms-color-clear { display: none; }
480
+ .hcms-color-panel { margin-top: 8px; }
481
+ .hcms-color-panel[hidden] { display: none; }
482
+ .hcms-color-grid { display: flex; flex-wrap: wrap; gap: 4px; }
483
+ .hcms-color-grid--full { display: grid; grid-template-columns: repeat(11, minmax(0, 1fr)); gap: 3px; }
484
+ .hcms-color-swatch {
485
+ width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; cursor: pointer;
486
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mirk-fg) 15%, transparent);
487
+ }
488
+ .hcms-color-grid--full .hcms-color-swatch { width: auto; height: auto; aspect-ratio: 1; }
489
+ .hcms-color-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--mirk-bg), 0 0 0 4px var(--mirk-fg); }
490
+ .hcms-color-swatch:focus-visible { outline: 2px solid var(--mirk-focus-color); outline-offset: 2px; }
491
+ .hcms-color-custom { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
492
+ .hcms-color-native {
493
+ width: 32px; height: 28px; padding: 0; border: 1px solid var(--mirk-input-border);
494
+ border-radius: 4px; background: none; cursor: pointer;
495
+ }
496
+ .hcms-color-text { flex: 1; min-width: 0; }
497
+
498
+ /* ---------- FONT (@font) ----------
499
+ Each option sets its own font-family inline, so the list previews every
500
+ family in its face once the editor-only preview stylesheet arrives. */
501
+ .hcms-font-current {
502
+ display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%;
503
+ padding: 6px 10px; border: 1px solid var(--mirk-input-border); border-radius: 4px;
504
+ background: var(--mirk-input-bg); color: var(--mirk-fg); font: inherit; cursor: pointer; text-align: left;
505
+ }
506
+ .hcms-font-name { font-size: 15px; }
507
+ .hcms-font-chevron { transform: rotate(90deg); color: var(--mirk-placeholder-color); }
508
+ .hcms-font-panel { margin-top: 6px; }
509
+ .hcms-font-panel[hidden] { display: none; }
510
+ .hcms-font-list {
511
+ display: flex; flex-direction: column; max-height: 320px; overflow-y: auto;
512
+ border: 1px solid var(--mirk-input-border); border-radius: 4px; background: var(--mirk-input-bg);
513
+ }
514
+ .hcms-font-group {
515
+ padding: 8px 10px 4px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
516
+ color: var(--mirk-placeholder-color);
517
+ }
518
+ .hcms-font-option {
519
+ padding: 6px 10px; border: 0; background: transparent; color: var(--mirk-fg);
520
+ font-size: 16px; text-align: left; cursor: pointer;
521
+ }
522
+ .hcms-font-option:hover { background: color-mix(in oklab, var(--mirk-fg) 8%, transparent); }
523
+ .hcms-font-option[aria-selected="true"] { background: color-mix(in oklab, var(--mirk-fg) 14%, transparent); }
524
+ .hcms-font-option:focus-visible { outline: 2px solid var(--mirk-focus-color); outline-offset: -2px; }
525
+
526
+ /* ---------- VARIANT (@variant) ----------
527
+ Native radios behind icon tiles: arrow keys move the choice, the tile with
528
+ the checked radio is the selected one. */
529
+ .hcms-variant-row { display: flex; flex-wrap: wrap; gap: 8px; }
530
+ .hcms-variant-option {
531
+ position: relative; display: inline-flex; flex-direction: column; align-items: center; gap: 4px;
532
+ min-width: 64px; padding: 8px 10px; border: 1px solid var(--mirk-input-border); border-radius: 6px;
533
+ background: var(--mirk-input-bg); color: var(--mirk-placeholder-color); font-size: 12px; cursor: pointer;
534
+ }
535
+ .hcms-variant-option:hover { color: var(--mirk-fg); }
536
+ .hcms-variant-option:has(input:checked) {
537
+ color: var(--mirk-fg); border-color: var(--mirk-fg);
538
+ box-shadow: inset 0 0 0 1px var(--mirk-fg);
539
+ }
540
+ .hcms-variant-option:has(input:focus-visible) { outline: 2px solid var(--mirk-focus-color); outline-offset: 2px; }
541
+ .hcms-variant-icon { display: inline-flex; }
542
+
461
543
  /* ---------- UPLOADING (spec §9) ----------
462
544
  One attribute, [data-hcms-uploading], set on the field for as long as the host
463
545
  has the bytes, plus --hcms-upload-progress carrying the percent. No markup of
@@ -826,6 +908,9 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
826
908
  .hcms-inline .hcms-form [data-hcms-path] { display: none; }
827
909
  .hcms-inline .hcms-form [data-hcms-path].is-hcms-inline-onpath,
828
910
  .hcms-inline .hcms-form [data-hcms-path].is-hcms-inline-active { display: block; }
911
+ /* The Page style group has no data path of its own: hide it like a path until
912
+ a reveal marks it on-path. */
913
+ .hcms-inline .hcms-form [data-hcms-page-style]:not(.is-hcms-inline-onpath) { display: none; }
829
914
 
830
915
  .hcms-inline .hcms-form .is-hcms-inline-onpath,
831
916
  .hcms-inline .hcms-form {
@@ -1151,20 +1151,14 @@
1151
1151
  box-shadow: 16px 0 48px -28px rgba(43, 36, 27, 0.45);
1152
1152
  }
1153
1153
 
1154
- /* Push the page over so docked content is never hidden underneath, and publish
1155
- the geometry the floating toggle has to compose with. --hcms-toggle-shift is
1156
- the distance the toggle moves left, which is the shell width only while the
1157
- shell is docked on the right; docked left, as an overlay, or full-viewport it
1158
- is zero. */
1154
+ /* Push the page over so docked content is never hidden underneath. */
1159
1155
  body.hcms-open { --hcms-shell-width: 380px; }
1160
1156
  body.hcms-open:not(.hcms-overlay) { padding-right: var(--hcms-shell-width); }
1161
- body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) { --hcms-toggle-shift: var(--hcms-shell-width); }
1162
1157
  body.hcms-open.hcms-side-left:not(.hcms-overlay) { padding-right: 0; padding-left: var(--hcms-shell-width); }
1163
- body.hcms-open.hcms-overlay { overflow: hidden; --hcms-toggle-display: none; }
1158
+ body.hcms-open.hcms-overlay { overflow: hidden; }
1164
1159
 
1165
1160
  @media (max-width: 799px) {
1166
1161
  body.hcms-open { --hcms-shell-width: 100vw; }
1167
- body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) { --hcms-toggle-shift: 0px; }
1168
1162
  body.hcms-open:not(.hcms-overlay),
1169
1163
  body.hcms-open.hcms-side-left:not(.hcms-overlay) { padding-right: 0; padding-left: 0; }
1170
1164
  body.hcms-open { overflow: hidden; }
@@ -1469,6 +1463,94 @@ body.hcms-open.hcms-overlay { overflow: hidden; --hcms-toggle-display: none; }
1469
1463
  color: var(--mirk-placeholder-color);
1470
1464
  }
1471
1465
 
1466
+ /* ---------- COLOR (@color) ----------
1467
+ Swatch colors arrive as --hcms-swatch on each element's style, so a value
1468
+ never becomes a selector or a class. The full palette is an 11-column grid
1469
+ (one row per hue); a narrowed set wraps. */
1470
+ .hcms-color-row { display: flex; align-items: center; gap: 8px; }
1471
+ .hcms-color-current {
1472
+ display: inline-flex; align-items: center; gap: 8px;
1473
+ padding: 4px 12px 4px 4px; border: 1px solid var(--mirk-input-border); border-radius: 999px;
1474
+ background: var(--mirk-input-bg); color: var(--mirk-fg); font: inherit; cursor: pointer;
1475
+ }
1476
+ .hcms-color--inline .hcms-color-current { cursor: default; }
1477
+ .hcms-color-chip, .hcms-color-swatch { background: var(--hcms-swatch, transparent); }
1478
+ .hcms-color-chip {
1479
+ width: 20px; height: 20px; border-radius: 50%;
1480
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mirk-fg) 20%, transparent);
1481
+ }
1482
+ .hcms-color[data-hcms-color-empty] .hcms-color-chip {
1483
+ background: repeating-conic-gradient(var(--mirk-input-border) 0 25%, transparent 0 50%) 50% / 8px 8px;
1484
+ }
1485
+ .hcms-color-clear {
1486
+ display: inline-flex; padding: 2px; border: 0; background: transparent;
1487
+ color: var(--mirk-placeholder-color); cursor: pointer;
1488
+ }
1489
+ .hcms-color-clear:hover { color: var(--mirk-fg); }
1490
+ .hcms-color[data-hcms-color-empty] .hcms-color-clear { display: none; }
1491
+ .hcms-color-panel { margin-top: 8px; }
1492
+ .hcms-color-panel[hidden] { display: none; }
1493
+ .hcms-color-grid { display: flex; flex-wrap: wrap; gap: 4px; }
1494
+ .hcms-color-grid--full { display: grid; grid-template-columns: repeat(11, minmax(0, 1fr)); gap: 3px; }
1495
+ .hcms-color-swatch {
1496
+ width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; cursor: pointer;
1497
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mirk-fg) 15%, transparent);
1498
+ }
1499
+ .hcms-color-grid--full .hcms-color-swatch { width: auto; height: auto; aspect-ratio: 1; }
1500
+ .hcms-color-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--mirk-bg), 0 0 0 4px var(--mirk-fg); }
1501
+ .hcms-color-swatch:focus-visible { outline: 2px solid var(--mirk-focus-color); outline-offset: 2px; }
1502
+ .hcms-color-custom { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
1503
+ .hcms-color-native {
1504
+ width: 32px; height: 28px; padding: 0; border: 1px solid var(--mirk-input-border);
1505
+ border-radius: 4px; background: none; cursor: pointer;
1506
+ }
1507
+ .hcms-color-text { flex: 1; min-width: 0; }
1508
+
1509
+ /* ---------- FONT (@font) ----------
1510
+ Each option sets its own font-family inline, so the list previews every
1511
+ family in its face once the editor-only preview stylesheet arrives. */
1512
+ .hcms-font-current {
1513
+ display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%;
1514
+ padding: 6px 10px; border: 1px solid var(--mirk-input-border); border-radius: 4px;
1515
+ background: var(--mirk-input-bg); color: var(--mirk-fg); font: inherit; cursor: pointer; text-align: left;
1516
+ }
1517
+ .hcms-font-name { font-size: 15px; }
1518
+ .hcms-font-chevron { transform: rotate(90deg); color: var(--mirk-placeholder-color); }
1519
+ .hcms-font-panel { margin-top: 6px; }
1520
+ .hcms-font-panel[hidden] { display: none; }
1521
+ .hcms-font-list {
1522
+ display: flex; flex-direction: column; max-height: 320px; overflow-y: auto;
1523
+ border: 1px solid var(--mirk-input-border); border-radius: 4px; background: var(--mirk-input-bg);
1524
+ }
1525
+ .hcms-font-group {
1526
+ padding: 8px 10px 4px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
1527
+ color: var(--mirk-placeholder-color);
1528
+ }
1529
+ .hcms-font-option {
1530
+ padding: 6px 10px; border: 0; background: transparent; color: var(--mirk-fg);
1531
+ font-size: 16px; text-align: left; cursor: pointer;
1532
+ }
1533
+ .hcms-font-option:hover { background: color-mix(in oklab, var(--mirk-fg) 8%, transparent); }
1534
+ .hcms-font-option[aria-selected="true"] { background: color-mix(in oklab, var(--mirk-fg) 14%, transparent); }
1535
+ .hcms-font-option:focus-visible { outline: 2px solid var(--mirk-focus-color); outline-offset: -2px; }
1536
+
1537
+ /* ---------- VARIANT (@variant) ----------
1538
+ Native radios behind icon tiles: arrow keys move the choice, the tile with
1539
+ the checked radio is the selected one. */
1540
+ .hcms-variant-row { display: flex; flex-wrap: wrap; gap: 8px; }
1541
+ .hcms-variant-option {
1542
+ position: relative; display: inline-flex; flex-direction: column; align-items: center; gap: 4px;
1543
+ min-width: 64px; padding: 8px 10px; border: 1px solid var(--mirk-input-border); border-radius: 6px;
1544
+ background: var(--mirk-input-bg); color: var(--mirk-placeholder-color); font-size: 12px; cursor: pointer;
1545
+ }
1546
+ .hcms-variant-option:hover { color: var(--mirk-fg); }
1547
+ .hcms-variant-option:has(input:checked) {
1548
+ color: var(--mirk-fg); border-color: var(--mirk-fg);
1549
+ box-shadow: inset 0 0 0 1px var(--mirk-fg);
1550
+ }
1551
+ .hcms-variant-option:has(input:focus-visible) { outline: 2px solid var(--mirk-focus-color); outline-offset: 2px; }
1552
+ .hcms-variant-icon { display: inline-flex; }
1553
+
1472
1554
  /* ---------- UPLOADING (spec §9) ----------
1473
1555
  One attribute, [data-hcms-uploading], set on the field for as long as the host
1474
1556
  has the bytes, plus --hcms-upload-progress carrying the percent. No markup of
@@ -1837,6 +1919,9 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
1837
1919
  .hcms-inline .hcms-form [data-hcms-path] { display: none; }
1838
1920
  .hcms-inline .hcms-form [data-hcms-path].is-hcms-inline-onpath,
1839
1921
  .hcms-inline .hcms-form [data-hcms-path].is-hcms-inline-active { display: block; }
1922
+ /* The Page style group has no data path of its own: hide it like a path until
1923
+ a reveal marks it on-path. */
1924
+ .hcms-inline .hcms-form [data-hcms-page-style]:not(.is-hcms-inline-onpath) { display: none; }
1840
1925
 
1841
1926
  .hcms-inline .hcms-form .is-hcms-inline-onpath,
1842
1927
  .hcms-inline .hcms-form {
@@ -0,0 +1,46 @@
1
+ import { VARIANT_ICONS } from './catalogs/variant-icons.js'
2
+ import { humanize } from './templates.js'
3
+
4
+ // "2:cols-2" → { value: '2', icon: 'cols-2' }; a bare "cards" → its own icon
5
+ // when one exists. Split on the first colon only, so a value never loses text.
6
+ export function parseVariantOptions(tokens) {
7
+ return (tokens || []).map((token) => {
8
+ const colon = token.indexOf(':')
9
+ const value = colon === -1 ? token : token.slice(0, colon)
10
+ const iconName = colon === -1 ? token : token.slice(colon + 1)
11
+ const icon = VARIANT_ICONS[iconName] || null
12
+ return { value, icon, label: icon ? icon.label : humanize(value) }
13
+ })
14
+ }
15
+
16
+ // Build one native radio per option from the template's prototype row. The
17
+ // current value is prepended when it is not offered, so opening the editor
18
+ // never silently changes the page.
19
+ export function populateVariant(node, { tokens, value, name, doc }) {
20
+ const options = parseVariantOptions(tokens)
21
+ const current = value == null ? '' : String(value)
22
+ if (current !== '' && !options.some((o) => o.value === current)) {
23
+ options.unshift({ value: current, icon: null, label: humanize(current) })
24
+ }
25
+ const proto = node.querySelector('.hcms-variant-option')
26
+ if (!proto || !proto.parentNode) return options.length
27
+ for (const o of options) {
28
+ const row = proto.cloneNode(true)
29
+ const input = row.querySelector('input[type="radio"]')
30
+ if (input) {
31
+ input.value = o.value
32
+ input.name = name
33
+ }
34
+ const iconSlot = row.querySelector('.hcms-variant-icon')
35
+ if (iconSlot) {
36
+ if (o.icon) iconSlot.innerHTML = o.icon.svg
37
+ else iconSlot.remove()
38
+ }
39
+ const label = row.querySelector('.hcms-variant-label')
40
+ if (label) label.textContent = o.label
41
+ row.setAttribute('title', o.label)
42
+ proto.parentNode.insertBefore(row, proto)
43
+ }
44
+ proto.remove()
45
+ return options.length
46
+ }