@panphora/hyper-cms 0.5.0 → 0.6.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
@@ -116,11 +116,89 @@ const DEFAULT_TEMPLATES = {
116
116
  <div class="hcms-error" hidden></div>
117
117
  </div>
118
118
  `,
119
+ // Named built-in controls, opt-in via data-hcms-component on the page element
120
+ // a rule points at. Promoted from the demo custom templates (mirk chrome,
121
+ // already themed in theme.generated.css), so the easy path needs no template
122
+ // authoring. @checkbox/@toggle bind the same input[type=checkbox]@checked
123
+ // machinery; @select/@radio get their entries from data-hcms-options (the
124
+ // form-builder populates the empty <select> / clones the radio prototype).
125
+ '@checkbox': `
126
+ <div class="hcms-field hcms-field--row" data-hcms-shape="scalar">
127
+ <span class="hcms-label" data-hcms-label></span>
128
+ <label class="mirk-checkbox">
129
+ <input type="checkbox" class="mirk-sr-only" data-hcms-field />
130
+ <span class="mirk-checkbox__box"><span class="mirk-checkbox__mark"></span></span>
131
+ </label>
132
+ <div class="hcms-error" hidden></div>
133
+ </div>
134
+ `,
135
+ '@toggle': `
136
+ <div class="hcms-field hcms-field--row" data-hcms-shape="scalar">
137
+ <span class="hcms-label" data-hcms-label></span>
138
+ <label class="mirk-toggle">
139
+ <input type="checkbox" role="switch" class="mirk-sr-only" data-hcms-field />
140
+ <span class="mirk-toggle__track"><span class="mirk-toggle__thumb"></span></span>
141
+ </label>
142
+ <div class="hcms-error" hidden></div>
143
+ </div>
144
+ `,
145
+ '@select': `
146
+ <label class="hcms-field" data-hcms-shape="scalar">
147
+ <span class="hcms-label" data-hcms-label></span>
148
+ <div class="mirk-select">
149
+ <select class="mirk-select__field" data-hcms-field></select>
150
+ <span aria-hidden="true" class="mirk-select__chevron">›</span>
151
+ </div>
152
+ <div class="hcms-error" hidden></div>
153
+ </label>
154
+ `,
155
+ '@radio': `
156
+ <div class="hcms-field" data-hcms-shape="scalar">
157
+ <span class="hcms-label" data-hcms-label></span>
158
+ <div class="hcms-radio-row">
159
+ <label class="mirk-radio">
160
+ <input type="radio" class="mirk-sr-only" data-hcms-field />
161
+ <span class="mirk-radio__ring"><span class="mirk-radio__fill"></span><span class="mirk-radio__dot"></span></span>
162
+ <span class="mirk-radio__label"></span>
163
+ </label>
164
+ </div>
165
+ <div class="hcms-error" hidden></div>
166
+ </div>
167
+ `,
168
+ '@textarea': `
169
+ <label class="hcms-field" data-hcms-shape="scalar">
170
+ <span class="hcms-label" data-hcms-label></span>
171
+ <textarea class="mirk-textarea" rows="3" data-hcms-field></textarea>
172
+ <div class="hcms-error" hidden></div>
173
+ </label>
174
+ `,
175
+ '@number': `
176
+ <label class="hcms-field" data-hcms-shape="scalar">
177
+ <span class="hcms-label" data-hcms-label></span>
178
+ <input class="mirk-input" type="number" data-hcms-field />
179
+ <div class="hcms-error" hidden></div>
180
+ </label>
181
+ `,
182
+ '@chips': `
183
+ <div class="hcms-field hcms-chips" data-hcms-shape="scalar-array">
184
+ <span class="hcms-label" data-hcms-label></span>
185
+ <div class="mirk-tags hcms-array-items"></div>
186
+ <button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
187
+ <div class="hcms-error" hidden></div>
188
+ </div>
189
+ `,
190
+ '@chips-item': `
191
+ <span class="mirk-tags__chip" data-hcms-array-item>
192
+ <input class="hcms-chip-field" data-hcms-field aria-label="Item" placeholder="…" />
193
+ <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">×</button>
194
+ </span>
195
+ `,
119
196
  }
120
197
 
121
- // Shape templates fill-the-gaps on every managed page. The opt-in upload
122
- // components (@file/@image) are injected on demand the first time a field
123
- // selects them (the Slice-2 component seam), so pages using neither stay clean.
198
+ // Shape templates fill-the-gaps on every managed page. The opt-in components
199
+ // (@file/@image uploads + the named controls) are injected on demand the first
200
+ // time a field selects them (the Slice-2 component seam), so pages using none
201
+ // stay clean.
124
202
  const ALWAYS_INJECT_KEYS = [
125
203
  '@scalar',
126
204
  '@object',
@@ -136,9 +214,9 @@ export function injectDefaults(doc) {
136
214
  for (const key of ALWAYS_INJECT_KEYS) injectTemplate(doc, head, key)
137
215
  }
138
216
 
139
- // Inject one opt-in component template (@file/@image) if the page hasn't
140
- // already defined its own. Called by the component-selection seam the first
141
- // time a field resolves to an upload component.
217
+ // Inject one opt-in component template if the page hasn't already defined its
218
+ // own. Called by the component-selection seam the first time a field resolves
219
+ // to a component.
142
220
  export function injectComponentTemplate(doc, key) {
143
221
  if (!DEFAULT_TEMPLATES[key]) return null
144
222
  const head = doc && (doc.head || doc.documentElement)
@@ -146,33 +224,204 @@ export function injectComponentTemplate(doc, key) {
146
224
  return injectTemplate(doc, head, key)
147
225
  }
148
226
 
149
- // Opt-in upload-component selection. A scalar field upgrades from a plain text
150
- // input to an image-upload widget when its rule ends in @src (uploading is the
151
- // dominant image-CMS workflow). @href is NOT inferred: editing a link/URL is far
152
- // more common than uploading a file, so an a@href rule stays a plain URL field.
153
- // A file upload (and an image override) is opt-in via data-hcms-component on the
154
- // page element the rule points at — the only branch that reads the page DOM.
155
- // Suffix is split like the engine (lastIndexOf('@'), see engine/extract.js).
156
- // Returns a DEFAULT_TEMPLATES key.
157
- const PROP_TO_COMPONENT = { src: '@image' }
158
- const UPLOAD_COMPONENT_KEYS = new Set(['@image', '@file'])
159
-
160
- export function componentForScalarRule(rule, doc) {
227
+ // Named-component selection. Explicit beats inferred: a data-hcms-component
228
+ // attribute on the page element a rule points at wins over the @prop-suffix
229
+ // inference, so a checked-bound field can still opt into the @toggle look.
230
+ // Inference stays narrow: @src means image upload (the dominant image-CMS
231
+ // workflow) and @checked means checkbox (a text input showing the literal word
232
+ // "true" helps nobody). @href is NOT inferred: editing a link/URL is far more
233
+ // common than uploading a file, so an a@href rule stays a plain URL field and
234
+ // the file upload is opt-in. Suffix is split like the engine (lastIndexOf('@'),
235
+ // see engine/extract.js). Returns a DEFAULT_TEMPLATES key.
236
+ const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox' }
237
+ // data-hcms-component values for scalar rules. "chips" is array-only (below).
238
+ const SCALAR_COMPONENT_BY_NAME = {
239
+ image: '@image',
240
+ file: '@file',
241
+ checkbox: '@checkbox',
242
+ toggle: '@toggle',
243
+ select: '@select',
244
+ radio: '@radio',
245
+ textarea: '@textarea',
246
+ number: '@number',
247
+ }
248
+ // Every opt-in component injects on demand; only the 6 shape templates are
249
+ // always present.
250
+ const ON_DEMAND_COMPONENT_KEYS = new Set([
251
+ ...Object.values(SCALAR_COMPONENT_BY_NAME),
252
+ '@chips',
253
+ '@chips-item',
254
+ ])
255
+
256
+ export function componentForScalarRule(rule, doc, pathArr) {
161
257
  if (typeof rule !== 'string') return '@scalar'
162
258
  const at = rule.lastIndexOf('@')
259
+ const override = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
260
+ if (override && SCALAR_COMPONENT_BY_NAME[override]) {
261
+ const key = SCALAR_COMPONENT_BY_NAME[override]
262
+ // Value guards check exactly the elements the engine binds: under an array
263
+ // the same rule binds one element per item (every match votes), while a
264
+ // plain scalar binds only the FIRST match — a decorative second element
265
+ // sharing the selector must not veto the control.
266
+ const multi = Array.isArray(pathArr) && pathArr.some((s) => s === '*' || typeof s === 'number')
267
+ if (key === '@number' && !currentScalarValues(rule, at, doc, multi).every(numberInputCanHold)) {
268
+ return '@scalar'
269
+ }
270
+ if (
271
+ (key === '@checkbox' || key === '@toggle') &&
272
+ (at < 0 || rule.slice(at + 1) !== 'checked') &&
273
+ !currentScalarValues(rule, at, doc, multi).every(checkboxCanHold)
274
+ ) {
275
+ return '@scalar'
276
+ }
277
+ return key
278
+ }
163
279
  if (at >= 0) {
164
280
  const prop = rule.slice(at + 1)
165
281
  if (PROP_TO_COMPONENT[prop]) return PROP_TO_COMPONENT[prop]
166
282
  }
167
- const override = readComponentOverride(rule, at, doc)
168
- if (override === 'image') return '@image'
169
- if (override === 'file') return '@file'
170
283
  return '@scalar'
171
284
  }
172
285
 
173
- function readComponentOverride(rule, at, doc) {
286
+ // The data-hcms-component key the page element explicitly declares for this
287
+ // rule, or null (absent / unknown name / value-guard fallback). Lets the
288
+ // builder tell "author asked for this" apart from suffix inference when
289
+ // logging shadowing notices.
290
+ export function declaredComponentKey(rule, doc) {
291
+ if (typeof rule !== 'string') return null
292
+ const at = rule.lastIndexOf('@')
293
+ const name = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
294
+ return (name && SCALAR_COMPONENT_BY_NAME[name]) || null
295
+ }
296
+
297
+ // A number input physically rejects any value that isn't a plain number — it
298
+ // reads back '' — and the whole-form commit would then blank the page value on
299
+ // the next edit of ANY field. When any bound value isn't number-shaped, fall
300
+ // back to the plain text control. This runs inside componentForScalarRule so
301
+ // the builder and deriveFormRules stay in lockstep (both call it).
302
+ const NUMBER_VALUE_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/
303
+
304
+ function numberInputCanHold(value) {
305
+ if (value == null || value === '') return true
306
+ return NUMBER_VALUE_RE.test(String(value))
307
+ }
308
+
309
+ // A checkbox/toggle extracts its checked property — 'true' or 'false' — so any
310
+ // other bound value ("yes", "on", "1") would be clobbered to 'false' on the
311
+ // next whole-form commit. @checked projections are exempt in the caller: they
312
+ // round-trip the checked state faithfully by construction.
313
+ function checkboxCanHold(value) {
314
+ return value == null || value === '' || value === 'true' || value === 'false'
315
+ }
316
+
317
+ // Read the page values the rule binds, the way the engine extracts them:
318
+ // trimmed textContent for text projections (adapter.text trims), the live
319
+ // property for @value, the raw attribute otherwise. Mirrors extraction's
320
+ // skips: cms-template seeds and the shell are never extracted, so their
321
+ // values don't get a vote. `multi` mirrors the engine's binding: all matches
322
+ // under an array (one element per item), only the first match otherwise.
323
+ function currentScalarValues(rule, at, doc, multi) {
324
+ if (!doc || !doc.querySelectorAll) return []
325
+ const selector = scalarSelectorOf(rule, at)
326
+ if (!selector || selector === '.') return []
327
+ let els = null
328
+ try {
329
+ els = doc.querySelectorAll(selector)
330
+ } catch {
331
+ return []
332
+ }
333
+ const prop = at >= 0 ? rule.slice(at + 1) : null
334
+ const out = []
335
+ for (const el of els) {
336
+ if (el.closest && el.closest('[cms-template], [data-hcms-shell]')) continue
337
+ if (prop) {
338
+ if (prop === 'value' && 'value' in el) out.push(el.value)
339
+ else out.push(el.getAttribute ? el.getAttribute(prop) : null)
340
+ } else {
341
+ out.push((el.textContent || '').trim())
342
+ }
343
+ if (!multi) break
344
+ }
345
+ return out
346
+ }
347
+
348
+ // Array-level component selection: data-hcms-component="chips" on the list
349
+ // container (or any ancestor of the items) swaps the scalar-array chrome for
350
+ // the chip list. Resolved through the first item the rule's selector matches,
351
+ // then closest(), so the natural authoring spot — the <ul> — works even though
352
+ // the selector targets the items. Growable-from-zero lists carry a cms-template
353
+ // item, so the item selector always has something to match.
354
+ export function componentForScalarArrayRule(rule, doc) {
355
+ if (typeof rule !== 'string' || !rule.endsWith('[]')) return null
356
+ if (!doc || !doc.querySelector) return null
357
+ const selector = rule.slice(0, -2).trim()
358
+ if (!selector) return null
359
+ let item = null
360
+ try {
361
+ item = doc.querySelector(selector)
362
+ } catch {
363
+ return null
364
+ }
365
+ const host = item && item.closest ? item.closest('[data-hcms-component]') : null
366
+ const name = host && host.getAttribute ? host.getAttribute('data-hcms-component') : null
367
+ if (name === 'chips') return { array: '@chips', item: '@chips-item' }
368
+ return null
369
+ }
370
+
371
+ // Template resolution shared by the builder and deriveFormRules: a custom
372
+ // template at the exact path wins, then a wildcard-path one, then the default.
373
+ export function resolveTemplate(pathArr, defaultKey, doc) {
374
+ const pathStr = pathArr.join('.')
375
+ const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
376
+ return (
377
+ (pathStr && findTemplate(doc, pathStr)) ||
378
+ (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
379
+ findTemplate(doc, defaultKey)
380
+ )
381
+ }
382
+
383
+ // The declared array component, but only when its array template actually
384
+ // wins at this path — a custom path/wildcard template that shadows it returns
385
+ // null. The form-builder (item chrome) and deriveFormRules (item leaf
386
+ // selector) MUST share this verdict: if they diverge, the derived selector
387
+ // stops matching the rendered item leaf, every item extracts null, and the
388
+ // whole-form commit writes those nulls back over real page data.
389
+ export function winningScalarArrayComponent(rule, pathArr, doc) {
390
+ const comp = componentForScalarArrayRule(rule, doc)
391
+ if (!comp) return null
392
+ const winner = resolveTemplate(pathArr, comp.array, doc)
393
+ if (winner && winner.getAttribute('data-hcms-tpl') === comp.array) return comp
394
+ return null
395
+ }
396
+
397
+ // Option list for @select/@radio: data-hcms-options="low medium high" on the
398
+ // same page element as data-hcms-component. Space-separated value tokens (the
399
+ // data-rules-name idiom); labels derive via humanize() like every other label.
400
+ export function readOptionsOverride(rule, doc) {
401
+ if (typeof rule !== 'string') return null
402
+ const at = rule.lastIndexOf('@')
403
+ const raw = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-options')
404
+ if (raw == null) return null
405
+ const tokens = raw.trim().split(/\s+/).filter(Boolean)
406
+ return tokens.length ? tokens : null
407
+ }
408
+
409
+ // Crop opt-in for @image: data-hcms-crop="1:1" | "16:9" | "free" on the page
410
+ // element. The form-builder copies it onto the built field so the upload path
411
+ // reads it without a second page lookup.
412
+ export function readCropOverride(rule, doc) {
413
+ if (typeof rule !== 'string') return null
414
+ const at = rule.lastIndexOf('@')
415
+ return readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-crop')
416
+ }
417
+
418
+ function scalarSelectorOf(rule, at) {
419
+ return at >= 0 ? rule.slice(0, at) : rule
420
+ }
421
+
422
+ // The only branch that reads the page DOM during selection.
423
+ function readElementAttr(selector, doc, attrName) {
174
424
  if (!doc || !doc.querySelector) return null
175
- const selector = at >= 0 ? rule.slice(0, at) : rule
176
425
  if (!selector || selector === '.') return null
177
426
  let el = null
178
427
  try {
@@ -180,13 +429,13 @@ function readComponentOverride(rule, at, doc) {
180
429
  } catch {
181
430
  return null
182
431
  }
183
- return el && el.getAttribute ? el.getAttribute('data-hcms-component') : null
432
+ return el && el.getAttribute ? el.getAttribute(attrName) : null
184
433
  }
185
434
 
186
- // Pre-inject the @file/@image templates this page's rules actually select, so
435
+ // Pre-inject the component templates this page's rules actually select, so
187
436
  // both deriveFormRules (selectors) and buildForm (DOM) resolve them. Walks the
188
437
  // same rule shapes as buildTemplateMap; idempotent and author-template-safe.
189
- export function injectUploadComponents(doc, pageRules) {
438
+ export function injectComponents(doc, pageRules) {
190
439
  if (!doc || pageRules == null) return
191
440
  walk(pageRules)
192
441
 
@@ -194,7 +443,15 @@ export function injectUploadComponents(doc, pageRules) {
194
443
  const kind = shapeKindOf(rule)
195
444
  if (kind === 'scalar') {
196
445
  const key = componentForScalarRule(rule, doc)
197
- if (UPLOAD_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
446
+ if (ON_DEMAND_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
447
+ return
448
+ }
449
+ if (kind === 'scalar-array') {
450
+ const comp = componentForScalarArrayRule(rule, doc)
451
+ if (comp) {
452
+ injectComponentTemplate(doc, comp.array)
453
+ injectComponentTemplate(doc, comp.item)
454
+ }
198
455
  return
199
456
  }
200
457
  if (kind === 'object') {
@@ -275,8 +532,14 @@ export function buildTemplateMap(pageRules, doc) {
275
532
  const pathStr = pathArr.join('.')
276
533
  const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
277
534
  const shape = shapeKindOf(rule)
535
+ const arrayComp = shape === 'scalar-array' ? componentForScalarArrayRule(rule, doc) : null
536
+ // Items follow the WINNING component (a shadowed chips array renders
537
+ // default items), matching buildScalarArray.
538
+ const winningComp = shape === 'scalar-array' ? winningScalarArrayComponent(rule, pathArr, doc) : null
278
539
  const defaultKey =
279
- shape === 'scalar' ? componentForScalarRule(rule, doc) : SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
540
+ shape === 'scalar'
541
+ ? componentForScalarRule(rule, doc, pathArr)
542
+ : (arrayComp && arrayComp.array) || SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
280
543
 
281
544
  let tpl =
282
545
  (pathStr && findTemplate(doc, pathStr)) ||
@@ -292,7 +555,9 @@ export function buildTemplateMap(pageRules, doc) {
292
555
  const itemKey = itemPath.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
293
556
  const itemShape = shape === 'object-array' ? 'object-array-item' : 'scalar-array-item'
294
557
  const itemTpl =
295
- findTemplate(doc, itemKey) || findTemplate(doc, '@' + itemShape)
558
+ findTemplate(doc, itemKey) ||
559
+ (winningComp && findTemplate(doc, winningComp.item)) ||
560
+ findTemplate(doc, '@' + itemShape)
296
561
  map.set(itemKey, itemTpl)
297
562
  if (shape === 'object-array') {
298
563
  const itemRule = rule[1]
@@ -264,6 +264,15 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
264
264
  /* inline radio row */
265
265
  .hcms-radio-row { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
266
266
 
267
+ /* chip-field (the @chips built-in): a borderless inline input that sizes to its
268
+ text, so chips read like static chips but stay inline-editable. */
269
+ .hcms-shell .mirk-tags__chip { padding-right: 6px; }
270
+ .hcms-shell .hcms-chip-field {
271
+ border: 0; background: transparent; color: inherit; font: inherit;
272
+ outline: none; min-width: 2ch; field-sizing: content; padding: 0;
273
+ }
274
+ .hcms-shell .hcms-chips .hcms-add { margin-top: 4px; align-self: flex-start; }
275
+
267
276
  /* ---------- OBJECT GROUP — a quiet framed band, not a heavy card ---------- */
268
277
  .hcms-object {
269
278
  display: flex;
@@ -1,8 +1,8 @@
1
- /* GENERATED by scripts/build-theme.js from mirk-ui-kit/mirk.css — DO NOT EDIT.
2
- Source of truth: mirk-ui-kit/mirk.css + src/theme/pixel-quiet.overrides.css.
1
+ /* GENERATED by scripts/build-theme.js from mirk-interface/mirk.css — DO NOT EDIT.
2
+ Source of truth: mirk-interface/mirk.css + src/theme/pixel-quiet.overrides.css.
3
3
  Regenerate with: npm run build:theme */
4
4
 
5
- /* ===== mirk-interface@2.0.1, scoped to .hcms-shell ===== */
5
+ /* ===== mirk-interface@2.2.0, scoped to .hcms-shell ===== */
6
6
  /*
7
7
  * mirk.css — the mirk UI kit, v2.
8
8
  * Hand-written, no build. Fourteen form components as semantic BEM classes in
@@ -27,7 +27,7 @@
27
27
 
28
28
  @font-face {
29
29
  font-family: 'Departure Mono';
30
- src: url('https://cdn.jsdelivr.net/npm/mirk-interface@2.0.1/fonts/DepartureMono-1.500/DepartureMono-Regular.woff2') format('woff2');
30
+ src: url('https://cdn.jsdelivr.net/npm/mirk-interface@2.2.0/fonts/DepartureMono-1.500/DepartureMono-Regular.woff2') format('woff2');
31
31
  font-weight: 400; font-style: normal; font-display: swap;
32
32
  }
33
33
 
@@ -89,7 +89,44 @@
89
89
  background: var(--mirk-canvas); color: var(--mirk-fg);
90
90
  }
91
91
 
92
- /* Brand-theme escape hatch:
92
+ /* Built-in brand variant — "Pixel Quiet": mirk's warm soul with the volume
93
+ down. A softened, near-equal bevel, warmer ink, terracotta destructive, a
94
+ deeper navy-black dark. Authored with light-dark() like :root, so it follows
95
+ the OS and still flips with .dark / .light. Opt in: data-theme="pixel-quiet".
96
+ Sits after :root (equal specificity, source order wins). --mirk-radius /
97
+ --mirk-focus-offset are inherited unchanged; --mirk-ctrl-bg is unused. */
98
+ .hcms-shell[data-theme="pixel-quiet"] {
99
+ --mirk-fg: light-dark(#2B241B, #ECEAF2);
100
+ --mirk-bg: light-dark(#F7F2EA, #11131E);
101
+ --mirk-canvas: light-dark(#F7F2EA, #0B0C13);
102
+ --mirk-accent: light-dark(#efefe5, #1D1F2F);
103
+ --mirk-destructive: light-dark(#C24A3A, #ff5566);
104
+ --mirk-focus-color: light-dark(#C7AE93, #4A506B);
105
+ --mirk-bevel-bg: light-dark(#FCF8F1, #1A1D2C);
106
+ --mirk-bevel-fg: light-dark(#2B241B, #ECEAF2);
107
+ --mirk-bevel-tl: light-dark(#F0E7D8, #2A2E42);
108
+ --mirk-bevel-br: light-dark(#E2D4BF, #14182A);
109
+ --mirk-bevel-hover-bg: light-dark(#F4ECDF, #202436);
110
+ --mirk-pill-inner-top: light-dark(#FBF6EE, #202436);
111
+ --mirk-input-border: light-dark(#D8C8AF, #353B52);
112
+ --mirk-placeholder-color: light-dark(#A8987F, #6A7090);
113
+ --mirk-mark-fg: light-dark(#6B5942, #C9CDE0);
114
+ --mirk-toggle-bg: light-dark(#EFDBBD, #3E4660);
115
+ --mirk-toggle-hi: light-dark(#F4EADA, #4E567A);
116
+ --mirk-toggle-lo: light-dark(#C2A87E, #262B42);
117
+ --mirk-sortable-dot: light-dark(#DDCBB0, #353B52);
118
+ --mirk-sortable-shadow: light-dark(#C9B493, #0E1120);
119
+ --mirk-sortable-label: light-dark(#8C7B62, #8A90AB);
120
+ --mirk-sortable-placeholder: light-dark(#A8987F, #6A7090);
121
+ /* slider nub reuses the toggle family (identical pairing in :root); the fill
122
+ is the one value with no CMS precedent — a soft warm / muted navy track. */
123
+ --mirk-slider-fill: light-dark(#F2E0BD, #2A2E42);
124
+ --mirk-slider-nub-bg: light-dark(#EFDBBD, #3E4660);
125
+ --mirk-slider-nub-hi: light-dark(#F4EADA, #4E567A);
126
+ --mirk-slider-nub-lo: light-dark(#C2A87E, #262B42);
127
+ }
128
+
129
+ /* Roll your own the same way — an explicit [data-theme] block is the escape hatch:
93
130
  [data-theme="sunset"] { color-scheme: light; --mirk-accent: #f0a868; … } */
94
131
  }
95
132
 
@@ -291,6 +328,14 @@
291
328
  outline: 1px solid var(--mirk-focus-color); outline-offset: var(--mirk-focus-offset);
292
329
  }
293
330
 
331
+ .hcms-shell .mirk-checkbox--small { gap: 0.5rem; }
332
+ .hcms-shell .mirk-checkbox--small .mirk-checkbox__box { width: 18px; height: 18px; }
333
+ .hcms-shell .mirk-checkbox--small .mirk-checkbox__mark {
334
+ width: 5px; height: 10px;
335
+ border-right-width: 2px; border-bottom-width: 2px; translate: 0.5px -1px;
336
+ }
337
+ .hcms-shell .mirk-checkbox--small .mirk-checkbox__label { font-size: 14px; }
338
+
294
339
  /* ============================ RADIO ============================ */
295
340
  .hcms-shell .mirk-radio { display: inline-flex; align-items: center; gap: 0.75rem; cursor: pointer; width: fit-content; }
296
341
  .hcms-shell .mirk-radio__ring {
@@ -317,6 +362,11 @@
317
362
  outline: 1px solid var(--mirk-focus-color); outline-offset: var(--mirk-focus-offset);
318
363
  }
319
364
 
365
+ .hcms-shell .mirk-radio--small { gap: 0.5rem; }
366
+ .hcms-shell .mirk-radio--small .mirk-radio__ring { width: 20px; height: 20px; }
367
+ .hcms-shell .mirk-radio--small .mirk-radio__dot { width: 7px; height: 7px; }
368
+ .hcms-shell .mirk-radio--small .mirk-radio__label { font-size: 14px; }
369
+
320
370
  /* ============================ TOGGLE ============================ */
321
371
  .hcms-shell .mirk-toggle { display: inline-flex; align-items: center; gap: 0.75rem; cursor: pointer; width: fit-content; }
322
372
  .hcms-shell .mirk-toggle__track {
@@ -349,6 +399,15 @@
349
399
  }
350
400
  .hcms-shell .mirk-toggle--round:has(:checked) .mirk-toggle__thumb { translate: 21px; }
351
401
 
402
+ .hcms-shell .mirk-toggle--small { gap: 0.5rem; }
403
+ .hcms-shell .mirk-toggle--small .mirk-toggle__track { width: 42px; height: 23px; }
404
+ .hcms-shell .mirk-toggle--small .mirk-toggle__thumb { top: 2px; left: 2px; width: 17px; height: 17px; }
405
+ .hcms-shell .mirk-toggle--small:has(:checked) .mirk-toggle__thumb { translate: 19px; }
406
+ .hcms-shell .mirk-toggle--small .mirk-toggle__label { font-size: 14px; }
407
+ .hcms-shell .mirk-toggle--round.mirk-toggle--small .mirk-toggle__track { width: 43px; height: 25px; }
408
+ .hcms-shell .mirk-toggle--round.mirk-toggle--small .mirk-toggle__thumb { width: 19px; height: 19px; }
409
+ .hcms-shell .mirk-toggle--round.mirk-toggle--small:has(:checked) .mirk-toggle__thumb { translate: 18px; }
410
+
352
411
  /* ============================ SLIDER ============================ */
353
412
  .hcms-shell .mirk-slider { position: relative; height: 32px; width: 100%; --mirk-value: 0%; }
354
413
  .hcms-shell .mirk-slider__input {
@@ -381,6 +440,12 @@
381
440
  content: ""; position: absolute; inset: 2px; border-radius: 9999px; background: var(--mirk-slider-nub-bg);
382
441
  }
383
442
 
443
+ .hcms-shell .mirk-slider--small { height: 24px; }
444
+ .hcms-shell .mirk-slider--small .mirk-slider__track { height: 8px; }
445
+ .hcms-shell .mirk-slider--small .mirk-slider__nub { width: 16px; height: 16px; }
446
+ .hcms-shell .mirk-slider--round.mirk-slider--small .mirk-slider__track { height: 7px; }
447
+ .hcms-shell .mirk-slider--round.mirk-slider--small .mirk-slider__nub { width: 18px; height: 18px; }
448
+
384
449
  /* ============================ DATE ============================ */
385
450
  .hcms-shell .mirk-date { position: relative; }
386
451
  .hcms-shell .mirk-date__field {
@@ -402,6 +467,10 @@
402
467
  }
403
468
  .hcms-shell .mirk-date--rounded .mirk-date__field { border-radius: var(--mirk-radius); }
404
469
 
470
+ .hcms-shell .mirk-date--small .mirk-date__field { padding: 4px 36px 4px 12px; font-size: 14px; }
471
+ .hcms-shell .mirk-date--small .mirk-date__field::-webkit-calendar-picker-indicator { width: 36px; }
472
+ .hcms-shell .mirk-date--small .mirk-date__icon { right: 12px; }
473
+
405
474
  /* The native file/image inputs are visually hidden; their styled label drives
406
475
  them, and the focus ring rides :has(:focus-visible) on button or container. */
407
476
  .hcms-shell .mirk-file__input, .hcms-shell .mirk-image__input {
@@ -432,17 +501,28 @@
432
501
  .hcms-shell .mirk-file--compact .mirk-file__name { font-size: 16px; }
433
502
  .hcms-shell .mirk-file--compact.mirk-file--round { border-radius: 15px; }
434
503
 
435
- /* Filled: the name slot becomes a link to the chosen file, beside a bare × to
436
- clear it (the tags-remove pattern). Empty keeps the placeholder span. */
504
+ /* Filled: the name slot becomes a link to the chosen file, beside a circular ×
505
+ to clear it: a 1px ring over a bevel fill that turns destructive on hover.
506
+ Empty keeps the placeholder span. */
437
507
  .hcms-shell a.mirk-file__name { text-decoration: underline; text-underline-offset: 2px; }
438
508
  .hcms-shell .mirk-file__remove {
439
509
  appearance: none; -webkit-appearance: none; flex-shrink: 0;
510
+ width: 18px; height: 18px; border-radius: 50%;
440
511
  display: inline-flex; align-items: center; justify-content: center;
441
- margin: 0; padding: 0; border: 0; background: none; color: inherit;
442
- cursor: pointer; line-height: 0;
512
+ margin: 0; padding: 0; cursor: pointer; line-height: 0;
513
+ color: var(--mirk-bevel-fg); background: var(--mirk-bevel-bg);
514
+ border: 1px solid var(--mirk-input-border);
443
515
  }
444
- .hcms-shell .mirk-file__remove svg { display: block; width: 14px; height: 14px; }
445
- .hcms-shell .mirk-file__remove:hover { color: var(--mirk-destructive); }
516
+ .hcms-shell .mirk-file__remove svg { display: block; width: 66%; height: 66%; }
517
+ .hcms-shell .mirk-file__remove:hover { border-color: var(--mirk-destructive); color: var(--mirk-destructive); }
518
+
519
+ /* Small: tighter name + gap + remove ×; pair the trigger with mirk-button--small.
520
+ Composes with --compact (densest) and --round. */
521
+ .hcms-shell .mirk-file--small { gap: 0.5rem; }
522
+ .hcms-shell .mirk-file--small .mirk-file__name { font-size: 14px; }
523
+ .hcms-shell .mirk-file--small .mirk-file__remove { width: 16px; height: 16px; }
524
+ .hcms-shell .mirk-file--small.mirk-file--compact { padding: 3px 8px; }
525
+ .hcms-shell .mirk-file--small.mirk-file--compact .mirk-file__name { font-size: 13px; }
446
526
 
447
527
  /* ============================ IMAGE ============================ */
448
528
  .hcms-shell .mirk-image { display: flex; flex-direction: column; gap: 0.5rem; }
@@ -526,6 +606,13 @@
526
606
  background-image: linear-gradient(to top in oklab, var(--mirk-bevel-bg), var(--mirk-pill-inner-top));
527
607
  }
528
608
 
609
+ .hcms-shell .mirk-tags--small { gap: 0.375rem; padding: 0.375rem; }
610
+ .hcms-shell .mirk-tags--small .mirk-tags__chip { padding: 1px 6px 1px 10px; font-size: 12px; }
611
+ .hcms-shell .mirk-tags--small .mirk-tags__remove { font-size: 12px; }
612
+ .hcms-shell .mirk-tags--small .mirk-tags__input { font-size: 14px; min-width: 90px; }
613
+ .hcms-shell .mirk-tags--small.mirk-tags--round { border-radius: 12px; }
614
+ .hcms-shell .mirk-tags--small.mirk-tags--round .mirk-tags__chip-inner { padding: 1px 6px 1px 10px; }
615
+
529
616
  /* ============================ SORTABLE ============================ */
530
617
  .hcms-shell .mirk-sortable { display: flex; flex-direction: column; gap: 0.5rem; }
531
618
  .hcms-shell .mirk-sortable__item {
@@ -557,6 +644,11 @@
557
644
  .hcms-shell .mirk-sortable__field:focus-visible {
558
645
  outline: 1px solid var(--mirk-focus-color); outline-offset: var(--mirk-focus-offset);
559
646
  }
647
+ .hcms-shell .mirk-sortable--small { gap: 0.375rem; }
648
+ .hcms-shell .mirk-sortable--small .mirk-sortable__grip { width: 24px; }
649
+ .hcms-shell .mirk-sortable--small .mirk-sortable__row { padding: 5px 13px 6px; }
650
+ .hcms-shell .mirk-sortable--small .mirk-sortable__label { font-size: 10px; margin-bottom: 1px; }
651
+ .hcms-shell .mirk-sortable--small .mirk-sortable__field { font-size: 14px; }
560
652
  }
561
653
 
562
654
  /* Respect the machine — outside @layer components so it always wins. */
@@ -835,6 +927,15 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
835
927
  /* inline radio row */
836
928
  .hcms-radio-row { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
837
929
 
930
+ /* chip-field (the @chips built-in): a borderless inline input that sizes to its
931
+ text, so chips read like static chips but stay inline-editable. */
932
+ .hcms-shell .mirk-tags__chip { padding-right: 6px; }
933
+ .hcms-shell .hcms-chip-field {
934
+ border: 0; background: transparent; color: inherit; font: inherit;
935
+ outline: none; min-width: 2ch; field-sizing: content; padding: 0;
936
+ }
937
+ .hcms-shell .hcms-chips .hcms-add { margin-top: 4px; align-self: flex-start; }
938
+
838
939
  /* ---------- OBJECT GROUP — a quiet framed band, not a heavy card ---------- */
839
940
  .hcms-object {
840
941
  display: flex;
@@ -1,8 +1,8 @@
1
- /* VENDORED from mirk-ui-kit/mirk.js — DO NOT EDIT. Regenerate: npm run build:theme */
1
+ /* VENDORED from mirk-interface/mirk.js — DO NOT EDIT. Regenerate: npm run build:theme */
2
2
  if (typeof window !== "undefined" && typeof document !== "undefined") {
3
3
  /*
4
4
  * mirk.js — the delegated runtime for the mirk UI kit, v2.
5
- * https://github.com/davidmiranda/mirk-ui-kit
5
+ * https://github.com/panphora/mirk-interface
6
6
  *
7
7
  * One listener per interaction on `document`. No init(), no MutationObserver:
8
8
  * every current AND future element is handled, so injected / re-rendered /