@panphora/hyper-cms 0.5.0 → 0.6.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/src/refresh.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { engine } from 'hyper-html-api'
2
2
  import { buildForm } from './form-builder.js'
3
3
  import { morphForm } from './morph.js'
4
- import { injectDefaults, injectUploadComponents } from './templates.js'
4
+ import { injectDefaults, injectComponents } from './templates.js'
5
5
  import { deriveFormRules } from './form-rules.js'
6
6
  import { coerceBooleans, applyErrorState } from './events.js'
7
+ import { enhanceFields, upgradeRichTextRules } from './enhance.js'
7
8
 
8
9
  const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
9
10
 
@@ -13,12 +14,15 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
13
14
  // livesync-replaced rules tag is picked up. Document-scoped via ctx.doc.
14
15
  const found = engine.findRules(ctx.doc, ctx.rulesSource || 'cms')
15
16
  if (found) {
16
- ctx.pageRules = found.rules
17
+ // Re-apply the rich-text upgrade open() performed: the tag holds the
18
+ // author's bare rules, and using them raw would rebind upgraded fields
19
+ // back to textContent mid-session.
20
+ ctx.pageRules = ctx.richText ? upgradeRichTextRules(found.rules, ctx.pageRoot) : found.rules
17
21
  ctx.rulesTagNode = found.tagNode
18
22
  }
19
23
  // Re-derive formRules so schema changes (rule shape, template overrides) flow through.
20
24
  injectDefaults(ctx.doc)
21
- injectUploadComponents(ctx.doc, ctx.pageRules)
25
+ injectComponents(ctx.doc, ctx.pageRules)
22
26
  ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
23
27
 
24
28
  const newData = coerceBooleans(
@@ -32,6 +36,9 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
32
36
  doc: ctx.doc,
33
37
  })
34
38
  morphForm(ctx.formRoot, fragment, { ignoreActiveValue })
39
+ // The morph can rebuild field nodes, dropping autosize heights and richclay
40
+ // instances (instances are per-element); re-enhance whatever is new.
41
+ enhanceFields(ctx.formRoot, ctx.doc)
35
42
  // morphForm rebuilds the form structure; inline error slots get wiped. Re-apply
36
43
  // the last error state so error messages survive across observer-driven refreshes
37
44
  // (e.g., the rollback after a failed add).
package/src/shell.js CHANGED
@@ -155,6 +155,10 @@ function ensureStyles(doc) {
155
155
  const style = doc.createElement('style')
156
156
  style.id = SHELL_STYLE_ID
157
157
  style.setAttribute('save-remove', '')
158
+ // save-ignore mirrors the shell root so a full-document morph (e.g. a live-sync
159
+ // head-merge) preserves the stylesheet instead of stripping it; without it the
160
+ // style survives only via restoreChrome's reactive re-injection.
161
+ style.setAttribute('save-ignore', '')
158
162
  style.textContent = cssText
159
163
  ;(doc.head || doc.documentElement).appendChild(style)
160
164
  styledDocs.add(doc)
@@ -170,12 +174,19 @@ function ensureStyles(doc) {
170
174
  link.rel = 'stylesheet'
171
175
  link.id = SHELL_STYLE_ID
172
176
  link.setAttribute('save-remove', '')
177
+ link.setAttribute('save-ignore', '')
173
178
  link.href = href
174
179
  ;(doc.head || doc.documentElement).appendChild(link)
175
180
  styledDocs.add(doc)
176
181
  } catch (_) {
177
- // SSR or environments without import.meta.url — author can call
178
- // installStyles(text) manually.
182
+ // No bundled CSS and no resolvable co-located theme (e.g. the IIFE bundle,
183
+ // where import.meta is undefined). Warn loudly rather than mount a silently
184
+ // unstyled shell — the host must call installStyles(themeText) before opening.
185
+ console.warn(
186
+ 'hypercms: shell stylesheet not applied — cssText is empty and the ' +
187
+ 'co-located theme fallback is unavailable. Call installStyles(themeText) ' +
188
+ 'before opening the CMS.'
189
+ )
179
190
  }
180
191
  }
