@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/README.md +26 -4
- package/dist/hypercms.min.js +1040 -220
- package/package.json +5 -3
- package/src/events.js +221 -14
- package/src/form-builder.js +32 -3
- package/src/form-rules.js +7 -5
- package/src/hypercms-bundle.js +5 -2
- package/src/hypercms.js +249 -4
- package/src/refresh.js +2 -1
- package/src/shell.js +103 -16
- package/src/templates.js +165 -24
- package/src/theme/pixel-quiet.overrides.css +503 -0
- package/src/theme.generated.css +1074 -0
- package/src/vendor/mirk.vendor.js +197 -0
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
|
-
|
|
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'
|
|
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 = () =>
|
|
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
|
-
|
|
22
|
+
function escapeHtml(s) {
|
|
23
|
+
return String(s).replace(/[&<>"]/g, (c) => (
|
|
24
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
-
|
|
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
|
-
<
|
|
39
|
-
<
|
|
40
|
-
<button type="button" class="hcms-shell-close" data-hcms-action="close" aria-label="Close"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
|
|
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
|
|
96
|
-
// Browsers ESM-resolved imports place a co-located
|
|
97
|
-
// hypercms.js. Falls back silently if resolution
|
|
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('./
|
|
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
|