@panphora/hyper-cms 0.4.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.
@@ -0,0 +1,321 @@
1
+ import { engine } from 'hyper-html-api'
2
+ import * as pathUtil from './path.js'
3
+ import { scaffold } from './scaffold.js'
4
+ import { morphForm } from './morph.js'
5
+ import { injectDefaults } from './templates.js'
6
+ import { deriveFormRules } from './form-rules.js'
7
+ import { buildForm } from './form-builder.js'
8
+ import {
9
+ bindEvents,
10
+ commit,
11
+ commitWithUndo,
12
+ suppressUndo,
13
+ onAdd as evOnAdd,
14
+ onRemove as evOnRemove,
15
+ extractFormData,
16
+ stableStringify,
17
+ restampAllSiblings,
18
+ coerceBooleans,
19
+ } from './events.js'
20
+ import { mountShell, setShellStyles, markStylesBundled } from './shell.js'
21
+ import { refreshForm, installObserver } from './refresh.js'
22
+ import { warnUnmatchedTemplates } from './diagnostics.js'
23
+
24
+ export function installStyles(text) {
25
+ setShellStyles(text)
26
+ }
27
+
28
+ export function markBundledStyles(doc) {
29
+ markStylesBundled(doc)
30
+ }
31
+
32
+ const state = {
33
+ isOpen: false,
34
+ ctx: null,
35
+ shell: null,
36
+ opts: null,
37
+ }
38
+
39
+ export function open(opts = {}) {
40
+ if (state.isOpen) {
41
+ console.warn('cms.open() called while already open; ignoring')
42
+ return
43
+ }
44
+ const pageRoot = opts.pageRoot || (typeof document !== 'undefined' ? document.body : null)
45
+ if (!pageRoot) throw new Error('hypercms: no pageRoot available')
46
+ const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
47
+ if (!doc) throw new Error('hypercms: no document available')
48
+
49
+ // Resolve rules via the union: an explicit rules object, a token string, or
50
+ // the default token "cms". findRules is document-scoped, so a head-mounted
51
+ // rules tag is found even though pageRoot defaults to <body>.
52
+ const source = opts.rules !== undefined ? opts.rules : 'cms'
53
+ const found = engine.findRules(doc, source)
54
+ if (!found) {
55
+ const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
56
+ throw new Error(`hypercms: no rules found for ${what}`)
57
+ }
58
+ const pageRules = found.rules
59
+ const rulesTagNode = found.tagNode
60
+
61
+ injectDefaults(doc)
62
+ warnUnmatchedTemplates(doc, pageRules)
63
+ const formRules = deriveFormRules(pageRules, doc)
64
+ const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
65
+
66
+ // Suppress undo around mountShell: it toggles chrome-only classes on
67
+ // document.body (hcms-open, etc.) which would otherwise land as undoable
68
+ // page edits. The shell subtree itself is already filtered via save-ignore.
69
+ const shell = suppressUndo(() => mountShell({
70
+ mountTo: opts.mountTo || doc.body,
71
+ side: opts.side || 'right',
72
+ overlay: !!opts.overlay,
73
+ showSaveButton: !!opts.showSaveButton,
74
+ doc,
75
+ }))
76
+
77
+ const ctx = {
78
+ doc,
79
+ pageRoot,
80
+ pageRules,
81
+ formRules,
82
+ rulesTagNode,
83
+ rulesSource: source,
84
+ formRoot: shell.formRoot,
85
+ shellRoot: shell.root,
86
+ errorEl: shell.errorEl,
87
+ lastFingerprint: null,
88
+ lastData: null,
89
+ observerHandle: null,
90
+ onChange: opts.onChange,
91
+ onError: opts.onError,
92
+ onSave: opts.onSave,
93
+ previouslyFocused: doc.activeElement,
94
+ dispatch(name, detail) {
95
+ const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
96
+ if (!Ctor) return
97
+ const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change' || name === 'hcms:save', detail })
98
+ shell.root.dispatchEvent(ev)
99
+ },
100
+ onCloseRequested() {
101
+ close()
102
+ },
103
+ }
104
+ ctx.updateFingerprint = () => {
105
+ ctx.lastFingerprint = stableStringify(extractFormData(ctx))
106
+ }
107
+
108
+ const fragment = buildForm({ pageRules, formRules, data, doc })
109
+ shell.formRoot.appendChild(fragment)
110
+
111
+ bindEvents(ctx)
112
+ ctx.updateFingerprint()
113
+
114
+ ctx.observerHandle = installObserver({
115
+ onRefresh: () => refreshForm(ctx),
116
+ })
117
+
118
+ // Wire global sortable callback to current ctx (replaced by close()).
119
+ globalCommitTarget.ctx = ctx
120
+ installGlobalSortableCommit(doc)
121
+
122
+ // Move focus into the shell — survives close+restore via previouslyFocused.
123
+ focusFirstIn(shell.root)
124
+
125
+ state.isOpen = true
126
+ state.ctx = ctx
127
+ state.shell = shell
128
+ state.opts = opts
129
+
130
+ ctx.dispatch('hcms:open', { pageRoot })
131
+ }
132
+
133
+ function focusFirstIn(root) {
134
+ const sel = 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
135
+ const target = root.querySelector(sel)
136
+ if (target && typeof target.focus === 'function') target.focus()
137
+ }
138
+
139
+ const globalCommitTarget = { ctx: null }
140
+
141
+ function installGlobalSortableCommit(doc) {
142
+ const win = doc.defaultView || (typeof globalThis !== 'undefined' ? globalThis : null)
143
+ if (!win) return
144
+ const hypercmsCommitFn = function hypercmsCommitGlobal() {
145
+ const ctx = globalCommitTarget.ctx
146
+ if (!ctx) return
147
+ restampAllSiblings(ctx.formRoot)
148
+ // Route through commitWithUndo so a drag-reorder lands as a labeled
149
+ // 'Reorder' commit (not a generic idle 'Edit') and a failed apply's
150
+ // mutate+rollback is never recorded as a no-op commit. No-op pass-through
151
+ // when undo isn't loaded.
152
+ return commitWithUndo('Reorder', () =>
153
+ commit(extractFormData(ctx), { path: '', structural: true }, ctx)
154
+ )
155
+ }
156
+ if (typeof win.hypercmsCommit !== 'function') win.hypercmsCommit = hypercmsCommitFn
157
+ // Also mirror to globalThis so non-window contexts (Node tests, workers)
158
+ // can resolve the bare name from `new Function('hypercmsCommit()')`.
159
+ if (typeof globalThis !== 'undefined' && typeof globalThis.hypercmsCommit !== 'function') {
160
+ globalThis.hypercmsCommit = hypercmsCommitFn
161
+ }
162
+ }
163
+
164
+ export function close() {
165
+ if (!state.isOpen) return
166
+ const { ctx, shell } = state
167
+ const previouslyFocused = ctx.previouslyFocused
168
+ ctx.dispatch('hcms:close', null)
169
+ ctx.observerHandle?.unsubscribe?.()
170
+ ctx.detachEvents?.()
171
+ // destroy() removes the chrome-only body classes; suppress so the inverse
172
+ // class removal doesn't enter the undo stack either.
173
+ suppressUndo(() => shell.destroy())
174
+ state.isOpen = false
175
+ state.ctx = null
176
+ state.shell = null
177
+ state.opts = null
178
+ globalCommitTarget.ctx = null
179
+ if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
180
+ try { previouslyFocused.focus() } catch (_) {}
181
+ }
182
+ }
183
+
184
+ export function refresh() {
185
+ if (!state.isOpen) return
186
+ refreshForm(state.ctx)
187
+ }
188
+
189
+ export function isOpen() {
190
+ return state.isOpen
191
+ }
192
+
193
+ export const api = {
194
+ getData() {
195
+ if (!state.isOpen) return null
196
+ return extractFormData(state.ctx)
197
+ },
198
+ setValue(path, value) {
199
+ if (!state.isOpen) throw new Error('hypercms: cms is not open')
200
+ const ctx = state.ctx
201
+ const pathArr = pathUtil.fromString(path)
202
+ const rule = pathUtil.getRuleAtPath(ctx.pageRules, pathArr)
203
+ if (rule === undefined) throw new Error(`hypercms: no rule at path "${path}"`)
204
+ if (typeof rule !== 'string' || rule.endsWith('[]')) {
205
+ throw new Error(`hypercms: setValue requires a leaf scalar path; "${path}" is not a leaf`)
206
+ }
207
+ const field = findLeafField(ctx.formRoot, path)
208
+ if (!field) throw new Error(`hypercms: no field element at path "${path}"`)
209
+ writeFieldValue(field, value, ctx.formRoot, path)
210
+ commit(extractFormData(ctx), { path, structural: false }, ctx)
211
+ },
212
+ addItem(arrayPath) {
213
+ if (!state.isOpen) throw new Error('hypercms: cms is not open')
214
+ evOnAdd(arrayPath, state.ctx)
215
+ },
216
+ removeItem(itemPath) {
217
+ if (!state.isOpen) throw new Error('hypercms: cms is not open')
218
+ const ctx = state.ctx
219
+ const pathArr = pathUtil.fromString(itemPath)
220
+ // Validate path: must be an array item — last segment is a non-negative
221
+ // integer, parent rule must be an array (object-array or scalar-array).
222
+ const lastSeg = pathArr[pathArr.length - 1]
223
+ if (typeof lastSeg !== 'number') {
224
+ throw new Error(`hypercms: removeItem requires an item path; "${itemPath}" is not an array index`)
225
+ }
226
+ const parentRule = pathUtil.getRuleAtPath(ctx.pageRules, pathArr.slice(0, -1))
227
+ const parentIsArray = Array.isArray(parentRule) || (typeof parentRule === 'string' && parentRule.endsWith('[]'))
228
+ if (!parentIsArray) {
229
+ throw new Error(`hypercms: removeItem requires an item path; parent of "${itemPath}" is not an array`)
230
+ }
231
+ const itemEl = ctx.formRoot.querySelector(`[data-hcms-path="${cssEscape(itemPath)}"]`)
232
+ if (!itemEl) throw new Error(`hypercms: no element at path "${itemPath}"`)
233
+ evOnRemove(itemEl, ctx)
234
+ },
235
+ refresh,
236
+ _commit() {
237
+ if (!state.isOpen) return
238
+ const ctx = state.ctx
239
+ restampAllSiblings(ctx.formRoot)
240
+ return commitWithUndo('Update', () =>
241
+ commit(extractFormData(ctx), { path: '', structural: true }, ctx)
242
+ )
243
+ },
244
+ }
245
+
246
+ function findLeafField(formRoot, path) {
247
+ const esc = cssEscape(path)
248
+ // Prefer a tag-qualified leaf (input/textarea/select/img/a) — since v0.3
249
+ // stamps data-hcms-field on the wrapping container too, a bare attribute
250
+ // match would resolve to the container and lose the value write.
251
+ const leafSel =
252
+ `[data-hcms-path="${esc}"] input[data-hcms-field], ` +
253
+ `[data-hcms-path="${esc}"] textarea[data-hcms-field], ` +
254
+ `[data-hcms-path="${esc}"] select[data-hcms-field], ` +
255
+ `[data-hcms-path="${esc}"] img[data-hcms-field], ` +
256
+ `[data-hcms-path="${esc}"] a[data-hcms-field], ` +
257
+ `[data-hcms-path="${esc}"] [contenteditable][data-hcms-field], ` +
258
+ // Leaf is the path-stamped element itself (inline-stamped fields):
259
+ `input[data-hcms-path="${esc}"][data-hcms-field], ` +
260
+ `textarea[data-hcms-path="${esc}"][data-hcms-field], ` +
261
+ `select[data-hcms-path="${esc}"][data-hcms-field], ` +
262
+ `img[data-hcms-path="${esc}"][data-hcms-field], ` +
263
+ `a[data-hcms-path="${esc}"][data-hcms-field], ` +
264
+ `[contenteditable][data-hcms-path="${esc}"][data-hcms-field]`
265
+ return formRoot.querySelector(leafSel)
266
+ }
267
+
268
+ function writeFieldValue(el, value, formRoot, path) {
269
+ const tag = (el.tagName || '').toUpperCase()
270
+ const type = (el.getAttribute('type') || '').toLowerCase()
271
+ if (tag === 'INPUT' && type === 'checkbox') {
272
+ el.checked = value === true || value === 'true'
273
+ return
274
+ }
275
+ if (tag === 'INPUT' && type === 'radio') {
276
+ // Radios sharing the same path act as a group. Toggle the matching option.
277
+ const esc = cssEscape(path)
278
+ const group = formRoot.querySelectorAll(
279
+ `[data-hcms-path="${esc}"][data-hcms-field][type="radio"], [data-hcms-path="${esc}"] [data-hcms-field][type="radio"]`
280
+ )
281
+ if (group.length) {
282
+ group.forEach((r) => { r.checked = String(r.value) === String(value ?? '') })
283
+ } else {
284
+ el.checked = String(el.value) === String(value ?? '')
285
+ }
286
+ return
287
+ }
288
+ if (tag === 'IMG') {
289
+ el.src = value == null ? '' : String(value)
290
+ return
291
+ }
292
+ if (tag === 'A') {
293
+ el.href = value == null ? '' : String(value)
294
+ return
295
+ }
296
+ if ('value' in el) {
297
+ el.value = value == null ? '' : String(value)
298
+ return
299
+ }
300
+ el.textContent = value == null ? '' : String(value)
301
+ }
302
+
303
+ const cms = {
304
+ open,
305
+ close,
306
+ refresh,
307
+ api,
308
+ get isOpen() { return state.isOpen },
309
+ // Power-user exports (mostly for testing + advanced integration)
310
+ path: pathUtil,
311
+ scaffold,
312
+ morphForm,
313
+ }
314
+
315
+ function cssEscape(value) {
316
+ if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value)
317
+ return String(value).replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
318
+ }
319
+
320
+ export { cms }
321
+ export default { cms }
package/src/morph.js ADDED
@@ -0,0 +1,10 @@
1
+ import HyperMorph from 'hyper-morph'
2
+
3
+ export function morphForm(formRoot, newFragment) {
4
+ HyperMorph.morph(formRoot, newFragment, {
5
+ morphStyle: 'innerHTML',
6
+ ignoreActiveValue: true,
7
+ restoreFocus: true,
8
+ formStateSync: 'property',
9
+ })
10
+ }
package/src/path.js ADDED
@@ -0,0 +1,61 @@
1
+ export function toString(path) {
2
+ return path.map(String).join('.')
3
+ }
4
+
5
+ export function fromString(str) {
6
+ if (str === '') return []
7
+ return str.split('.').map((seg) => (/^\d+$/.test(seg) ? Number(seg) : seg))
8
+ }
9
+
10
+ export function getRuleAtPath(rules, path) {
11
+ let node = rules
12
+ for (const seg of path) {
13
+ if (node == null) return undefined
14
+ if (typeof node === 'string') {
15
+ // Scalar-array rules like 'li[]' contain leaf scalars at integer indices.
16
+ // Descend by stripping the trailing []; the leaf is treated as a plain
17
+ // scalar string for path queries.
18
+ if (node.endsWith('[]') && (typeof seg === 'number' || seg === '*')) {
19
+ node = node.slice(0, -2)
20
+ continue
21
+ }
22
+ return undefined
23
+ }
24
+ if (Array.isArray(node)) {
25
+ if (typeof seg !== 'number' && seg !== '*') return undefined
26
+ node = node[1]
27
+ continue
28
+ }
29
+ if (typeof node === 'object') {
30
+ if (typeof seg === 'number') return undefined
31
+ if (!(seg in node)) return undefined
32
+ node = node[seg]
33
+ continue
34
+ }
35
+ return undefined
36
+ }
37
+ return node
38
+ }
39
+
40
+ export function getValueAtPath(data, path) {
41
+ let node = data
42
+ for (const seg of path) {
43
+ if (node == null) return undefined
44
+ node = node[seg]
45
+ }
46
+ return node
47
+ }
48
+
49
+ export function setAtPath(obj, path, value) {
50
+ if (path.length === 0) return value
51
+ const [k, ...rest] = path
52
+ if (typeof k === 'number') {
53
+ const next = Array.isArray(obj) ? [...obj] : []
54
+ next[k] = setAtPath(next[k], rest, value)
55
+ return next
56
+ }
57
+ return {
58
+ ...(obj && typeof obj === 'object' ? obj : {}),
59
+ [k]: setAtPath((obj || {})[k], rest, value),
60
+ }
61
+ }
package/src/refresh.js ADDED
@@ -0,0 +1,57 @@
1
+ import { engine } from 'hyper-html-api'
2
+ import { buildForm } from './form-builder.js'
3
+ import { morphForm } from './morph.js'
4
+ import { injectDefaults } from './templates.js'
5
+ import { deriveFormRules } from './form-rules.js'
6
+ import { coerceBooleans, applyErrorState } from './events.js'
7
+
8
+ const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
9
+
10
+ export function refreshForm(ctx) {
11
+ // Re-resolve rules every refresh, source-aware: an object source returns the
12
+ // same literal (tagNode null); a token source re-resolves the tag so a
13
+ // livesync-replaced rules tag is picked up. Document-scoped via ctx.doc.
14
+ const found = engine.findRules(ctx.doc, ctx.rulesSource || 'cms')
15
+ if (found) {
16
+ ctx.pageRules = found.rules
17
+ ctx.rulesTagNode = found.tagNode
18
+ }
19
+ // Re-derive formRules so schema changes (rule shape, template overrides) flow through.
20
+ injectDefaults(ctx.doc)
21
+ ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
22
+
23
+ const newData = coerceBooleans(
24
+ engine.extract(ctx.pageRoot, ctx.pageRules, ENGINE_OPTS),
25
+ ctx.pageRules
26
+ )
27
+ const fragment = buildForm({
28
+ pageRules: ctx.pageRules,
29
+ formRules: ctx.formRules,
30
+ data: newData,
31
+ doc: ctx.doc,
32
+ })
33
+ morphForm(ctx.formRoot, fragment)
34
+ // morphForm rebuilds the form structure; inline error slots get wiped. Re-apply
35
+ // the last error state so error messages survive across observer-driven refreshes
36
+ // (e.g., the rollback after a failed add).
37
+ applyErrorState(ctx)
38
+ // Update fingerprint so subsequent commits against the new page state compare correctly.
39
+ if (ctx.updateFingerprint) ctx.updateFingerprint()
40
+ }
41
+
42
+ export function installObserver({ debounce = 100, onRefresh }) {
43
+ const M = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.Mutation) || null
44
+ if (!M || typeof M.onAnyChange !== 'function') {
45
+ throw new Error('hypercms: window.hyperclay.Mutation is required. Load hyperclayjs (or just the mutation utility) before initializing hypercms.')
46
+ }
47
+ let paused = 0
48
+ const unsub = M.onAnyChange({ debounce }, () => {
49
+ if (paused > 0) return
50
+ onRefresh()
51
+ })
52
+ return {
53
+ unsubscribe: typeof unsub === 'function' ? unsub : () => {},
54
+ pause() { paused++ },
55
+ resume() { paused = Math.max(0, paused - 1) },
56
+ }
57
+ }
@@ -0,0 +1,10 @@
1
+ export function scaffold(shape) {
2
+ if (typeof shape === 'string') return shape.endsWith('[]') ? [] : ''
3
+ if (Array.isArray(shape)) return []
4
+ if (typeof shape === 'object' && shape !== null) {
5
+ const out = {}
6
+ for (const [k, v] of Object.entries(shape)) out[k] = scaffold(v)
7
+ return out
8
+ }
9
+ return ''
10
+ }
package/src/shell.js ADDED
@@ -0,0 +1,132 @@
1
+ const SHELL_STYLE_ID = 'hcms-shell-styles'
2
+ const SHELL_BUNDLED_FLAG = 'hcms-bundled-styles-installed'
3
+ const FOCUSABLE = 'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
4
+
5
+ const styledDocs = new WeakSet()
6
+ let cssText = ''
7
+
8
+ export function setShellStyles(text) {
9
+ cssText = text
10
+ }
11
+
12
+ export function markStylesBundled(doc) {
13
+ // Bundler emits a <style data-hcms-bundled-styles> tag and sets this so
14
+ // the link-based path doesn't double-install.
15
+ if (!doc) return
16
+ styledDocs.add(doc)
17
+ doc[SHELL_BUNDLED_FLAG] = true
18
+ }
19
+
20
+ let titleIdCounter = 0
21
+
22
+ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveButton = false, doc }) {
23
+ ensureStyles(doc)
24
+ const titleId = `hcms-shell-title-${++titleIdCounter}`
25
+ const root = doc.createElement('div')
26
+ root.setAttribute('data-hcms-shell', '')
27
+ root.setAttribute('save-remove', '')
28
+ root.setAttribute('save-ignore', '')
29
+ root.setAttribute('tabindex', '-1')
30
+ // Dialog semantics: focus is trapped + body scrolling locked, so screen
31
+ // readers should announce this as a modal dialog with the shell title as
32
+ // its accessible name.
33
+ root.setAttribute('role', 'dialog')
34
+ root.setAttribute('aria-modal', 'true')
35
+ root.setAttribute('aria-labelledby', titleId)
36
+ root.className = 'hcms-shell hcms-side-' + side + (overlay ? ' hcms-overlay' : '')
37
+ root.innerHTML = `
38
+ <header class="hcms-shell-header">
39
+ <h2 class="hcms-shell-title" id="${titleId}">Edit</h2>
40
+ <button type="button" class="hcms-shell-close" data-hcms-action="close" aria-label="Close">×</button>
41
+ </header>
42
+ <div class="hcms-shell-error" role="alert" hidden></div>
43
+ <div data-hcms-form-root class="hcms-form"></div>
44
+ <footer class="hcms-shell-footer"${showSaveButton ? '' : ' hidden'}>
45
+ <button type="button" class="hcms-shell-save" data-hcms-action="save">Save</button>
46
+ </footer>
47
+ `
48
+
49
+ // mountTo may be a nested element inside pageRoot. Use it as-is for the
50
+ // visual hierarchy; engine reads/writes pass { skip: '[data-hcms-shell]' }
51
+ // so the engine never traverses into the form regardless of where it's mounted.
52
+ const host = mountTo || doc.body
53
+ host.appendChild(root)
54
+
55
+ const body = doc.body
56
+ body.classList.add('hcms-open')
57
+ if (overlay) body.classList.add('hcms-overlay')
58
+ if (side === 'left') body.classList.add('hcms-side-left')
59
+
60
+ const focusTrap = installFocusTrap(root, doc)
61
+
62
+ return {
63
+ root,
64
+ formRoot: root.querySelector('[data-hcms-form-root]'),
65
+ errorEl: root.querySelector('.hcms-shell-error'),
66
+ saveButton: root.querySelector('.hcms-shell-save'),
67
+ destroy() {
68
+ focusTrap.detach()
69
+ root.remove()
70
+ body.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
71
+ },
72
+ }
73
+ }
74
+
75
+ function ensureStyles(doc) {
76
+ if (!doc) return
77
+ if (styledDocs.has(doc)) return
78
+ if (doc[SHELL_BUNDLED_FLAG]) {
79
+ styledDocs.add(doc)
80
+ return
81
+ }
82
+ if (doc.getElementById(SHELL_STYLE_ID) || doc.querySelector('style[data-hcms-bundled-styles]')) {
83
+ styledDocs.add(doc)
84
+ return
85
+ }
86
+ if (cssText) {
87
+ const style = doc.createElement('style')
88
+ style.id = SHELL_STYLE_ID
89
+ style.setAttribute('save-remove', '')
90
+ style.textContent = cssText
91
+ ;(doc.head || doc.documentElement).appendChild(style)
92
+ styledDocs.add(doc)
93
+ return
94
+ }
95
+ // No bundled CSS — try resolving the sibling styles.css via import.meta.url.
96
+ // Browsers ESM-resolved imports place a co-located styles.css next to
97
+ // hypercms.js. Falls back silently if resolution fails (e.g., bundlers
98
+ // without an asset emitter).
99
+ try {
100
+ const href = new URL('./styles.css', import.meta.url).href
101
+ const link = doc.createElement('link')
102
+ link.rel = 'stylesheet'
103
+ link.id = SHELL_STYLE_ID
104
+ link.setAttribute('save-remove', '')
105
+ link.href = href
106
+ ;(doc.head || doc.documentElement).appendChild(link)
107
+ styledDocs.add(doc)
108
+ } catch (_) {
109
+ // SSR or environments without import.meta.url — author can call
110
+ // installStyles(text) manually.
111
+ }
112
+ }
113
+
114
+ function installFocusTrap(root, doc) {
115
+ function onKeyDown(e) {
116
+ if (e.key !== 'Tab') return
117
+ if (!root.contains(doc.activeElement)) return
118
+ const focusables = Array.from(root.querySelectorAll(FOCUSABLE))
119
+ if (focusables.length === 0) return
120
+ const first = focusables[0]
121
+ const last = focusables[focusables.length - 1]
122
+ if (e.shiftKey && doc.activeElement === first) {
123
+ e.preventDefault()
124
+ last.focus()
125
+ } else if (!e.shiftKey && doc.activeElement === last) {
126
+ e.preventDefault()
127
+ first.focus()
128
+ }
129
+ }
130
+ doc.addEventListener('keydown', onKeyDown)
131
+ return { detach: () => doc.removeEventListener('keydown', onKeyDown) }
132
+ }