@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/hyper-cms",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Live edit-in-place CMS sidebar for HTML pages with a hyper-html-api rules tag",
5
5
  "keywords": [
6
6
  "HTML",
package/src/events.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { engine } from 'hyper-html-api'
2
2
  import { applyWithRollback } from './apply-loop.js'
3
3
  import { fromString as pathFromString, getRuleAtPath } from './path.js'
4
- import { buildItem, fileNameFromUrl } from './form-builder.js'
4
+ import { buildItem, fileNameFromUrl, radioGroupName } from './form-builder.js'
5
5
  import { scaffold } from './scaffold.js'
6
6
 
7
7
  const BOUND = new WeakSet()
@@ -172,13 +172,54 @@ export function onScalarChange(target, ctx) {
172
172
  }
173
173
  }
174
174
 
175
- // Crop adapter seam. A pass-through today; Slice 6 fills the body to route the
176
- // file through window.hyperclay.quickcrop before upload when the field opts in
177
- // via data-hcms-crop. Returns { file, dataURL? }, or null to abort (crop
178
- // cancelled). Keeping ALL crop coupling here means no other upload code changes
179
- // when crop lands.
180
- async function maybeCrop(file /*, fieldEl */) {
181
- return { file }
175
+ // Crop adapter seam: ALL crop coupling lives here, between the file pick and
176
+ // the upload. A field opts in via data-hcms-crop on the page element (copied
177
+ // onto the built @image field by the form-builder); absent attr or absent
178
+ // quickcrop (standalone page) uploads raw. Returns { file, dataURL? }, or null
179
+ // to abort (crop cancelled / failed) — the caller resets the picker and skips
180
+ // the commit. quickcrop resolves null on cancel (it does NOT reject), so the
181
+ // cancel branch is the null check, not the catch.
182
+ const CROP_OUTPUT = { type: 'image/webp', quality: 0.85, maxWidth: 2048, maxHeight: 2048 }
183
+
184
+ async function maybeCrop(file, fieldEl) {
185
+ const attr = fieldEl && fieldEl.getAttribute ? fieldEl.getAttribute('data-hcms-crop') : null
186
+ if (attr == null) return { file }
187
+ const crop = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.quickcrop) || null
188
+ if (typeof crop !== 'function') return { file }
189
+ try {
190
+ const r = await crop(file, { aspect: parseCropAspect(attr), ...CROP_OUTPUT })
191
+ if (r === null) return null
192
+ return { file: blobToFile(r.blob, file.name), dataURL: r.dataURL }
193
+ } catch (err) {
194
+ showFieldUploadError(fieldEl, (err && err.message) || 'Crop failed')
195
+ return null
196
+ }
197
+ }
198
+
199
+ // "1:1" → 1, "16:9" → 16/9, "free" / "" / unparseable → null (freeform crop).
200
+ // Exported for tests.
201
+ export function parseCropAspect(value) {
202
+ const v = String(value ?? '').trim().toLowerCase()
203
+ if (v === '' || v === 'free') return null
204
+ const m = v.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/)
205
+ if (!m) return null
206
+ const w = parseFloat(m[1])
207
+ const h = parseFloat(m[2])
208
+ if (!w || !h) return null
209
+ return w / h
210
+ }
211
+
212
+ // quickcrop returns a nameless Blob; uploadFileBasic rejects raw Blobs and the
213
+ // server keys content-type off the filename extension, so wrap it as a File
214
+ // whose extension matches the encoded mime. Exported for tests.
215
+ export function blobToFile(blob, originalName) {
216
+ const ext = blob.type === 'image/webp' ? '.webp' : blob.type === 'image/jpeg' ? '.jpg' : '.png'
217
+ const base = String(originalName || 'image').replace(/\.[^.]+$/, '')
218
+ try {
219
+ return new File([blob], base + ext, { type: blob.type })
220
+ } catch {
221
+ return blob
222
+ }
182
223
  }
183
224
 
184
225
  // A file was picked in an @file/@image widget. Optionally crop, upload via the
