@panphora/hyper-cms 0.4.2 → 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/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,6 +110,7 @@ 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
  },
@@ -110,28 +155,58 @@ function ensureStyles(doc) {
110
155
  const style = doc.createElement('style')
111
156
  style.id = SHELL_STYLE_ID
112
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', '')
113
162
  style.textContent = cssText
114
163
  ;(doc.head || doc.documentElement).appendChild(style)
115
164
  styledDocs.add(doc)
116
165
  return
117
166
  }
118
- // No bundled CSS — try resolving the sibling styles.css via import.meta.url.
119
- // Browsers ESM-resolved imports place a co-located styles.css next to
120
- // hypercms.js. Falls back silently if resolution fails (e.g., bundlers
121
- // without an asset emitter).
167
+ // No bundled CSS — try resolving the sibling generated theme via
168
+ // import.meta.url. Browsers ESM-resolved imports place a co-located
169
+ // theme.generated.css next to hypercms.js. Falls back silently if resolution
170
+ // fails (e.g., bundlers without an asset emitter).
122
171
  try {
123
- const href = new URL('./styles.css', import.meta.url).href
172
+ const href = new URL('./theme.generated.css', import.meta.url).href
124
173
  const link = doc.createElement('link')
125
174
  link.rel = 'stylesheet'
126
175
  link.id = SHELL_STYLE_ID
127
176
  link.setAttribute('save-remove', '')
177
+ link.setAttribute('save-ignore', '')
128
178
  link.href = href
129
179
  ;(doc.head || doc.documentElement).appendChild(link)
130
180
  styledDocs.add(doc)
131
181
  } catch (_) {
132
- // SSR or environments without import.meta.url — author can call
133
- // 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
+ )
190
+ }
191
+ }
192
+
193
+ // Reveal the condensed minibar once the full header scrolls out of the body
194
+ // (pixel-quiet decision 0003). Passive listener; toggles .is-condensed on the
195
+ // shell root when scrollTop passes the header height. No-op if the body or
196
+ // header is missing (e.g. a custom mount that omits them).
197
+ function installCondenseOnScroll(root) {
198
+ const bodyEl = root.querySelector('.hcms-shell-body')
199
+ const header = root.querySelector('.hcms-shell-header')
200
+ if (!bodyEl || !header || typeof bodyEl.addEventListener !== 'function') {
201
+ return { detach() {} }
202
+ }
203
+ const reveal = () => {
204
+ const trigger = (header.offsetHeight || 0) - 12
205
+ root.classList.toggle('is-condensed', bodyEl.scrollTop > trigger)
134
206
  }
207
+ bodyEl.addEventListener('scroll', reveal, { passive: true })
208
+ reveal()
209
+ return { detach() { bodyEl.removeEventListener('scroll', reveal) } }
135
210
  }
136
211
 
137
212
  function installFocusTrap(root, doc) {