@panphora/hyper-cms 0.4.1 → 0.5.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/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 } from './templates.js'
5
+ import { injectDefaults, injectUploadComponents } from './templates.js'
6
6
  import { deriveFormRules } from './form-rules.js'
7
7
  import { buildForm } from './form-builder.js'
8
8
  import {
@@ -36,11 +36,56 @@ const state = {
36
36
  opts: null,
37
37
  }
38
38
 
39
+ // Re-sync the form to the page after a change hypercms did NOT originate.
40
+ // `reason` selects whether the field the user is currently typing in snaps:
41
+ // 'undo' / 'redo' — the user's own deliberate action, so the focused field
42
+ // SHOULD snap to the reverted value (ignoreActiveValue:false).
43
+ // 'livesync' — someone else's / the framework's change, so the user's
44
+ // in-progress focused field must NOT be yanked away
45
+ // (ignoreActiveValue:true). morphForm re-extracts every
46
+ // other field to the new page value, killing the stale-form
47
+ // clobber, while leaving the active field alone until blur.
48
+ // Both reasons route through this one handler so there is a single place to
49
+ // maintain the form-resync behavior.
50
+ function resyncForm(ctx, reason) {
51
+ // open() may have moved on (closed, or reopened with a new ctx) between an
52
+ // async morph completing and this firing.
53
+ if (state.ctx !== ctx) return
54
+ const ignoreActiveValue = reason === 'livesync'
55
+ if (reason === 'livesync') {
56
+ // A full-document morph can wipe the shell's <head> stylesheet and the
57
+ // <body> chrome classes (both live outside the save-ignore shell subtree).
58
+ // Re-assert them before re-extracting.
59
+ state.shell?.restoreChrome?.()
60
+ }
61
+ refreshForm(ctx, { ignoreActiveValue })
62
+ }
63
+
64
+ // Strip hypercms's own body chrome from the SAVE clone so it never reaches disk
65
+ // (the shell element itself is [save-remove], but the hcms-open class lives on
66
+ // <body>, outside the stripped subtree). Registered once at first open():
67
+ // onPrepareForSave runs save-only, on a clone, so it never touches the live DOM,
68
+ // and removing absent classes is a no-op when the shell is closed. Guarded so it
69
+ // degrades cleanly when the host save pipeline (hyperclayjs) isn't present.
70
+ let prepareHookInstalled = false
71
+ function installSavePrepareHook() {
72
+ if (prepareHookInstalled) return
73
+ const onPrepareForSave =
74
+ (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.onPrepareForSave) || null
75
+ if (typeof onPrepareForSave !== 'function') return
76
+ onPrepareForSave((clonedDocEl) => {
77
+ const b = clonedDocEl && clonedDocEl.querySelector && clonedDocEl.querySelector('body')
78
+ if (b) b.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
79
+ })
80
+ prepareHookInstalled = true
81
+ }
82
+
39
83
  export function open(opts = {}) {
40
84
  if (state.isOpen) {
41
85
  console.warn('cms.open() called while already open; ignoring')
42
86
  return
43
87
  }
88
+ installSavePrepareHook()
44
89
  const pageRoot = opts.pageRoot || (typeof document !== 'undefined' ? document.body : null)
45
90
  if (!pageRoot) throw new Error('hypercms: no pageRoot available')
46
91
  const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
@@ -59,6 +104,7 @@ export function open(opts = {}) {
59
104
  const rulesTagNode = found.tagNode
60
105
 
61
106
  injectDefaults(doc)
107
+ injectUploadComponents(doc, pageRules)
62
108
  warnUnmatchedTemplates(doc, pageRules)
63
109
  const formRules = deriveFormRules(pageRules, doc)
64
110
  const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
@@ -71,6 +117,9 @@ export function open(opts = {}) {
71
117
  side: opts.side || 'right',
72
118
  overlay: !!opts.overlay,
73
119
  showSaveButton: !!opts.showSaveButton,
120
+ title: opts.title,
121
+ eyebrow: opts.eyebrow,
122
+ theme: opts.theme,
74
123
  doc,
75
124
  }))
76
125
 
@@ -88,14 +137,15 @@ export function open(opts = {}) {
88
137
  lastData: null,
89
138
  observerHandle: null,
90
139
  undoUnsub: null,
140
+ livesyncUnsub: null,
91
141
  onChange: opts.onChange,
92
142
  onError: opts.onError,
93
- onSave: opts.onSave,
143
+ confirmRemove: opts.confirmRemove,
94
144
  previouslyFocused: doc.activeElement,
95
145
  dispatch(name, detail) {
96
146
  const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
97
147
  if (!Ctor) return
98
- const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change' || name === 'hcms:save', detail })
148
+ const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail })
99
149
  shell.root.dispatchEvent(ev)
100
150
  },