@@ -193,6 +234,10 @@ export async function onUploadChange(inputEl, ctx) {
193
234
  if (!fieldEl) return
194
235
  const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
195
236
 
237
+ // A fresh pick is a fresh attempt: clear any stale inline error (e.g. a
238
+ // crop failure from a previous attempt) before running the new flow.
239
+ showFieldUploadError(fieldEl, null)
240
+
196
241
  const cropped = await maybeCrop(file, fieldEl)
197
242
  if (!cropped || ctx.closed) { resetPicker(inputEl); return }
198
243
  const fileLike = cropped.file
@@ -316,6 +361,9 @@ export function onAdd(arrayPath, ctx) {
316
361
  pathArr: [...pathArr, nextIndex],
317
362
  data: itemData,
318
363
  doc: ctx.doc,
364
+ // Component arrays (chips) record their item-template key at build time so
365
+ // added items keep the same chrome.
366
+ itemKey: arrayEl.getAttribute('data-hcms-item-tpl') || null,
319
367
  })
320
368
  slot.appendChild(itemNode)
321
369
  updateArrayButtonsVisibility(arrayEl)
@@ -619,6 +667,16 @@ export function restampAllSiblings(formRoot) {
619
667
  }
620
668
 
621
669
  function restampSiblingPaths(parent) {
670
+ // Radio names are rewritten one item at a time below, so during a move or
671
+ // drag a just-renamed radio can transiently share a name with a
672
+ // not-yet-renamed sibling — and the browser enforces single-selection per
673
+ // group AT THAT INSTANT, permanently unchecking one of them (null extract →
674
+ // the whole-form commit clobbers that field's page value). Snapshot every
675
+ // radio's checked state and re-assert it once all names are final (and
676
+ // unique per field) again.
677
+ const radios = parent.querySelectorAll
678
+ ? Array.from(parent.querySelectorAll('input[type="radio"][data-hcms-field]'), (r) => [r, r.checked])
679
+ : []
622
680
  let i = 0
623
681
  for (const child of parent.children) {
624
682
  if (!child.matches?.('[data-hcms-card], [data-hcms-array-item]')) continue
@@ -630,6 +688,9 @@ function restampSiblingPaths(parent) {
630
688
  if (newPath !== path) restampSubtree(child, path, newPath)
631
689
  i++
632
690
  }
691
+ for (const [r, wasChecked] of radios) {
692
+ if (r.checked !== wasChecked) r.checked = wasChecked
693
+ }
633
694
  }
634
695
 
635
696
  function restampSubtree(root, oldPrefix, newPrefix) {
@@ -643,6 +704,22 @@ function restampSubtree(root, oldPrefix, newPrefix) {
643
704
  el.setAttribute('data-hcms-path', newPrefix + p.slice(oldPrefix.length))
644
705
  }
645
706
  }
707
+ syncRadioGroupNames(root)
708
+ }
709
+
710
+ // Radio group names derive from the field path at build time (populateOptions),
711
+ // so a restamp that moves paths must recompute them. Otherwise a removed-then-
712
+ // re-added sibling gets a fresh name that collides with this item's stale one:
713
+ // the two fields share a browser radio group, checking one unchecks the other,
714
+ // and the whole-form commit clobbers the unchecked field's page value. Only
715
+ // names the builder minted ('hcms-' prefix) are touched — radios in inline or
716
+ // custom templates keep whatever name the author gave them.
717
+ function syncRadioGroupNames(root) {
718
+ for (const input of root.querySelectorAll('input[type="radio"][data-hcms-field]')) {
719
+ if (!input.name || !input.name.startsWith('hcms-')) continue
720
+ const host = input.closest('[data-hcms-path]')
721
+ if (host) input.name = radioGroupName(host.getAttribute('data-hcms-path'))
722
+ }
646
723
  }
647
724
 
648
725
  function stableStringify(value) {
@@ -4,6 +4,12 @@ import {
4
4
  humanize,
5
5
  shapeKindOf,
6
6
  componentForScalarRule,
7
+ componentForScalarArrayRule,
8
+ winningScalarArrayComponent,
9
+ resolveTemplate,
10
+ declaredComponentKey,
11
+ readOptionsOverride,
12
+ readCropOverride,
7
13
  } from './templates.js'
8
14
  import { fieldPropertyFor } from './form-rules.js'
9
15
 
@@ -27,12 +33,12 @@ export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
27
33
  return fragment
28
34
  }
29
35
 