181
192
 
package/src/templates.js CHANGED
@@ -23,10 +23,13 @@ const SORTABLE_GRIP = `<div class="hcms-drag-handle mirk-sortable__grip" aria-hi
23
23
  const CLOSE_ICON = `<svg class="hcms-x" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"></path></svg>`
24
24
 
25
25
  const DEFAULT_TEMPLATES = {
26
+ // The default scalar is a one-row auto-growing textarea, not an <input>:
27
+ // page copy is prose and routinely wraps. Growth comes from field-sizing:
28
+ // content in the theme, with a scrollHeight fallback (enhance.js).
26
29
  '@scalar': `
27
30
  <label class="hcms-field" data-hcms-shape="scalar">
28
31
  <span class="hcms-label" data-hcms-label></span>
29
- <input class="mirk-input" data-hcms-field />
32
+ <textarea class="mirk-textarea" rows="1" data-hcms-field></textarea>
30
33
  <div class="hcms-error" hidden></div>
31
34
  </label>
32
35
  `,
@@ -116,11 +119,100 @@ const DEFAULT_TEMPLATES = {
116
119
  <div class="hcms-error" hidden></div>
117
120
  </div>
118
121
  `,
122
+ // Named built-in controls, opt-in via data-hcms-component on the page element
123
+ // a rule points at. Promoted from the demo custom templates (mirk chrome,
124
+ // already themed in theme.generated.css), so the easy path needs no template
125
+ // authoring. @checkbox/@toggle bind the same input[type=checkbox]@checked
126
+ // machinery; @select/@radio get their entries from data-hcms-options (the
127
+ // form-builder populates the empty <select> / clones the radio prototype).
128
+ '@checkbox': `
129
+ <div class="hcms-field hcms-field--row" data-hcms-shape="scalar">
130
+ <span class="hcms-label" data-hcms-label></span>
131
+ <label class="mirk-checkbox">
132
+ <input type="checkbox" class="mirk-sr-only" data-hcms-field />
133
+ <span class="mirk-checkbox__box"><span class="mirk-checkbox__mark"></span></span>
134
+ </label>
135
+ <div class="hcms-error" hidden></div>
136
+ </div>
137
+ `,
138
+ '@toggle': `
139
+ <div class="hcms-field hcms-field--row" data-hcms-shape="scalar">
140
+ <span class="hcms-label" data-hcms-label></span>
141
+ <label class="mirk-toggle">
142
+ <input type="checkbox" role="switch" class="mirk-sr-only" data-hcms-field />
143
+ <span class="mirk-toggle__track"><span class="mirk-toggle__thumb"></span></span>
144
+ </label>
145
+ <div class="hcms-error" hidden></div>
146
+ </div>
147
+ `,
148
+ '@select': `
149
+ <label class="hcms-field" data-hcms-shape="scalar">
150
+ <span class="hcms-label" data-hcms-label></span>
151
+ <div class="mirk-select">
152
+ <select class="mirk-select__field" data-hcms-field></select>
153
+ <span aria-hidden="true" class="mirk-select__chevron">›</span>
154
+ </div>
155
+ <div class="hcms-error" hidden></div>
156
+ </label>
157
+ `,
158
+ '@radio': `
159
+ <div class="hcms-field" data-hcms-shape="scalar">
160
+ <span class="hcms-label" data-hcms-label></span>
161
+ <div class="hcms-radio-row">
162
+ <label class="mirk-radio">
163
+ <input type="radio" class="mirk-sr-only" data-hcms-field />
164
+ <span class="mirk-radio__ring"><span class="mirk-radio__fill"></span><span class="mirk-radio__dot"></span></span>
165
+ <span class="mirk-radio__label"></span>
166
+ </label>
167
+ </div>
168
+ <div class="hcms-error" hidden></div>
169
+ </div>
170
+ `,
171
+ '@textarea': `
172
+ <label class="hcms-field" data-hcms-shape="scalar">
173
+ <span class="hcms-label" data-hcms-label></span>
174
+ <textarea class="mirk-textarea" rows="3" data-hcms-field></textarea>
175
+ <div class="hcms-error" hidden></div>
176
+ </label>
177
+ `,
178
+ // Rich-text surface for @innerHTML-bound rules: a contenteditable div whose
179
+ // value interface is innerHTML (fieldPropertyFor), so links and inline
180
+ // formatting survive the round-trip. richclay mounts on it when the host
181
+ // page ships it (enhance.js); otherwise it stays a plain contenteditable.
182
+ '@richtext': `
183
+ <div class="hcms-field" data-hcms-shape="scalar">
184
+ <span class="hcms-label" data-hcms-label></span>
185
+ <div class="mirk-textarea hcms-richtext" contenteditable="true" data-hcms-field></div>
186
+ <div class="hcms-error" hidden></div>
187
+ </div>
188
+ `,
189
+ '@number': `
190
+ <label class="hcms-field" data-hcms-shape="scalar">
191
+ <span class="hcms-label" data-hcms-label></span>
192
+ <input class="mirk-input" type="number" data-hcms-field />
193
+ <div class="hcms-error" hidden></div>
194
+ </label>
195
+ `,
196
+ '@chips': `
197
+ <div class="hcms-field hcms-chips" data-hcms-shape="scalar-array">
198
+ <span class="hcms-label" data-hcms-label></span>
199
+ <div class="mirk-tags hcms-array-items"></div>
200
+ <button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
201
+ <div class="hcms-error" hidden></div>
202
+ </div>
203
+ `,
204
+ '@chips-item': `
205
+ <span class="mirk-tags__chip" data-hcms-array-item>
206
+ <input class="hcms-chip-field" data-hcms-field aria-label="Item" placeholder="…" />
207
+ <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">×</button>
208
+ </span>
209
+ `,
119
210
  }
120
211
 
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.
212
+ // Shape templates fill-the-gaps on every managed page. The opt-in components
213
+ // (@file/@image uploads + the named controls) are injected on demand the first
214
+ // time a field selects them (the Slice-2 component seam), so pages using none
215
+ // stay clean.
124
216
  const ALWAYS_INJECT_KEYS = [
125
217
  '@scalar',
126
218
  '@object',
@@ -136,9 +228,9 @@ export function injectDefaults(doc) {
136
228
  for (const key of ALWAYS_INJECT_KEYS) injectTemplate(doc, head, key)
137
229
  }
138
230
 
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.
231
+ // Inject one opt-in component template if the page hasn't already defined its
232
+ // own. Called by the component-selection seam the first time a field resolves
233
+ // to a component.
142
234
  export function injectComponentTemplate(doc, key) {
143
235
  if (!DEFAULT_TEMPLATES[key]) return null
144
236
  const head = doc && (doc.head || doc.documentElement)
@@ -146,33 +238,205 @@ export function injectComponentTemplate(doc, key) {
146
238
  return injectTemplate(doc, head, key)
147
239
  }
148
240
 
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) {
241
+ // Named-component selection. Explicit beats inferred: a data-hcms-component
242
+ // attribute on the page element a rule points at wins over the @prop-suffix
243
+ // inference, so a checked-bound field can still opt into the @toggle look.
244
+ // Inference stays narrow: @src means image upload (the dominant image-CMS
245
+ // workflow) and @checked means checkbox (a text input showing the literal word
246
+ // "true" helps nobody). @href is NOT inferred: editing a link/URL is far more
247
+ // common than uploading a file, so an a@href rule stays a plain URL field and
248
+ // the file upload is opt-in. Suffix is split like the engine (lastIndexOf('@'),
249
+ // see engine/extract.js). Returns a DEFAULT_TEMPLATES key.
250
+ const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox', innerHTML: '@richtext' }
251
+ // data-hcms-component values for scalar rules. "chips" is array-only (below).
252
+ const SCALAR_COMPONENT_BY_NAME = {
253
+ image: '@image',
254
+ file: '@file',
255
+ checkbox: '@checkbox',
256
+ toggle: '@toggle',
257
+ select: '@select',
258
+ radio: '@radio',
259
+ textarea: '@textarea',
260
+ number: '@number',
261
+ richtext: '@richtext',
262
+ }
263
+ // Every opt-in component injects on demand; only the 6 shape templates are
264
+ // always present.
265
+ const ON_DEMAND_COMPONENT_KEYS = new Set([
266
+ ...Object.values(SCALAR_COMPONENT_BY_NAME),
267
+ '@chips',
268
+ '@chips-item',
269
+ ])
270
+
271
+ export function componentForScalarRule(rule, doc, pathArr) {
161
272
  if (typeof rule !== 'string') return '@scalar'
162
273
  const at = rule.lastIndexOf('@')
274
+ const override = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
275
+ if (override && SCALAR_COMPONENT_BY_NAME[override]) {
276
+ const key = SCALAR_COMPONENT_BY_NAME[override]
277
+ // Value guards check exactly the elements the engine binds: under an array
278
+ // the same rule binds one element per item (every match votes), while a
279
+ // plain scalar binds only the FIRST match — a decorative second element
280
+ // sharing the selector must not veto the control.
281
+ const multi = Array.isArray(pathArr) && pathArr.some((s) => s === '*' || typeof s === 'number')
282
+ if (key === '@number' && !currentScalarValues(rule, at, doc, multi).every(numberInputCanHold)) {
283
+ return '@scalar'
284
+ }
285
+ if (
286
+ (key === '@checkbox' || key === '@toggle') &&
287
+ (at < 0 || rule.slice(at + 1) !== 'checked') &&
288
+ !currentScalarValues(rule, at, doc, multi).every(checkboxCanHold)
289
+ ) {
290
+ return '@scalar'
291
+ }
292
+ return key
293
+ }
163
294
  if (at >= 0) {
164
295
  const prop = rule.slice(at + 1)
165
296
  if (PROP_TO_COMPONENT[prop]) return PROP_TO_COMPONENT[prop]
166
297
  }
167
- const override = readComponentOverride(rule, at, doc)
168
- if (override === 'image') return '@image'
169
- if (override === 'file') return '@file'
170
298
  return '@scalar'
171
299
  }
172
300
 
173
- function readComponentOverride(rule, at, doc) {
301
+ // The data-hcms-component key the page element explicitly declares for this
302
+ // rule, or null (absent / unknown name / value-guard fallback). Lets the
303
+ // builder tell "author asked for this" apart from suffix inference when
304
+ // logging shadowing notices.
305
+ export function declaredComponentKey(rule, doc) {
306
+ if (typeof rule !== 'string') return null
307
+ const at = rule.lastIndexOf('@')
308
+ const name = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
309
+ return (name && SCALAR_COMPONENT_BY_NAME[name]) || null
310
+ }
311
+
312
+ // A number input physically rejects any value that isn't a plain number — it
313
+ // reads back '' — and the whole-form commit would then blank the page value on
314
+ // the next edit of ANY field. When any bound value isn't number-shaped, fall
315
+ // back to the plain text control. This runs inside componentForScalarRule so
316
+ // the builder and deriveFormRules stay in lockstep (both call it).
317
+ const NUMBER_VALUE_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/
318
+
319
+ function numberInputCanHold(value) {
320
+ if (value == null || value === '') return true
321
+ return NUMBER_VALUE_RE.test(String(value))
322
+ }
323
+
324
+ // A checkbox/toggle extracts its checked property — 'true' or 'false' — so any
325
+ // other bound value ("yes", "on", "1") would be clobbered to 'false' on the
326
+ // next whole-form commit. @checked projections are exempt in the caller: they
327
+ // round-trip the checked state faithfully by construction.
328
+ function checkboxCanHold(value) {
329
+ return value == null || value === '' || value === 'true' || value === 'false'
330
+ }
331
+
332
+ // Read the page values the rule binds, the way the engine extracts them:
333
+ // trimmed textContent for text projections (adapter.text trims), the live
334
+ // property for @value, the raw attribute otherwise. Mirrors extraction's
335
+ // skips: cms-template seeds and the shell are never extracted, so their
336
+ // values don't get a vote. `multi` mirrors the engine's binding: all matches
337
+ // under an array (one element per item), only the first match otherwise.
338
+ function currentScalarValues(rule, at, doc, multi) {
339
+ if (!doc || !doc.querySelectorAll) return []
340
+ const selector = scalarSelectorOf(rule, at)
341
+ if (!selector || selector === '.') return []
342
+ let els = null
343
+ try {
344
+ els = doc.querySelectorAll(selector)
345
+ } catch {
346
+ return []
347
+ }
348
+ const prop = at >= 0 ? rule.slice(at + 1) : null
349
+ const out = []
350
+ for (const el of els) {
351
+ if (el.closest && el.closest('[cms-template], [data-hcms-shell]')) continue
352
+ if (prop) {
353
+ if (prop === 'value' && 'value' in el) out.push(el.value)
354
+ else out.push(el.getAttribute ? el.getAttribute(prop) : null)
355
+ } else {
356
+ out.push((el.textContent || '').trim())
357
+ }
358
+ if (!multi) break
359
+ }
360
+ return out
361
+ }
362
+
363
+ // Array-level component selection: data-hcms-component="chips" on the list
364
+ // container (or any ancestor of the items) swaps the scalar-array chrome for
365
+ // the chip list. Resolved through the first item the rule's selector matches,
366
+ // then closest(), so the natural authoring spot — the <ul> — works even though
367
+ // the selector targets the items. Growable-from-zero lists carry a cms-template
368
+ // item, so the item selector always has something to match.
369
+ export function componentForScalarArrayRule(rule, doc) {
370
+ if (typeof rule !== 'string' || !rule.endsWith('[]')) return null
371
+ if (!doc || !doc.querySelector) return null
372
+ const selector = rule.slice(0, -2).trim()
373
+ if (!selector) return null
374
+ let item = null
375
+ try {
376
+ item = doc.querySelector(selector)
377
+ } catch {
378
+ return null
379
+ }
380
+ const host = item && item.closest ? item.closest('[data-hcms-component]') : null
381
+ const name = host && host.getAttribute ? host.getAttribute('data-hcms-component') : null
382
+ if (name === 'chips') return { array: '@chips', item: '@chips-item' }
383
+ return null
384
+ }
385
+
386
+ // Template resolution shared by the builder and deriveFormRules: a custom
387
+ // template at the exact path wins, then a wildcard-path one, then the default.
388
+ export function resolveTemplate(pathArr, defaultKey, doc) {
389
+ const pathStr = pathArr.join('.')
390
+ const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
391
+ return (
392
+ (pathStr && findTemplate(doc, pathStr)) ||
393
+ (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
394
+ findTemplate(doc, defaultKey)
395
+ )
396
+ }
397
+
398
+ // The declared array component, but only when its array template actually
399
+ // wins at this path — a custom path/wildcard template that shadows it returns
400
+ // null. The form-builder (item chrome) and deriveFormRules (item leaf
401
+ // selector) MUST share this verdict: if they diverge, the derived selector
402
+ // stops matching the rendered item leaf, every item extracts null, and the
403
+ // whole-form commit writes those nulls back over real page data.
404
+ export function winningScalarArrayComponent(rule, pathArr, doc) {
405
+ const comp = componentForScalarArrayRule(rule, doc)
406
+ if (!comp) return null
407
+ const winner = resolveTemplate(pathArr, comp.array, doc)
408
+ if (winner && winner.getAttribute('data-hcms-tpl') === comp.array) return comp
409
+ return null
410
+ }
411
+
412
+ // Option list for @select/@radio: data-hcms-options="low medium high" on the
413
+ // same page element as data-hcms-component. Space-separated value tokens (the
414
+ // data-rules-name idiom); labels derive via humanize() like every other label.
415
+ export function readOptionsOverride(rule, doc) {
416
+ if (typeof rule !== 'string') return null
417
+ const at = rule.lastIndexOf('@')
418
+ const raw = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-options')
419
+ if (raw == null) return null
420
+ const tokens = raw.trim().split(/\s+/).filter(Boolean)
421
+ return tokens.length ? tokens : null
422
+ }
423
+
424
+ // Crop opt-in for @image: data-hcms-crop="1:1" | "16:9" | "free" on the page
425
+ // element. The form-builder copies it onto the built field so the upload path
426
+ // reads it without a second page lookup.
427
+ export function readCropOverride(rule, doc) {
428
+ if (typeof rule !== 'string') return null
429
+ const at = rule.lastIndexOf('@')
430
+ return readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-crop')
431
+ }
432
+
433
+ function scalarSelectorOf(rule, at) {
434
+ return at >= 0 ? rule.slice(0, at) : rule
435
+ }
436
+
437
+ // The only branch that reads the page DOM during selection.
438
+ function readElementAttr(selector, doc, attrName) {
174
439
  if (!doc || !doc.querySelector) return null
175
- const selector = at >= 0 ? rule.slice(0, at) : rule
176
440
  if (!selector || selector === '.') return null
177
441
  let el = null
178
442
  try {
@@ -180,13 +444,13 @@ function readComponentOverride(rule, at, doc) {
180
444
  } catch {
181
445
  return null
182
446
  }
183
- return el && el.getAttribute ? el.getAttribute('data-hcms-component') : null
447
+ return el && el.getAttribute ? el.getAttribute(attrName) : null
184
448
  }
185
449
 
186
- // Pre-inject the @file/@image templates this page's rules actually select, so
450
+ // Pre-inject the component templates this page's rules actually select, so
187
451
  // both deriveFormRules (selectors) and buildForm (DOM) resolve them. Walks the
188
452
  // same rule shapes as buildTemplateMap; idempotent and author-template-safe.
189
- export function injectUploadComponents(doc, pageRules) {
453
+ export function injectComponents(doc, pageRules) {
190
454
  if (!doc || pageRules == null) return
191
455
  walk(pageRules)
192
456
 
@@ -194,7 +458,15 @@ export function injectUploadComponents(doc, pageRules) {
194
458
  const kind = shapeKindOf(rule)
195
459
  if (kind === 'scalar') {
196
460
  const key = componentForScalarRule(rule, doc)
197
- if (UPLOAD_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
461
+ if (ON_DEMAND_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
462
+ return
463
+ }
464
+ if (kind === 'scalar-array') {
465
+ const comp = componentForScalarArrayRule(rule, doc)
466
+ if (comp) {
467
+ injectComponentTemplate(doc, comp.array)
468
+ injectComponentTemplate(doc, comp.item)
469
+ }
198
470
  return
199
471
  }
200
472
  if (kind === 'object') {
@@ -275,8 +547,14 @@ export function buildTemplateMap(pageRules, doc) {
275
547
  const pathStr = pathArr.join('.')
276
548
  const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
277
549
  const shape = shapeKindOf(rule)
550
+ const arrayComp = shape === 'scalar-array' ? componentForScalarArrayRule(rule, doc) : null
551
+ // Items follow the WINNING component (a shadowed chips array renders
552
+ // default items), matching buildScalarArray.
553
+ const winningComp = shape === 'scalar-array' ? winningScalarArrayComponent(rule, pathArr, doc) : null
278
554
  const defaultKey =
279
- shape === 'scalar' ? componentForScalarRule(rule, doc) : SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
555
+ shape === 'scalar'
556
+ ? componentForScalarRule(rule, doc, pathArr)
557
+ : (arrayComp && arrayComp.array) || SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
280
558
 
281
559
  let tpl =
282
560
  (pathStr && findTemplate(doc, pathStr)) ||
@@ -292,7 +570,9 @@ export function buildTemplateMap(pageRules, doc) {
292
570
  const itemKey = itemPath.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
293
571
  const itemShape = shape === 'object-array' ? 'object-array-item' : 'scalar-array-item'
294
572
  const itemTpl =
295
- findTemplate(doc, itemKey) || findTemplate(doc, '@' + itemShape)
573
+ findTemplate(doc, itemKey) ||
574
+ (winningComp && findTemplate(doc, winningComp.item)) ||
575
+ findTemplate(doc, '@' + itemShape)
296
576
  map.set(itemKey, itemTpl)
297
577
  if (shape === 'object-array') {
298
578
  const itemRule = rule[1]
@@ -227,7 +227,7 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
227
227
  display: flex;
228
228
  flex-direction: column;
229
229
  gap: 26px;
230
- padding: 24px 24px 28px;
230
+ padding: 12px 24px 28px;
231
231
  }
232
232
 
233
233
  /* one labeled scalar field */
@@ -253,6 +253,31 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
253
253
  padding: 9px 14px 10px;
254
254
  }
255
255
  .hcms-shell.pixel-quiet textarea.mirk-input { min-height: 76px; resize: vertical; }
256
+
257
+ /* default scalar fields: one-row textareas that grow with their content.
258
+ Browsers without field-sizing get a scrollHeight fallback (enhance.js). */
259
+ .hcms-shell.pixel-quiet .hcms-form textarea.mirk-textarea {
260
+ field-sizing: content;
261
+ resize: none;
262
+ overflow: hidden;
263
+ min-height: 0;
264
+ }
265
+
266
+ /* rich-text surface (@richtext): a contenteditable styled like a textarea */
267
+ .hcms-shell.pixel-quiet .hcms-richtext {
268
+ min-height: 2.5em;
269
+ cursor: text;
270
+ overflow-wrap: break-word;
271
+ }
272
+ .hcms-shell.pixel-quiet .hcms-richtext a {
273
+ color: inherit;
274
+ text-decoration: underline;
275
+ text-underline-offset: 2px;
276
+ }
277
+ .hcms-shell.pixel-quiet .hcms-richtext:empty::before {
278
+ content: attr(data-hcms-placeholder);
279
+ color: var(--mirk-placeholder-color);
280
+ }
256
281
  .hcms-shell.pixel-quiet .mirk-select__field {
257
282
  font-size: 15px;
258
283
  padding: 8px 40px 9px 14px;
@@ -264,6 +289,15 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
264
289
  /* inline radio row */
265
290
  .hcms-radio-row { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
266
291
 
292
+ /* chip-field (the @chips built-in): a borderless inline input that sizes to its
293
+ text, so chips read like static chips but stay inline-editable. */
294
+ .hcms-shell .mirk-tags__chip { padding-right: 6px; }
295
+ .hcms-shell .hcms-chip-field {
296
+ border: 0; background: transparent; color: inherit; font: inherit;
297
+ outline: none; min-width: 2ch; field-sizing: content; padding: 0;
298
+ }
299
+ .hcms-shell .hcms-chips .hcms-add { margin-top: 4px; align-self: flex-start; }
300
+
267
301
  /* ---------- OBJECT GROUP — a quiet framed band, not a heavy card ---------- */
268
302
  .hcms-object {
269
303
  display: flex;