doxa-maps/library/layouts/StackLayout.vue
Doxa Maps 28d4c83d96
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 52eeb0db
2026-09-26 18:08:09 +00:00

347 lines
22 KiB
Vue

<!--
StackLayout.vue — a brand-neutral, shared SCROLL-STACK layout for grouping elements top-to-bottom.
★ WHY THIS EXISTS (the second shared layout, alongside VerticalTabsLayout). VerticalTabsLayout shows
ONE element at a time (tabs, fixed-viewport panes). StackLayout does the OPPOSITE: it renders ALL of
its elements STACKED VERTICALLY, each at its NATURAL height, so the whole thing grows the page and the
HOST page scrolls through them — exactly how a normal host page flows.
Use it to GROUP two (or more) elements on top of each other and embed the group on a page you scroll.
★ SAME NESTED-ELEMENT CONTRACT as VerticalTabsLayout (so a shell composes other element-plugins). Pass
:sections="[{ key, title?, sub?, element, src?, attrs? }]". For each section:
element : a custom-element tag to render, e.g. "research-dashboard-map"
src? : a bundle URL to inject (once, deduped) if `element` isn't registered yet
attrs? : a map of attributes/props set on the element BEFORE it mounts ("props in advance")
title? : an optional section heading rendered above the element (omit for a bare stack)
A section can INSTEAD carry no `element` and use the #section-<key> slot (or the default slot) for
host-rendered content — `element` is an ALTERNATIVE, not a replacement.
★ EAGER, NOT LAZY (the deliberate difference from tabs). Because the whole point is to scroll through
everything, every section's element mounts on FIRST RENDER (not on activation). If you only want the
visible ones to load, that's the tabs layout's job; a scroll-stack shows all.
★ THE LAYOUT OWNS THE SCROLL — the elements do NOT. This is the agent's scroll-page contract: a
plugin dropped in a section only needs ROOM to display at full height; it never needs its own
scrollbar. The root is `display:flow-root` with NO fixed height and NO `position:absolute; inset:0`
— so it takes its content's natural height and the PARENT PAGE scrolls through everything. Each
nested element is given `display:block; width:100%` and whatever height IT wants (a 900px dashboard
makes the page 900px taller). Because many of our plugins ship their own `position:absolute; inset:0`
/ `height:100%` (they were authored to fill a fixed-viewport tab pane), the host pane is defensive:
it is `position:relative` and DEMOTES an absolutely/fixed-positioned direct child back to
`position:static` so it lays out in the flow instead of collapsing the section to 0px. For such a
self-sizing element that still can't measure its own height in flow, pass :min-section-height (or a
section's own `minHeight`) to reserve room. Optionally cap a section with :max-section-height to give
a genuinely tall element an inner scroll instead of an endless page (default: no cap = pure page
scroll — a normal host page's flow).
★ EMBEDDING A LAYOUT INSIDE A LAYOUT (scroll-page in the cap-5 main). The cap-5 SPA's shell is
VerticalTabsLayout (sidebar + a FIXED-VIEWPORT content frame whose pane is `position:absolute;
inset:0`). A raw StackLayout dropped straight into that pane would be clipped by the inset overlay,
so the page can't grow. The proven layout-inside-a-layout path is therefore the IFRAME LEAF: a tab
is registered `mode:'iframe'` and its leaf page renders THIS layout at document root, where the
document itself owns the scroll. dashboard-plus-grid does exactly this (a cap-5 tab whose leaf is a
StackLayout) — scroll-page inside the vertical-tabs main, proven end to end.
★ SHADOW-DOM SAFE. Like VerticalTabsLayout, this is typically imported as a CHILD of a customElement
root, so its <style> would be dropped from the shadow root. It injects its critical CSS via
useShadowStyles() at setup(). Keep STL_SHADOW_CSS and the <style> mirror in sync.
Slots: #brand / #meta (an optional top bar — omitted entirely if neither is used) · #section-<key>
(host content for a section without `element`) · #default (a trailing host area at the bottom).
-->
<template>
<div class="stl">
<header class="stl-top" v-if="$slots.brand || $slots.meta || title">
<span class="stl-brand"><slot name="brand" /></span>
<b class="stl-title" v-if="title">{{ title }}</b>
<span class="stl-meta"><slot name="meta" /></span>
</header>
<div class="stl-stack">
<section
v-for="sec in sections" :key="sec.key" class="stl-sec"
:class="{ 'stl-sec-fill': hasDefiniteHeight(sec) }"
:style="sectionStyle(sec)">
<div class="stl-sec-head" v-if="sec.title">
<b class="stl-sec-title">{{ sec.title }}</b>
<span class="stl-sec-sub" v-if="sec.sub">{{ sec.sub }}</span>
</div>
<!-- element-bearing section → a host pane the element is mounted into imperatively. -->
<div v-if="sec.element" class="stl-host" :ref="el => registerHost(sec.key, el)"></div>
<!-- otherwise → host-rendered content via a per-section slot. -->
<div v-else class="stl-slot"><slot :name="'section-' + sec.key" /></div>
</section>
<div class="stl-tail"><slot /></div>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { bundleTreeRoot } from '@map/utils/embedBase.js'
// Inject a bundle script exactly once per URL (deduped across all layout instances). Same helper the
// tabs layout + the SPA use — resolves after the IIFE's customElements.define has run.
const _stlLoaded = {}
// ★ ANCHOR A SIBLING-BUNDLE src TO THE TREE ROOT, NOT TO A GUESSED DEPTH.
// THE BUG (the agent-reported 2026-09-23, verbatim console text):
// Could not load <doxa-research-map> from ../../doxa-research-map/doxa-research-map.js
// A composite declares its children as `src: '../../<bundle>/<bundle>.js'`. That literal is correct
// from a ONE-deep leaf and wrong from a TWO-deep one, and the generator emits both:
// /doxa-maps/research-map-plus-grid/research-map-plus-grid/index.html ../../ -> /doxa-maps/ 200
// /doxa-maps/research-map-plus-grid/research-map-plus-grid/default/index.html ../../ -> /doxa-maps/research-map-plus-grid/ 404
// MEASURED live: both leaves serve 200, the child script 404s only from the deeper one. So a
// hardcoded relative path encodes an ASSUMPTION ABOUT THE HOST'S DEPTH that the host does not
// control and cannot see — the classic relative-path failure, and the reason it survived: the
// shallow leaf (which every gate happened to exercise) is genuinely fine.
// THE FIX: find the tree root from the document's own URL — everything up to and including the
// /doxa-maps/ segment — and rebuild the path from there. Depth-independent by construction rather
// than tuned, so adding a third leaf depth later cannot reintroduce this.
// Anything already absolute, protocol-relative or root-relative is passed through untouched; if no
// /doxa-maps/ segment is found (a foreign mount) the original string is returned, so this can only
// ever fix a path, never break one that was already working.
// eslint-disable-next-line no-undef
const TREE_SEGMENT = (typeof __DOXA_TREE_NAME__ !== 'undefined' ? __DOXA_TREE_NAME__ : 'doxa-maps').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
function resolveBundleSrc(url) {
if (!url || /^(https?:)?\/\//.test(url) || url.startsWith('/')) return url
const bare = url.replace(/^(\.\.\/)+/, '').replace(/^\.\//, '')
// (1) The document is somewhere inside the tree — anchor on its own /<tree>/ segment. The name is
// baked in at build time (vite define __DOXA_TREE_NAME__, from the build config), never a literal:
// a hardcoded '/doxa-maps/' here broke every composite's nested bundles on a renamed tree.
const m = new RegExp('^(.*/' + TREE_SEGMENT + '/)').exec(window.location.pathname)
if (m) return m[1] + bare
// (2) THE DOCUMENT IS NOT INSIDE THE TREE AT ALL, so there is no segment to anchor on.
// the agent-reported 2026-09-23: <plugin-studio> mounts at the SERVER ROOT (/index.html) in both
// trees, and from there this function's only anchor was missing, so it returned the literal
// '../../doxa-research-map/doxa-research-map.js' unchanged. The browser resolved that against
// '/' — where '../' cannot climb — and requested /doxa-research-map/doxa-research-map.js:
// 404, and StackLayout's .catch() reported "Could not load <doxa-research-map>".
// MEASURED on the built tree: the SAME composite opened at its own leaf renders perfectly, which
// is why this survived — every gate and every screenshot used a leaf URL, and the studio is the
// one host that is not one.
// The answer is the tree root the BUNDLE ITSELF was served from. bundleTreeRoot() reads this
// bundle's own script URL (rollup rewrites import.meta.url to document.currentScript.src in the
// IIFE build), so it is a FACT about where the code came from rather than a guess about where the
// page is — which is the lesson stack-demo.vue already wrote down after this same defect appeared
// for the third time: "resolve at RUNTIME from something that knows which tree is serving."
// It returns null in dev ESM and for any layout that is not <name>/<name>.js, so it never guesses.
const root = bundleTreeRoot()
if (root) return String(root).replace(/\/+$/, '') + '/' + bare
// (3) Nothing knowable — return the original untouched. Together with (1) coming first, this
// keeps every path that worked before byte-identical: this function can still only ever fix a
// path, never break one.
return url
}
function injectScriptOnce(rawUrl) {
const url = resolveBundleSrc(rawUrl)
if (!url) return Promise.resolve()
if (_stlLoaded[url]) return _stlLoaded[url]
_stlLoaded[url] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
// ★ A DEV ENTRY IS AN ES MODULE, AND A CLASSIC <script> LIES ABOUT LOADING IT.
// Pointing a classic script at /plugin-designs/<bundle>/index.js fires onload SUCCESSFULLY and
// only THEN throws "Cannot use import statement outside a module", asynchronously. So this
// promise resolved, waitForDefine() below timed out, the tag never registered, and the section
// rendered empty — with NO failed request and NO console error at the injection site. onload is
// structurally incapable of detecting this.
// MEASURED on stack-demo's dev leaf 2026-09-22: 0 failed requests, and yet
// customElements.get('research-dashboard-map') === false with 0 rows rendered.
// Same rule as plugin-studio.vue:255, which hit this first. It belongs HERE because StackLayout
// is the SHARED layout — dashboard-plus-grid and research-map-plus-grid inject through this same
// function, so one fix covers all three consumers instead of becoming a fourth private copy.
if (/^\/plugin-designs\//.test(url) || /\/index\.js(\?|$)/.test(url)) s.type = 'module'
s.onload = () => ok()
s.onerror = () => { delete _stlLoaded[url]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return _stlLoaded[url]
}
function waitForDefine(tag, timeoutMs = 5000) {
if (typeof customElements === 'undefined') return Promise.resolve()
if (customElements.get(tag)) return Promise.resolve()
if (customElements.whenDefined) {
return Promise.race([
customElements.whenDefined(tag),
new Promise((_, r) => setTimeout(() => r(new Error('timeout waiting for <' + tag + '>')), timeoutMs))
])
}
return Promise.resolve()
}
// Mirror of the <style> block for the shadow root (kept comment/interp-free for the strip plugin).
const STL_SHADOW_CSS = `
.stl { display:flow-root; width:100%; min-height:100%; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --stl-accent:#92b195; --stl-border:#d1d4cd; --stl-elevated:#ffffff; --stl-muted:#6d766e; }
.stl-top { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--stl-border); background:#ffffff; position:sticky; top:0; z-index:5; }
.stl-brand { display:inline-flex; align-items:center; }
.stl-brand:empty { display:none; }
.stl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
.stl-meta { color:var(--stl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
.stl-stack { display:flex; flex-direction:column; }
.stl-sec { display:flex; flex-direction:column; min-width:0; border-bottom:1px solid var(--stl-border); }
.stl-sec:last-child { border-bottom:0; }
.stl-sec-head { padding:12px 16px 8px; display:flex; align-items:baseline; gap:10px; }
.stl-sec-title { font-size:13px; font-weight:600; letter-spacing:.02em; color:#3b463d; }
.stl-sec-sub { font-size:12px; color:var(--stl-muted); }
.stl-host { display:block; width:100%; min-width:0; position:relative; }
.stl-host > * { display:block; width:100%; }
.stl-host > *[style*="absolute"], .stl-host > *[style*="fixed"] { position:static !important; inset:auto !important; }
.stl-slot { display:block; width:100%; }
.stl-tail:empty { display:none; }
.stl-sec-fill { position:relative; overflow:hidden; }
.stl-sec-fill > .stl-host, .stl-sec-fill > .stl-slot { position:absolute; inset:0; overflow:hidden; }
.stl-sec-fill > .stl-host > *, .stl-sec-fill > .stl-slot > * { height:100%; }
.stl, .stl * { scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; }
.stl ::-webkit-scrollbar { width:8px; height:8px; }
.stl ::-webkit-scrollbar-track { background:transparent; }
.stl ::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
.stl ::-webkit-scrollbar-thumb:hover { background:#9aa197; }
.stl ::-webkit-scrollbar-corner { background:transparent; }
`
export default defineComponent({
name: 'StackLayout',
props: {
// [{ key, title?, sub?, element?, src?, attrs? }] — rendered top-to-bottom, all eager.
sections: { type: Array, default: () => [] },
// Optional top-bar title (omit for a bare stack with no header).
title: { type: String, default: '' },
// Optional per-section max height (e.g. '70vh') → that section gets an inner scroll instead of an
// unbounded page. Default '' = no cap, pure page scroll (a normal host page's flow).
maxSectionHeight: { type: String, default: '' },
// Optional default MIN height for every section (e.g. '60vh') → reserves room for a self-sizing
// element that ships its own fixed layout and can't measure its natural height in flow. A section
// can override per-item with its own `minHeight`. Default '' = no floor (pure natural height).
minSectionHeight: { type: String, default: '' }
},
setup() {
useShadowStyles(STL_SHADOW_CSS, 'stack-layout')
},
data() {
return { _hosts: {}, _mounted: {} }
},
mounted() {
// Eager: mount every element-bearing section on first render (scroll-through shows all).
for (const sec of (this.sections || [])) {
if (sec.element) this.mountSection(sec)
}
},
methods: {
registerHost(key, el) { if (el) this._hosts[key] = el },
// Per-section wrapper style: an optional inner-scroll cap (maxHeight) and/or a reserved floor
// (minHeight, per-section override → layout default). Returns null when neither applies so a plain
// section stays pure natural-height page-flow.
sectionStyle(sec) {
const s = {}
const max = sec.maxHeight || this.maxSectionHeight
const min = sec.minHeight || this.minSectionHeight
// A definite-height section (min===max) is a fixed BAND: it must NOT get an inner section scroll —
// the nested element (via .stl-sec-fill) fills it and owns any inner scroll, and the PAGE scrolls
// past it. Only a cap-WITHOUT-a-matching-floor gets the maxHeight inner-scroll (`overflow:auto`).
const definite = !!(max && min && max === min)
if (max) { s.maxHeight = max; if (!definite) s.overflow = 'auto' }
if (min) s.minHeight = min
return Object.keys(s).length ? s : null
},
// A section is "definite-height" when it pins BOTH a floor and a cap to the SAME value (min===max):
// its height is fixed, not natural. Such a section adds `.stl-sec-fill` so its host/slot + the nested
// element stretch to fill it — the escape hatch for a fixed-viewport plugin (e.g. the research
// dashboard) that ships `height:100%` and needs a definite parent height to resolve against, while
// the OUTER page still grows below the section so page-scroll reaches the next section.
hasDefiniteHeight(sec) {
const max = sec.maxHeight || this.maxSectionHeight
const min = sec.minHeight || this.minSectionHeight
return !!(max && min && max === min)
},
mountSection(sec) {
if (this._mounted[sec.key]) return
this._mounted[sec.key] = true
const host = this._hosts[sec.key]
if (!host) return
injectScriptOnce(sec.src)
.then(() => waitForDefine(sec.element))
.then(() => {
if (!customElements.get(sec.element)) return
const el = document.createElement(sec.element)
const attrs = sec.attrs || {}
for (const k in attrs) {
const val = attrs[k]
// props-in-advance: set as a property AND an attribute (custom elements read either).
try { el[k] = val } catch { /* ignore */ }
try { el.setAttribute(k, val == null ? '' : String(val)) } catch { /* ignore invalid attr name */ }
}
host.innerHTML = ''
host.appendChild(el)
})
// ★ A FAILED SECTION MUST SAY SO (BUG-SIMPLE-RESEARCH-GREY-PANE, the agent reported 4x).
// The rest of the stack still renders — that part was always right, and is why this catch
// is per-section rather than around the whole loop. What was wrong is that it left the host
// EMPTY and silent, so a composite whose children all failed rendered as a blank rectangle
// with nothing in the console at this site. MEASURED under the dev server: <research-map-
// plus-grid> mounted (count 1) with BOTH children 404 — 794px of pure grey and no message.
// An empty band inside a working page is indistinguishable from a broken app, and it hid
// this defect through four separate reports.
//
// The message is deliberately plain text in the host rather than a thrown error: throwing
// here would abort the other sections, which is the one behaviour this catch exists to
// prevent. Styling stays inline because the host is the consumer's node, not ours.
.catch((e) => {
const why = (e && e.message) || String(e)
console.error('[StackLayout] section "' + sec.key + '" failed to mount <' +
sec.element + '>' + (sec.src ? ' from ' + sec.src : '') + ': ' + why)
try {
host.innerHTML = ''
const d = document.createElement('div')
d.setAttribute('role', 'status')
// overflow-wrap + break-word, because the payload is a URL: a long unbroken path
// otherwise runs straight out of the card (measured — it overflowed the 1024px pane).
// box-sizing + max-width:100% keep the card inside the host: the host can be narrower
// than the message, and without these the margin is ADDED to a 100% width and the text
// runs off the pane (measured — the URL escaped the 1024px pane twice before this).
d.style.cssText = 'box-sizing:border-box;max-width:100%;' +
'padding:16px;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;' +
'color:#6d766e;background:#fff;border:1px solid #d1d4cd;border-radius:8px;margin:12px 0;' +
'overflow-wrap:anywhere;word-break:break-word'
d.textContent = 'Could not load <' + sec.element + '>' +
(sec.src ? ' from ' + sec.src : '') + ' — ' + why
host.appendChild(d)
} catch { /* host detached mid-flight: nothing to report into */ }
})
}
}
})
</script>
<style>
/* Mirror of STL_SHADOW_CSS for dev / light-DOM readability. Keep the two in sync.
Brand-neutral default dark theme; consumers override via the --stl-* custom properties. */
.stl { display:flow-root; width:100%; min-height:100%; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --stl-accent:#92b195; --stl-border:#d1d4cd; --stl-elevated:#ffffff; --stl-muted:#6d766e; }
.stl-top { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--stl-border); background:#ffffff; position:sticky; top:0; z-index:5; }
.stl-brand { display:inline-flex; align-items:center; }
.stl-brand:empty { display:none; }
.stl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
.stl-meta { color:var(--stl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
.stl-stack { display:flex; flex-direction:column; }
.stl-sec { display:flex; flex-direction:column; min-width:0; border-bottom:1px solid var(--stl-border); }
.stl-sec:last-child { border-bottom:0; }
.stl-sec-head { padding:12px 16px 8px; display:flex; align-items:baseline; gap:10px; }
.stl-sec-title { font-size:13px; font-weight:600; letter-spacing:.02em; color:#3b463d; }
.stl-sec-sub { font-size:12px; color:var(--stl-muted); }
.stl-host { display:block; width:100%; min-width:0; position:relative; }
.stl-host > * { display:block; width:100%; }
.stl-host > *[style*="absolute"], .stl-host > *[style*="fixed"] { position:static !important; inset:auto !important; }
.stl-slot { display:block; width:100%; }
.stl-tail:empty { display:none; }
.stl-sec-fill { position:relative; overflow:hidden; }
.stl-sec-fill > .stl-host, .stl-sec-fill > .stl-slot { position:absolute; inset:0; overflow:hidden; }
.stl-sec-fill > .stl-host > *, .stl-sec-fill > .stl-slot > * { height:100%; }
.stl, .stl * { scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; }
.stl ::-webkit-scrollbar { width:8px; height:8px; }
.stl ::-webkit-scrollbar-track { background:transparent; }
.stl ::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
.stl ::-webkit-scrollbar-thumb:hover { background:#9aa197; }
.stl ::-webkit-scrollbar-corner { background:transparent; }
</style>