Some checks are pending
build-gate / build (push) Waiting to run
stack-demo rendered two empty sections in dev. Its sibling bundles were addressed with a tree-relative path, which the browser resolves against the document URL, so no host and no attribute can redirect it — correct in the built tree, a 404 in dev. Measured: the dev entry answers 200 while the path the relative form resolves to answers 404. The sources now resolve at runtime, so the same file is right in both trees, and the production branch compiles away to the string it replaces. That was the third place this defect appeared. Both capability-5 shells carried the same hardcoded paths and were cured the same way, and each instance was found separately after the previous one was fixed — because a literal parent-directory path reads as correct in review and is invisible to a gate that only ever checks the built tree. Removing the 404 was not enough: the sections were still empty with zero failed requests. The shared layout injected each bundle with a classic script, and a classic script pointed at a dev ES module fires its load handler successfully and only then throws, so the promise resolved while the tag never registered and the wait for definition quietly timed out. A load handler cannot detect that. The module rule now lives in the shared layout rather than in a fourth private copy of the same loader, so the two other composites that inject through it are covered too. Verified after: both elements define, sixty-six facet rows and twenty-four cards on stack-demo and on dashboard-plus-grid, zero failed requests on each.
262 lines
16 KiB
Vue
262 lines
16 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 document / a doxa-life / nuxtinator 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 Driver'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 — the doxa-life / nuxtinator page-flow model).
|
|
|
|
★ 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'
|
|
|
|
// 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 = {}
|
|
function injectScriptOnce(url) {
|
|
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 (the doxa-life/nuxtinator page-flow model).
|
|
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)
|
|
})
|
|
.catch(() => { /* a failed section leaves its host empty; the rest of the stack still renders */ })
|
|
}
|
|
}
|
|
})
|
|
</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>
|