30
- export function buildItem({ shape, itemShape, pathArr, data, doc }) {
36
+ export function buildItem({ shape, itemShape, pathArr, data, doc, itemKey }) {
31
37
  if (shape === 'object-array-item') {
32
38
  return buildObjectArrayItem(itemShape, pathArr, data, doc)
33
39
  }
34
40
  if (shape === 'scalar-array-item') {
35
- return buildScalarArrayItem(pathArr, data, doc)
41
+ return buildScalarArrayItem(pathArr, data, doc, itemKey || null)
36
42
  }
37
43
  throw new Error(`hypercms: buildItem called with unknown shape "${shape}"`)
38
44
  }
@@ -47,14 +53,40 @@ function buildNode(rule, pathArr, data, doc) {
47
53
  }
48
54
 
49
55
  function buildScalar(rule, pathArr, data, doc) {
50
- // Opt-in upload components select a richer template (@image/@file) off the
51
- // bound rule's @prop suffix; plain scalars stay @scalar. A path-bound
52
- // template override still wins inside resolveTemplate.
53
- const componentKey = componentForScalarRule(rule, doc)
56
+ // Opt-in components select a richer template off the bound rule (explicit
57
+ // data-hcms-component, then @prop inference); plain scalars stay @scalar.
58
+ // A path-bound template override still wins inside resolveTemplate.
59
+ const componentKey = componentForScalarRule(rule, doc, pathArr)
54
60
  const tpl = resolveTemplate(pathArr, componentKey, doc)
55
61
  if (!tpl) throw new Error(`hypercms: missing template for scalar at "${pathArr.join('.')}"`)
62
+ const declaredKey = declaredComponentKey(rule, doc)
63
+ // A value-guard fell back to text: say so, or "where did my number input /
64
+ // checkbox go" has no answer.
65
+ if (declaredKey === '@number' && componentKey === '@scalar') {
66
+ console.info(
67
+ `[hypercms] field "${pathArr.join('.')}" declares component "@number" but its value isn't a plain number; rendering a text input so the value is preserved`
68
+ )
69
+ }
70
+ if ((declaredKey === '@checkbox' || declaredKey === '@toggle') && componentKey === '@scalar') {
71
+ console.info(
72
+ `[hypercms] field "${pathArr.join('.')}" declares component "${declaredKey}" but its value isn't true/false; rendering a text input so the value is preserved`
73
+ )
74
+ }
75
+ noteShadowedComponent(tpl, declaredKey === componentKey ? declaredKey : null, pathArr)
56
76
  const node = cloneTemplate(tpl, doc)
57
77
  stampPath(node, pathArr)
78
+ // Component-specific setup runs only when the component's own template won —
79
+ // a custom path/wildcard template that shadows it is the author's UI, and
80
+ // injecting options, dropping radio rows, or stamping crop into it would
81
+ // corrupt that UI.
82
+ const winnerKey = tpl.getAttribute?.('data-hcms-tpl')
83
+ if ((componentKey === '@select' || componentKey === '@radio') && winnerKey === componentKey) {
84
+ populateOptions(node, rule, pathArr, data, doc, componentKey)
85
+ }
86
+ if (componentKey === '@image' && winnerKey === '@image') {
87
+ const crop = readCropOverride(rule, doc)
88
+ if (crop != null && !node.hasAttribute('data-hcms-crop')) node.setAttribute('data-hcms-crop', crop)
89
+ }
58
90
  // Stamp leaf input(s) FIRST so the container's data-hcms-field doesn't
59
91
  // make stampScalarField early-return (which would leave the input without
60
92
  // a key).
@@ -67,6 +99,89 @@ function buildScalar(rule, pathArr, data, doc) {
67
99
  return node
68
100
  }
69
101
 
102
+ // A custom template (exact or wildcard path) outranks a named component, by
103
+ // design: the author's template is the stronger signal. Surface the shadowing
104
+ // so "my data-hcms-component does nothing" has a visible answer — but only for
105
+ // EXPLICITLY declared components (declaredKey non-null): a custom template
106
+ // quietly outranking @src/@checked inference is unremarkable.
107
+ function noteShadowedComponent(tpl, declaredKey, pathArr) {
108
+ if (!declaredKey) return
109
+ const winner = tpl.getAttribute?.('data-hcms-tpl')
110
+ if (winner && winner !== declaredKey) {
111
+ console.info(
112
+ `[hypercms] field "${pathArr.join('.')}" declares component "${declaredKey}" but custom template "${winner}" wins`
113
+ )
114
+ }
115
+ }
116
+
117
+ // Fill a @select's <select> with <option>s, or clone a @radio's prototype row
118
+ // per option, from the page element's data-hcms-options tokens. The current
119
+ // page value is prepended when it isn't in the list, so opening the editor
120
+ // never silently changes data. Radio inputs get a per-field name derived from
121
+ // the path so sibling fields (and array items) never cross-group.
122
+ function populateOptions(node, rule, pathArr, data, doc, componentKey) {
123
+ const provided = readOptionsOverride(rule, doc)
124
+ const values = provided ? [...provided] : []
125
+ const current = data == null ? '' : String(data)
126
+ if (current !== '' && !values.includes(current)) values.unshift(current)
127
+ if (!provided) {
128
+ // Missing data-hcms-options is an authoring error: surface it ALWAYS, not
129
+ // just when the page value is empty too. When a value exists, fall through
130
+ // and render it as the single option so the data still round-trips while
131
+ // the attribute is missing.
132
+ setFieldError(node, 'data-hcms-options required (space-separated values)')
133
+ if (values.length === 0) {
134
+ // No options AND no value: drop the radio prototype so no phantom empty
135
+ // row renders. The null/'' extract this leaves behind applies as ''
136
+ // (writePropOrAttr), matching the already empty page value, so no data
137
+ // moves.
138
+ node.querySelector('.mirk-radio')?.remove()
139
+ return
140
+ }
141
+ }
142
+ if (componentKey === '@select') {
143
+ const select = node.querySelector('select[data-hcms-field]')
144
+ if (!select) return
145
+ for (const v of values) {
146
+ const opt = doc.createElement('option')
147
+ opt.value = v
148
+ opt.textContent = humanize(v)
149
+ select.appendChild(opt)
150
+ }
151
+ return
152
+ }
153
+ const proto = node.querySelector('.mirk-radio')
154
+ if (!proto || !proto.parentNode) return
155
+ const name = radioGroupName(pathArr.join('.'))
156
+ for (const v of values) {
157
+ const row = proto.cloneNode(true)
158
+ const input = row.querySelector('input[type="radio"]')
159
+ if (input) {
160
+ input.value = v
161
+ input.name = name
162
+ }
163
+ const label = row.querySelector('.mirk-radio__label')
164
+ if (label) label.textContent = humanize(v)
165
+ proto.parentNode.insertBefore(row, proto)
166
+ }
167
+ proto.remove()
168
+ }
169
+
170
+ // The radio group name a field at this dot-path gets. Shared with the restamp
171
+ // machinery in events.js: names derive from paths, so when a remove/move
172
+ // restamps paths, the names must be recomputed with the same formula or
173
+ // sibling items end up sharing a group.
174
+ export function radioGroupName(pathStr) {
175
+ return 'hcms-' + String(pathStr).replace(/[^A-Za-z0-9_-]/g, '-')
176
+ }
177
+
178
+ function setFieldError(node, message) {
179
+ const slot = node.querySelector ? node.querySelector('.hcms-error') : null
180
+ if (!slot) return
181
+ slot.textContent = message
182
+ slot.hidden = false
183
+ }
184
+
70
185
  // The @file leaf is an <a> whose value is the href; populateScalarValue sets
71
186
  // href but not the visible text, so derive the filename here. Empty href leaves
72
187
  // the anchor empty, which the theme's :empty placeholder dresses as "No file
@@ -149,27 +264,41 @@ function buildObjectArrayItem(itemShape, pathArr, data, doc) {
149
264
  }
150
265
 
151
266
  function buildScalarArray(rule, pathArr, data, doc) {
152
- const tpl = resolveTemplate(pathArr, '@scalar-array', doc)
267
+ // The chips component swaps the scalar-array chrome (data-hcms-component=
268
+ // "chips" on the list container or an item ancestor). A path-bound template
269
+ // still wins inside resolveTemplate — and when it does, `comp` is null
270
+ // (winningScalarArrayComponent shares that verdict with deriveFormRules, so
271
+ // the item chrome and the derived item selector can never diverge) and the
272
+ // custom template's own UI is left untouched.
273
+ const declared = componentForScalarArrayRule(rule, doc)
274
+ const comp = winningScalarArrayComponent(rule, pathArr, doc)
275
+ const defaultKey = declared ? declared.array : '@scalar-array'
276
+ const tpl = resolveTemplate(pathArr, defaultKey, doc)
153
277
  if (!tpl) throw new Error(`hypercms: missing template for scalar-array at "${pathArr.join('.')}"`)
278
+ // chips is always an explicit declaration (attr-driven), so always notable.
279
+ noteShadowedComponent(tpl, declared ? declared.array : null, pathArr)
154
280
  const node = cloneTemplate(tpl, doc)
155
281
  stampPath(node, pathArr)
156
282
  stampContainerField(node, lastKey(pathArr))
157
283
  setLabel(node, lastKey(pathArr))
158
284
  copyArrayConstraintAttrs(node, tpl)
159
285
  wireSortable(node, tpl, pathArr)
286
+ // Record the resolved item-template key so onAdd (which has no rule context)
287
+ // builds new items with the same component chrome.
288
+ if (comp) node.setAttribute('data-hcms-item-tpl', comp.item)
160
289
 
161
290
  const slot = requireSlot(node, '.hcms-array-items', tpl, pathArr)
162
291
  const items = Array.isArray(data) ? data : []
163
292
  items.forEach((itemValue, i) => {
164
- const itemNode = buildScalarArrayItem([...pathArr, i], itemValue, doc)
293
+ const itemNode = buildScalarArrayItem([...pathArr, i], itemValue, doc, comp ? comp.item : null)
165
294
  if (itemNode) slot.appendChild(itemNode)
166
295
  })
167
296
  applyConstraintVisibility(node)
168
297
  return node
169
298
  }
170
299
 
171
- function buildScalarArrayItem(pathArr, value, doc) {
172
- const tpl = resolveItemTemplate(pathArr, 'scalar-array-item', doc)
300
+ function buildScalarArrayItem(pathArr, value, doc, itemKey) {
301
+ const tpl = resolveItemTemplate(pathArr, 'scalar-array-item', doc, itemKey)
173
302
  if (!tpl) throw new Error(`hypercms: missing item template for "${pathArr.join('.')}"`)
174
303
  const node = cloneTemplate(tpl, doc)
175
304
  node.setAttribute('data-hcms-array-item', '')
@@ -180,21 +309,15 @@ function buildScalarArrayItem(pathArr, value, doc) {
180
309
  return node
181
310
  }
182
311
 
183
- function resolveTemplate(pathArr, defaultKey, doc) {
184
- const pathStr = pathArr.join('.')
312
+ function resolveItemTemplate(pathArr, defaultShape, doc, itemKey) {
185
313
  const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
186
314
  return (
187
- (pathStr && findTemplate(doc, pathStr)) ||
188
- (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
189
- findTemplate(doc, defaultKey)
315
+ findTemplate(doc, wildcardKey) ||
316
+ (itemKey && findTemplate(doc, itemKey)) ||
317
+ findTemplate(doc, '@' + defaultShape)
190
318
  )
191
319
  }
192
320
 
193
- function resolveItemTemplate(pathArr, defaultShape, doc) {
194
- const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
195
- return findTemplate(doc, wildcardKey) || findTemplate(doc, '@' + defaultShape)
196
- }
197
-
198
321
  function cloneTemplate(tpl, doc) {
199
322
  const content = tpl.content || tpl
200
323
  const wrapper = doc.createElement('div')
package/src/form-rules.js CHANGED
@@ -1,4 +1,10 @@
1
- import { shapeKindOf, findTemplate, isInlineTemplate, componentForScalarRule } from './templates.js'
1
+ import {
2
+ shapeKindOf,
3
+ findTemplate,
4
+ isInlineTemplate,
5
+ componentForScalarRule,
6
+ winningScalarArrayComponent,
7
+ } from './templates.js'
2
8
 
3
9
  const TAG_PROP_MAP = {
4
10
  IMG: 'src',
@@ -79,7 +85,7 @@ export function deriveFormRules(pageRules, doc) {
79
85
  function derive(rule, pathArr) {
80
86
  const kind = shapeKindOf(rule)
81
87
  if (kind === 'scalar') return scalarRule(rule, pathArr)
82
- if (kind === 'scalar-array') return scalarArrayRule(pathArr)
88
+ if (kind === 'scalar-array') return scalarArrayRule(rule, pathArr)
83
89
  if (kind === 'object-array') return objectArrayRule(rule, pathArr)
84
90
  if (kind === 'object') {
85
91
  const out = Object.create(null)
@@ -104,18 +110,24 @@ export function deriveFormRules(pageRules, doc) {
104
110
  // leaf tag, so an @image field reads img@src and an @file field reads
105
111
  // a@href, while a plain scalar still respects a global @scalar override
106
112
  // (e.g. a <textarea>) instead of assuming <input>.
107
- const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc), fieldKey)
113
+ const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc, pathArr), fieldKey)
108
114
  if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
109
115
  return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
110
116
  }
111
117
 
112
- function scalarArrayRule(pathArr) {
118
+ function scalarArrayRule(rule, pathArr) {
113
119
  // The engine's "selector[]" form is text-only; for an editable form we need
114
120
  // per-item @value reads, so emit the [selector, shape] form with a scalar
115
121
  // shape that targets the inner field's value property. If the site
116
122
  // overrides @scalar-array-item with a non-<input> leaf (textarea, etc.),
117
- // derive the inner-leaf selector from the override.
118
- const shapeEl = findShapeFieldEl('@scalar-array-item', null)
123
+ // derive the inner-leaf selector from the override. A component array
124
+ // (chips) derives from its own item template first — but only when the
125
+ // chips template actually wins at this path (winningScalarArrayComponent
126
+ // is the same verdict buildScalarArray uses, so the derived item selector
127
+ // always matches the rendered item leaf).
128
+ const comp = winningScalarArrayComponent(rule, pathArr, doc)
129
+ const shapeEl =
130
+ (comp && findShapeFieldEl(comp.item, null)) || findShapeFieldEl('@scalar-array-item', null)
119
131
  const itemSel = shapeEl ? keylessFieldSelectorFor(shapeEl) : 'input[data-hcms-field]@value'
120
132
  return [itemSelectorFor(pathArr, '[data-hcms-array-item]'), itemSel]
121
133
  }
package/src/hypercms.js CHANGED
@@ -2,7 +2,7 @@ import { engine } from 'hyper-html-api'
2
2
  import * as pathUtil from './path.js'
3
3
  import { scaffold } from './scaffold.js'
4
4
  import { morphForm } from './morph.js'
5
- import { injectDefaults, injectUploadComponents } from './templates.js'
5
+ import { injectDefaults, injectComponents } from './templates.js'
6
6
  import { deriveFormRules } from './form-rules.js'
7
7
  import { buildForm } from './form-builder.js'
8
8
  import {
@@ -104,7 +104,7 @@ export function open(opts = {}) {
104
104
  const rulesTagNode = found.tagNode
105
105
 
106
106
  injectDefaults(doc)
107
- injectUploadComponents(doc, pageRules)
107
+ injectComponents(doc, pageRules)
108
108
  warnUnmatchedTemplates(doc, pageRules)
109
109
  const formRules = deriveFormRules(pageRules, doc)
110
110
  const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
@@ -511,7 +511,13 @@ function autoOpenReady() {
511
511
  // deadline is hit (warn, never throw).
512
512
  function whenReady(fn) {
513
513
  if (autoOpenReady()) {
514
- fn()
514
+ // Defer one microtask. On the bundled entry (hypercms-bundle.js), installStyles()
515
+ // runs AFTER this module evaluates, so firing open() synchronously here would
516
+ // mount the shell with cssText still empty (an unstyled sidebar). A microtask
517
+ // runs only after the synchronous bundle evaluation completes, by which point
518
+ // styles are installed. The event/backstop paths below already fire post-eval,
519
+ // so only this fast path needed deferring.
520
+ queueMicrotask(fn)
515
521
  return
516
522
  }
517
523
  const deadline = Date.now() + AUTO_OPEN_DEADLINE_MS
package/src/refresh.js CHANGED
@@ -1,7 +1,7 @@
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
7
 
@@ -18,7 +18,7 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
18
18
  }
19
19
  // Re-derive formRules so schema changes (rule shape, template overrides) flow through.
20
20
  injectDefaults(ctx.doc)
21
- injectUploadComponents(ctx.doc, ctx.pageRules)
21
+ injectComponents(ctx.doc, ctx.pageRules)
22
22
  ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
23
23
 
24
24
  const newData = coerceBooleans(
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