101
151
  onCloseRequested() {
@@ -106,6 +156,13 @@ export function open(opts = {}) {
106
156
  ctx.lastFingerprint = stableStringify(extractFormData(ctx))
107
157
  }
108
158
 
159
+ // Everything past the shell mount touches the live DOM, the module-level state,
160
+ // and external subscriptions (observer, undo, livesync). A throw in here — most
161
+ // notably installObserver when window.hyperclay.Mutation isn't loaded — would
162
+ // otherwise strand the mounted shell in the DOM with no way to close it (close()
163
+ // early-returns while state.isOpen is false). Tear down whatever was wired,
164
+ // exactly as close() does, then rethrow so host misuse still fails loudly.
165
+ try {
109
166
  const fragment = buildForm({ pageRules, formRules, data, doc })
110
167
  shell.formRoot.appendChild(fragment)
111
168
 
@@ -123,12 +180,42 @@ export function open(opts = {}) {
123
180
  // the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
124
181
  const u = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
125
182
  if (u && typeof u.on === 'function') {
126
- const onRevert = () => refreshForm(ctx, { ignoreActiveValue: false })
183
+ const onRevert = () => {
184
+ if (state.ctx !== ctx) return
185
+ resyncForm(ctx, 'undo')
186
+ // A reverted change the consumer persists through onChange (e.g. the
187
+ // collection dashboard PUTs the record, not the page) would otherwise lag
188
+ // the undo: refreshForm re-syncs the form but never fires onChange, and a
189
+ // commit() here would be fingerprint-skipped (refreshForm already updated
190
+ // it). Extract the reverted state from the PAGE (the source of truth after
191
+ // an undo/redo; the form can lag a morph) and fire onChange directly, but
192
+ // only when it actually changed — guards spurious PUTs on undo/redo of
193
+ // unrelated edits elsewhere on the page.
194
+ const data = coerceBooleans(
195
+ engine.extract(ctx.pageRoot, ctx.pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }),
196
+ ctx.pageRules
197
+ )
198
+ if (stableStringify(data) !== stableStringify(ctx.lastData)) {
199
+ ctx.lastData = data
200
+ ctx.onChange?.(data, { path: '', structural: false })
201
+ }
202
+ }
127
203
  u.on('undo', onRevert)
128
204
  u.on('redo', onRevert)
129
205
  ctx.undoUnsub = () => { u.off('undo', onRevert); u.off('redo', onRevert) }
130
206
  }
131
207
 
208
+ // Live-sync / version-restore / any framework-applied full-document morph runs
209
+ // inside window.hyperclay.Mutation.pause(), which the source-blind observer
210
+ // above is deaf to — so without this the form stays stale after a remote edit
211
+ // and the next local commit re-applies the stale data, clobbering the remote
212
+ // change. Subscribe to the morph-applied signal live-sync emits and re-extract
213
+ // with ignoreActiveValue:true (preserve the user's in-progress field). Same
214
+ // document live-sync dispatches on; unsubscribed in close().
215
+ const onLivesync = () => resyncForm(ctx, 'livesync')
216
+ doc.addEventListener('hyperclay:livesync-applied', onLivesync)
217
+ ctx.livesyncUnsub = () => doc.removeEventListener('hyperclay:livesync-applied', onLivesync)
218
+
132
219
  // Wire global sortable callback to current ctx (replaced by close()).
133
220
  globalCommitTarget.ctx = ctx
134
221
  installGlobalSortableCommit(doc)
@@ -142,6 +229,19 @@ export function open(opts = {}) {
142
229
  state.opts = opts
143
230
 
144
231
  ctx.dispatch('hcms:open', { pageRoot })
232
+ } catch (err) {
233
+ ctx.observerHandle?.unsubscribe?.()
234
+ ctx.undoUnsub?.()
235
+ ctx.livesyncUnsub?.()
236
+ ctx.detachEvents?.()
237
+ if (globalCommitTarget.ctx === ctx) globalCommitTarget.ctx = null
238
+ suppressUndo(() => shell.destroy())
239
+ state.isOpen = false
240
+ state.ctx = null
241
+ state.shell = null
242
+ state.opts = null
243
+ throw err
244
+ }
145
245
  }
146
246
 
147
247
  function focusFirstIn(root) {
@@ -175,13 +275,60 @@ function installGlobalSortableCommit(doc) {
175
275
  }
176
276
  }
177
277
 
278
+ // The query param that drives ?cms=true auto-open. Closing the CMS rewrites it
279
+ // to cms=false (param kept) so a refresh/share of the post-close URL does not
280
+ // auto-reopen.
281
+ const AUTO_OPEN_PARAM = 'cms'
282
+
283
+ // Pure: given a location.search string, return the search string after a close.
284
+ // Only rewrites when cms=true is present; every other param + ordering is
285
+ // preserved, and a search without cms (or already cms=false) is returned
286
+ // unchanged so we never inject the param into a page that never carried it.
287
+ export function nextSearchAfterClose(search) {
288
+ const str = typeof search === 'string' ? search : ''
289
+ const qIndex = str.indexOf('?')
290
+ const query = qIndex === -1 ? str : str.slice(qIndex + 1)
291
+ if (!query) return str
292
+ const params = new URLSearchParams(query)
293
+ if (params.get(AUTO_OPEN_PARAM) !== 'true') return str
294
+ params.set(AUTO_OPEN_PARAM, 'false')
295
+ return '?' + params.toString()
296
+ }
297
+
298
+ // Pure: should the CMS auto-open for this location.search? Exactly 'true'.
299
+ export function shouldAutoOpenFromSearch(search) {
300
+ const str = typeof search === 'string' ? search : ''
301
+ const qIndex = str.indexOf('?')
302
+ const query = qIndex === -1 ? str : str.slice(qIndex + 1)
303
+ if (!query) return false
304
+ return new URLSearchParams(query).get(AUTO_OPEN_PARAM) === 'true'
305
+ }
306
+
307
+ // Rewrite ?cms=true → ?cms=false via history.replaceState (no reload, hash and
308
+ // every other param preserved). No-op when the param isn't present or the
309
+ // History API is unavailable.
310
+ function toggleCloseParam() {
311
+ if (typeof window === 'undefined' || !window.location || !window.history) return
312
+ if (typeof window.history.replaceState !== 'function') return
313
+ const current = window.location.search
314
+ const next = nextSearchAfterClose(current)
315
+ if (next === current) return
316
+ window.history.replaceState(window.history.state, '', next + window.location.hash)
317
+ }
318
+
178
319
  export function close() {
179
320
  if (!state.isOpen) return
180
321
  const { ctx, shell } = state
322
+ // Mark this ctx dead so any in-flight async work (e.g. an upload awaiting the
323
+ // host uploader) bails instead of mutating the page / firing onChange after
324
+ // teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
325
+ ctx.closed = true
181
326
  const previouslyFocused = ctx.previouslyFocused
182
327
  ctx.dispatch('hcms:close', null)
328
+ toggleCloseParam()
183
329
  ctx.observerHandle?.unsubscribe?.()
184
330
  ctx.undoUnsub?.()
331
+ ctx.livesyncUnsub?.()
185
332
  ctx.detachEvents?.()
186
333
  // destroy() removes the chrome-only body classes; suppress so the inverse
187
334
  // class removal doesn't enter the undo stack either.
@@ -315,6 +462,104 @@ function writeFieldValue(el, value, formRoot, path) {
315
462
  el.textContent = value == null ? '' : String(value)
316
463
  }
317
464
 
465
+ // ?cms=true auto-open. When the page URL carries cms=true at load, open the CMS
466
+ // for everyone (no gating — this is a feature showcase; a non-admin's edits stay
467
+ // DOM-local and the host save path shows its view-mode notice). open() is
468
+ // idempotent-safe (it warns + returns if already open), so a host that also calls
469
+ // open() never double-mounts. No special opts — host defaults apply.
470
+ //
471
+ // Patience matters here: this runs at module load from the dist IIFE, which a
472
+ // host typically loads BEFORE installing window.hyperclay.Mutation (the Mutation
473
+ // install often sits behind an `await import(...)` that resumes after this script,
474
+ // even after DOMContentLoaded). open() needs <body> AND Mutation, so we poll for
475
+ // both rather than firing eagerly. Firing without Mutation would throw out of
476
+ // installObserver, escape module evaluation, and abort esbuild's
477
+ // window.hypercms assignment — taking the host's own wiring down with it.
478
+ const SAFETY_POLL_MS = 250
479
+ const AUTO_OPEN_DEADLINE_MS = 10000
480
+ export function maybeAutoOpen() {
481
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
482
+ if (!shouldAutoOpenFromSearch(window.location ? window.location.search : '')) return
483
+ if (state.isOpen) return
484
+ whenReady(() => {
485
+ if (state.isOpen) return
486
+ try {
487
+ open()
488
+ } catch (err) {
489
+ console.warn('hypercms: auto-open failed', err)
490
+ }
491
+ })
492
+ }
493
+
494
+ // Auto-open is ready only once <body> is parsed AND window.hyperclay.Mutation is
495
+ // installed (open() needs both). NEVER throw out of module scope.
496
+ function autoOpenReady() {
497
+ return !!document.body && !!(window.hyperclay && window.hyperclay.Mutation)
498
+ }
499
+
500
+ // Three layers, in order of preference:
501
+ // 1. Sync fast-path: if <body> and Mutation are already here, fire now.
502
+ // 2. Event: hyperclayjs's mutation.js dispatches 'hyperclay:mutation-ready' the
503
+ // instant it installs window.hyperclay.Mutation. On loader pages this is
504
+ // moot — the loader's first wave evaluates mutation before hypercms, so the
505
+ // fast-path wins — but the event catches any late install (the deferred
506
+ // `await import()` ordering). The body gate is re-checked in the handler
507
+ // because the event can land before <body> is parsed.
508
+ // 3. Backstop: a slow self-cancelling 250ms poll for exotic hosts that hand-roll
509
+ // window.hyperclay.Mutation by direct assignment WITHOUT dispatching the
510
+ // event. It cancels the moment the event fires, open succeeds, or the
511
+ // deadline is hit (warn, never throw).
512
+ function whenReady(fn) {
513
+ if (autoOpenReady()) {
514
+ fn()
515
+ return
516
+ }
517
+ const deadline = Date.now() + AUTO_OPEN_DEADLINE_MS
518
+ let done = false
519
+ let timer = null
520
+ const finish = () => {
521
+ if (done) return
522
+ done = true
523
+ if (timer !== null) clearInterval(timer)
524
+ document.removeEventListener('hyperclay:mutation-ready', onMutationReady)
525
+ }
526
+ function onMutationReady() {
527
+ if (state.isOpen) {
528
+ finish()
529
+ return
530
+ }
531
+ if (autoOpenReady()) {
532
+ finish()
533
+ fn()
534
+ }
535
+ // Body may still be pending — the one-shot listener is already consumed,
536
+ // so only the backstop remains to catch it once body arrives.
537
+ }
538
+ document.addEventListener('hyperclay:mutation-ready', onMutationReady, { once: true })
539
+ timer = setInterval(() => {
540
+ if (state.isOpen) {
541
+ finish()
542
+ return
543
+ }
544
+ if (autoOpenReady()) {
545
+ finish()
546
+ fn()
547
+ return
548
+ }
549
+ if (Date.now() >= deadline) {
550
+ finish()
551
+ console.warn(
552
+ 'hypercms: ?cms=true auto-open gave up — window.hyperclay.Mutation never appeared. ' +
553
+ 'Load hyperclayjs (or the mutation utility) so the CMS can initialize.'
554
+ )
555
+ }
556
+ }, SAFETY_POLL_MS)
557
+ }
558
+
559
+ // Auto-open at module load (browser only — node tests import this without a real
560
+ // window, and the guards above no-op there).
561
+ maybeAutoOpen()
562
+
318
563
  const cms = {
319
564
  open,
320
565
  close,
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 } from './templates.js'
4
+ import { injectDefaults, injectUploadComponents } from './templates.js'
5
5
  import { deriveFormRules } from './form-rules.js'
6
6
  import { coerceBooleans, applyErrorState } from './events.js'
7
7
 
@@ -18,6 +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
22
  ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
22
23
 
23
24
  const newData = coerceBooleans(
package/src/shell.js CHANGED
@@ -19,7 +19,22 @@ export function markStylesBundled(doc) {
19
19
 
20
20
  let titleIdCounter = 0
21
21
 
22
- export function mountShell({ mountTo, side = 'right', overlay = false, showSaveButton = false, doc }) {
22
+ function escapeHtml(s) {
23
+ return String(s).replace(/[&<>"]/g, (c) => (
24
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
25
+ ))
26
+ }
27
+
28
+ export function mountShell({
29
+ mountTo,
30
+ side = 'right',
31
+ overlay = false,
32
+ showSaveButton = false,
33
+ title = 'Page content',
34
+ eyebrow = 'Edit',
35
+ theme = null,
36
+ doc,
37
+ }) {
23
38
  ensureStyles(doc)
24
39
  const titleId = `hcms-shell-title-${++titleIdCounter}`
25
40
  const root = doc.createElement('div')
@@ -33,17 +48,45 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
33
48
  root.setAttribute('role', 'dialog')
34
49
  root.setAttribute('aria-modal', 'true')
35
50
  root.setAttribute('aria-labelledby', titleId)
36
- root.className = 'hcms-shell hcms-side-' + side + (overlay ? ' hcms-overlay' : '')
51
+ // pixel-quiet is the baked-in look; an optional theme pins light/dark
52
+ // (otherwise the panel follows the OS via prefers-color-scheme).
53
+ const themeClass = theme === 'dark' ? ' dark' : theme === 'light' ? ' light' : ''
54
+ root.className =
55
+ 'hcms-shell pixel-quiet hcms-side-' + side + (overlay ? ' hcms-overlay' : '') + themeClass
56
+ const titleHtml = escapeHtml(title)
57
+ const eyebrowHtml = escapeHtml(eyebrow)
58
+ // Pixel Quiet shell: a scroll-away header + in-flow Save inside .hcms-shell-body,
59
+ // with a condensed minibar pinned to the panel top once the header scrolls out.
60
+ // Close + Save are real mirk buttons. The .hcms-* hooks stay so the engine and
61
+ // events bind exactly as before; only the chrome shape changed.
62
+ // The Save button has no wiring here: [trigger-save] is the hyperclayjs save
63
+ // attribute — its save system (or its view-mode notice) handles the click.
64
+ // Standalone hosts delegate their own [trigger-save] click listener.
37
65
  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>
66
+ <div class="hcms-shell-minibar" aria-hidden="true">
67
+ <span class="hcms-shell-minibar-title">${titleHtml}</span>
68
+ <button type="button" class="hcms-shell-close mirk-button mirk-button--small" data-hcms-action="close" aria-label="Close">
69
+ <span class="mirk-button__label">×</span>
70
+ </button>
71
+ </div>
72
+ <div class="hcms-shell-body">
73
+ <header class="hcms-shell-header">
74
+ <div class="hcms-shell-heading">
75
+ <div class="hcms-shell-eyebrow">${eyebrowHtml}</div>
76
+ <h2 class="hcms-shell-title" id="${titleId}">${titleHtml}</h2>
77
+ </div>
78
+ <button type="button" class="hcms-shell-close mirk-button mirk-button--small" data-hcms-action="close" aria-label="Close">
79
+ <span class="mirk-button__label">×</span>
80
+ </button>
81
+ </header>
82
+ <div class="hcms-shell-error" role="alert" hidden></div>
83
+ <div data-hcms-form-root class="hcms-form"></div>
84
+ <footer class="hcms-shell-footer"${showSaveButton ? '' : ' hidden'}>
85
+ <button type="button" class="hcms-shell-save mirk-button" trigger-save>
86
+ <span class="mirk-button__label">Save</span>
87
+ </button>
88
+ </footer>
89
+ </div>
47
90
  `
48
91
 
49
92
  // mountTo may be a nested element inside pageRoot. Use it as-is for the
@@ -58,6 +101,7 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
58
101
  if (side === 'left') body.classList.add('hcms-side-left')
59
102
 
60
103
  const focusTrap = installFocusTrap(root, doc)
104
+ const condense = installCondenseOnScroll(root)
61
105
 
62
106
  return {
63
107
  root,
@@ -66,12 +110,36 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
66
110
  saveButton: root.querySelector('.hcms-shell-save'),
67
111
  destroy() {
68
112
  focusTrap.detach()
113
+ condense.detach()
69
114
  root.remove()
70
115
  body.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
71
116
  },
117
+ // Re-assert the shell's out-of-subtree chrome after a full-document morph
118
+ // (e.g. a live-sync apply) wipes it: the stylesheet lives in <head> and the
119
+ // body classes live on <body>, both outside the save-ignore shell element,
120
+ // so hyper-morph's head-merge / class reconciliation can strip them.
121
+ restoreChrome() {
122
+ reensureStyles(doc)
123
+ body.classList.add('hcms-open')
124
+ if (overlay) body.classList.add('hcms-overlay')
125
+ if (side === 'left') body.classList.add('hcms-side-left')
126
+ },
72
127
  }
73
128
  }
74
129
 
130
+ // Re-inject the shell stylesheet if a morph (or anything else) removed it.
131
+ // Unlike ensureStyles, this does NOT trust the styledDocs WeakSet: a morph can
132
+ // remove the <style> tag while the WeakSet still marks the doc as "styled", so
133
+ // a naive ensureStyles() call would no-op. Re-check the live DOM, clear the
134
+ // stale mark, and let ensureStyles rebuild from cssText.
135
+ export function reensureStyles(doc) {
136
+ if (!doc) return
137
+ if (doc.getElementById(SHELL_STYLE_ID) ||
138
+ doc.querySelector('style[data-hcms-bundled-styles]')) return
139
+ styledDocs.delete(doc)
140
+ ensureStyles(doc)
141
+ }
142
+
75
143
  function ensureStyles(doc) {
76
144
  if (!doc) return
77
145
  if (styledDocs.has(doc)) return
@@ -92,12 +160,12 @@ function ensureStyles(doc) {
92
160
  styledDocs.add(doc)
93
161
  return
94
162
  }
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).
163
+ // No bundled CSS — try resolving the sibling generated theme via
164
+ // import.meta.url. Browsers ESM-resolved imports place a co-located
165
+ // theme.generated.css next to hypercms.js. Falls back silently if resolution
166
+ // fails (e.g., bundlers without an asset emitter).
99
167
  try {
100
- const href = new URL('./styles.css', import.meta.url).href
168
+ const href = new URL('./theme.generated.css', import.meta.url).href
101
169
  const link = doc.createElement('link')
102
170
  link.rel = 'stylesheet'
103
171
  link.id = SHELL_STYLE_ID
@@ -111,6 +179,25 @@ function ensureStyles(doc) {
111
179
  }
112
180
  }
113
181
 
182
+ // Reveal the condensed minibar once the full header scrolls out of the body
183
+ // (pixel-quiet decision 0003). Passive listener; toggles .is-condensed on the
184
+ // shell root when scrollTop passes the header height. No-op if the body or
185
+ // header is missing (e.g. a custom mount that omits them).
186
+ function installCondenseOnScroll(root) {
187
+ const bodyEl = root.querySelector('.hcms-shell-body')
188
+ const header = root.querySelector('.hcms-shell-header')
189
+ if (!bodyEl || !header || typeof bodyEl.addEventListener !== 'function') {
190
+ return { detach() {} }
191
+ }
192
+ const reveal = () => {
193
+ const trigger = (header.offsetHeight || 0) - 12
194
+ root.classList.toggle('is-condensed', bodyEl.scrollTop > trigger)
195
+ }
196
+ bodyEl.addEventListener('scroll', reveal, { passive: true })
197
+ reveal()
198
+ return { detach() { bodyEl.removeEventListener('scroll', reveal) } }
199
+ }
200
+
114
201
  function installFocusTrap(root, doc) {
115
202
  function onKeyDown(e) {
116
203
  if (e.key !== 'Tab') return