diff --git a/docs/kickside-development/17-settings.md b/docs/kickside-development/17-settings.md index f51b088..83924d7 100644 --- a/docs/kickside-development/17-settings.md +++ b/docs/kickside-development/17-settings.md @@ -465,10 +465,10 @@ change in a module upgrade propagates immediately, and reset-to-default is literally deleting the row (`unset`). Never store the default as an override. -Reads are served from a boot-warmed `store.memory` cache. A cold cache is a -loud error - `"settings cache is not warmed"` - not a silent DB reload. -Consumers reading at boot must start after the cache-warm barrier or fail -clearly; they must not switch to a second DB/env read path. +Reads are served from a `store.memory` cache warmed at boot. A cold cache is +normal, because a hot module update can replace the store: the read reloads from +the DB and fills the empty cache. It returns an error only when that reload +fails, and it never returns partial data. ## Gotchas diff --git a/scripts/check-module.mjs b/scripts/check-module.mjs index 2e645e9..8a128db 100644 --- a/scripts/check-module.mjs +++ b/scripts/check-module.mjs @@ -46,7 +46,8 @@ if (typeof packageJson.wippy?.description !== 'string' || packageJson.wippy.desc if (!securityIndex.includes('default: kickside.security.groups:members')) report('user_security_scope must default to the platform Members group') if (/^\s*default:\s*app[:.]/m.test(rootIndex)) report('requirements must not default to a host-owned app resource; the host binds them') if (!securityIndex.includes('intent: module_access')) report('the module must publish a module_access policy') -if (!viteConfig.includes('preserveEntrySignatures: false')) report('web-component build must preserve the entry ownership contract') +if (!viteConfig.includes("preserveEntrySignatures: 'strict'")) report('web-component build must keep registration and webComponent() in the entry') +if (!viteConfig.includes('manualChunks')) report('web-component build must keep the wrapper in its own chunk, so a lazy chunk never imports the entry back') if (/--p-(red|green|blue|orange|yellow|purple|pink|sky|emerald)-\d+/i.test(styles)) report('frontend uses a nonexistent color-named --p-* token') // Library builds don't auto-replace process.env; without the define shim any @@ -56,20 +57,13 @@ if (!viteConfig.includes("'process.env.NODE_ENV'")) report("vite.config.ts must if (!viteConfig.includes("'process.env': '{}'")) report("vite.config.ts must define 'process.env': '{}' as the fallback shim") // A component loads in two documents: auto-registered elements in the top -// document under the web-host import map, and surfaces inside the shell frame -// under the shell import map. Only packages both maps serve may stay external; -// a bundled copy of one of them duplicates framework state and reintroduces -// process.env reads the define shim cannot reach inside prebuilt library -// dists. Every other package, PrimeVue included, is bundled: the shell frame -// maps a subset of primevue/* and the top document maps none. -const hostProvidedPackages = ['vue', 'pinia', 'vue-router', 'axios', 'nanoevents', 'luxon', '@iconify/vue', - '@wippy-fe/proxy'] -const declaredDeps = Object.keys({ ...packageJson.dependencies, ...packageJson.devDependencies, ...packageJson.peerDependencies }) -for (const dep of declaredDeps) { - if (hostProvidedPackages.includes(dep) && !viteConfig.includes(`'${dep}'`)) { - report(`ui/package.json declares ${dep}, which every host import map provides; add it to rollupOptions.external in vite.config.ts`) - } -} +// document and surfaces inside the shell frame. Both serve the complete Host +// import map, so its pinned keys are the externals contract. A bundled copy of a +// mapped package duplicates framework state and reintroduces process.env reads +// the define shim cannot reach inside prebuilt library dists. +const hostImportMap = JSON.parse(await readFile(resolve(root, 'ui/web-host.import-map.snapshot.json'), 'utf8')) +const hostProvidedPackages = Object.keys(hostImportMap.imports) +if (!viteConfig.includes('Object.keys(hostImportMap.imports)')) report('rollupOptions.external must come from the pinned Host import map') // The shell mounts page components in a definite-height surface whose every // ancestor hides overflow: a page that does not own its scrolling gets diff --git a/scripts/test-initializer.mjs b/scripts/test-initializer.mjs index 2cafc6d..95bd541 100644 --- a/scripts/test-initializer.mjs +++ b/scripts/test-initializer.mjs @@ -82,27 +82,38 @@ const declarePrimeVue = (source) => { return `${JSON.stringify(manifest, null, 2)}\n` } -const bundledPrimeVue = await checkWith({ 'ui/package.json': declarePrimeVue }) -assert.equal(bundledPrimeVue.status, 0, `check must accept a module that bundles PrimeVue:\n${bundledPrimeVue.stderr}`) +const declaredPrimeVue = await checkWith({ 'ui/package.json': declarePrimeVue }) +assert.equal(declaredPrimeVue.status, 0, `check must accept a module that declares PrimeVue:\n${declaredPrimeVue.stderr}`) const externalPrimeVue = await checkWith({ 'ui/package.json': declarePrimeVue, 'static/index.js': (source) => `import { default as Tabs } from "primevue/tabs";\n${source}`, }) -assert.equal(externalPrimeVue.status, 1, 'check must reject a bundle that imports PrimeVue from the import map') -assert.match(externalPrimeVue.stderr, /static\/index\.js imports primevue\/tabs/) +assert.equal(externalPrimeVue.status, 0, `check must accept a bundle that imports a mapped PrimeVue path:\n${externalPrimeVue.stderr}`) -const surfaceOnlyImport = await checkWith({ - 'static/index.js': (source) => `import MarkdownIt from"markdown-it";\n${source}`, +const unmappedImport = await checkWith({ + 'static/index.js': (source) => `import { spinner } from"@primevue/icons/spinner";\n${source}`, }) -assert.equal(surfaceOnlyImport.status, 1, 'check must reject a bare import the shell surface import map lacks') -assert.match(surfaceOnlyImport.stderr, /static\/index\.js imports markdown-it/) +assert.equal(unmappedImport.status, 1, 'check must reject a bare import the Host import map lacks') +assert.match(unmappedImport.stderr, /static[\\/]index\.js imports @primevue\/icons\/spinner/) -const bundledVue = await checkWith({ - 'ui/vite.config.ts': (source) => source.replace(/^\s*'vue',\n/m, ''), +const curatedExternals = await checkWith({ + 'ui/vite.config.ts': (source) => source.replace('Object.keys(hostImportMap.imports)', "['vue']"), }) -assert.equal(bundledVue.status, 1, 'check must reject a declared host-provided package left out of rollupOptions.external') -assert.match(bundledVue.stderr, /declares vue, which every host import map provides/) +assert.equal(curatedExternals.status, 1, 'check must reject externals that do not come from the Host import map') +assert.match(curatedExternals.stderr, /must come from the pinned Host import map/) + +const droppedFactory = await checkWith({ + 'ui/vite.config.ts': (source) => source.replace("preserveEntrySignatures: 'strict'", 'preserveEntrySignatures: false'), +}) +assert.equal(droppedFactory.status, 1, 'check must reject a build that can drop webComponent() from the entry') +assert.match(droppedFactory.stderr, /keep registration and webComponent\(\) in the entry/) + +const sharedWrapper = await checkWith({ + 'ui/vite.config.ts': (source) => source.replace(' manualChunks: wcRuntimeChunk,\n', ''), +}) +assert.equal(sharedWrapper.status, 1, 'check must reject a build that leaves the wrapper in the entry') +assert.match(sharedWrapper.stderr, /keep the wrapper in its own chunk/) console.log('Initializer test passed') await rm(target, { recursive: true, force: true }) diff --git a/static/index.js b/static/index.js index 7a95dbd..c2a394e 100644 --- a/static/index.js +++ b/static/index.js @@ -1,606 +1,49 @@ -import { ref as d, onMounted as O, onUnmounted as V, watch as L, createApp as T, inject as N, defineComponent as H, openBlock as g, createElementBlock as b, createElementVNode as v, createVNode as z, unref as U, toDisplayString as x, createTextVNode as D } from "vue"; -import { addCollection as $, Icon as j } from "@iconify/vue"; -import { config as k, addIcons as B, on as P, applyThemeMode as y, host as W, hostCss as F, loadCss as K, api as M, define as Y } from "@wippy-fe/proxy"; -import { getActivePinia as J, createPinia as q, setActivePinia as G } from "pinia"; -var C = "data-wippy-visible"; -function S(r) { - const e = r.getAttribute(C); - return e === "true" ? !0 : e === "false" ? !1 : null; -} -var Q = Object.defineProperty, X = (r, e, t) => e in r ? Q(r, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : r[e] = t, l = (r, e, t) => X(r, typeof e != "symbol" ? e + "" : e, t), Z = [ - "themeConfigUrl", - "primeVueCssUrl", - "markdownCssUrl", - "iframeCssUrl" -], rr = [ - ":where([data-wippy-theme-root]).w-theme-light", - ":where([data-wippy-theme-root]).w-theme-dark" -].join(`, -`); -function E(r) { - const e = r.startsWith("--") ? r : `--${r}`; - return /^--[-\w\u0080-\uFFFF]+$/.test(e) ? e : null; -} -function er(...r) { - const e = /* @__PURE__ */ new Set(); - for (const t of r) - if (t) - for (const [o, s] of Object.entries(t)) { - if ((o === "@light" || o === "@dark") && s && typeof s == "object") { - for (const [p, n] of Object.entries(s)) { - const a = typeof n == "string" ? E(p) : null; - a && e.add(a); - } - continue; - } - const i = typeof s == "string" ? E(o) : null; - i && e.add(i); - } - return [...e].sort(); -} -function tr(r) { - if (r.length === 0) - return ""; - const e = r.map((t) => ` ${t}: inherit;`).join(` -`); - return `${rr} { -${e} -}`; -} -function or(r, e) { - const t = tr(e); - t && R(r, t, "@wippy-fe/css-variables-bridge"); -} -function sr(r, e) { - const o = (e ?? Z).map(async (s) => { - const i = F[s]; - if (!i) - return console.warn(`[wippy-fe/webcomponent-core] hostCss key "${s}" is undefined — skipping. Remove it from hostCssKeys if the CSS was removed.`), null; - try { - return await K(i); - } catch (p) { - return console.warn(`[wippy-fe/webcomponent-core] Failed to load hostCss "${s}" (${i}):`, p), null; - } +import { W as n, u as s } from "./wc-runtime-BMe_f3Uc.js"; +import { defineComponent as t, onMounted as l, h as v } from "vue"; +import "@iconify/vue"; +import { define as m } from "@wippy-fe/proxy"; +const e = 3e4; +function g(a) { + let r; + const o = new Promise((c, p) => { + r = setTimeout(() => p(new Error(`Loading timed out after ${e} ms.`)), e); }); - return Promise.all(o).then((s) => { - for (const i of s) { - if (!i) - continue; - const p = document.createElement("style"); - p.textContent = i, p.setAttribute("role", "@wippy-fe/host-css"), r.appendChild(p); - } - }); -} -function ar(r, e) { - const t = document.createElement("style"); - t.textContent = e, r.appendChild(t); -} -function R(r, e, t) { - if (typeof CSSStyleSheet < "u" && "replaceSync" in CSSStyleSheet.prototype) { - const s = new CSSStyleSheet(); - s.replaceSync(e), r.adoptedStyleSheets = [...r.adoptedStyleSheets, s]; - return; - } - const o = document.createElement("style"); - o.setAttribute("role", t), o.textContent = e, r.appendChild(o); -} -function ir(r, e) { - R(r, e, "@wippy-fe/custom-css"); -} -function A(r) { - return r.__wippyHost ?? null; -} -function nr(r) { - return r.replace(/-([a-z])/g, (e, t) => t.toUpperCase()); -} -function pr(r, e, t) { - switch (t.type) { - case "string": - return { value: e }; - case "number": { - const o = Number.parseFloat(e); - return Number.isNaN(o) ? { value: void 0, error: `Invalid ${r}: expected a number` } : { value: o }; - } - case "integer": { - const o = Number.parseInt(e, 10); - return Number.isNaN(o) ? { value: void 0, error: `Invalid ${r}: expected an integer` } : { value: o }; - } - case "boolean": - return { value: e !== "false" }; - case "array": - case "object": - try { - const o = JSON.parse(e); - return t.type === "array" && !Array.isArray(o) ? { value: void 0, error: `Invalid ${r}: expected a JSON array` } : { value: o }; - } catch { - return { value: void 0, error: `Invalid ${r}: must be valid JSON` }; - } - default: - return { value: e }; - } -} -function w(r, e) { - const t = {}, o = []; - for (const [s, i] of Object.entries(e.properties)) { - const p = r.getAttribute(s), n = nr(s); - if (p === null) { - i.default !== void 0 && (t[n] = i.default); - continue; - } - const a = pr(s, p, i); - a.error ? o.push(a.error) : t[n] = a.value; - } - return { props: t, errors: o }; -} -var cr = class { - constructor(r, e) { - l(this, "_props"), l(this, "_errors"), l(this, "_content"), l(this, "_hostVisibility"), l(this, "_propsListeners", /* @__PURE__ */ new Set()), l(this, "_contentListeners", /* @__PURE__ */ new Set()), l(this, "_hostVisibilityListeners", /* @__PURE__ */ new Set()), l(this, "_disposed", !1), l(this, "_emitToDom"), l(this, "props"), l(this, "events"), l(this, "content"), l(this, "hostVisibility"), this._props = r.props, this._errors = r.errors, this._content = r.content, this._hostVisibility = r.hostVisibility, this._emitToDom = e; - const t = this; - this.props = { - get value() { - return t._props; - }, - get errors() { - return t._errors; - }, - subscribe(o, s) { - return t._subscribeProps(o, s); - } - }, this.events = { - emit(o, s) { - t._disposed || t._emitToDom(o, s); - } - }, this.content = r.hasContent ? { - get value() { - return t._content; - }, - subscribe(o, s) { - return t._subscribeContent(o, s); - } - } : null, this.hostVisibility = { - get value() { - return t._hostVisibility; - }, - subscribe(o, s) { - return t._subscribeHostVisibility(o, s); - } - }; - } - /** @internal */ - notifyProps(r, e) { - if (!this._disposed) { - this._props = r, this._errors = e; - for (const t of this._propsListeners) - t(r, e); - } - } - /** @internal */ - notifyContent(r) { - if (!this._disposed) { - this._content = r; - for (const e of this._contentListeners) - e(r); - } - } - /** @internal */ - notifyHostVisibility(r, e) { - if (!(this._disposed || r === e)) { - this._hostVisibility = r; - for (const t of this._hostVisibilityListeners) - t(r, e); - } - } - /** @internal */ - dispose() { - this._disposed || (this._disposed = !0, this._propsListeners.clear(), this._contentListeners.clear(), this._hostVisibilityListeners.clear()); - } - _subscribeProps(r, e) { - if (this._disposed || e?.signal?.aborted) - return () => { - }; - this._propsListeners.add(r), e?.immediate && r(this._props, this._errors); - const t = () => { - this._propsListeners.delete(r), e?.signal?.removeEventListener("abort", t); - }; - return e?.signal?.addEventListener("abort", t, { once: !0 }), t; - } - _subscribeContent(r, e) { - if (this._disposed || e?.signal?.aborted) - return () => { - }; - this._contentListeners.add(r), e?.immediate && r(this._content); - const t = () => { - this._contentListeners.delete(r), e?.signal?.removeEventListener("abort", t); - }; - return e?.signal?.addEventListener("abort", t, { once: !0 }), t; - } - _subscribeHostVisibility(r, e) { - if (this._disposed || e?.signal?.aborted) - return () => { - }; - this._hostVisibilityListeners.add(r), e?.immediate && r(this._hostVisibility, this._hostVisibility); - const t = () => { - this._hostVisibilityListeners.delete(r), e?.signal?.removeEventListener("abort", t); - }; - return e?.signal?.addEventListener("abort", t, { once: !0 }), t; - } -}, lr = class extends HTMLElement { - constructor() { - super(), l(this, "_internals"), l(this, "_contentObserver", null), l(this, "_initialized", !1), l(this, "_container", null), l(this, "_reactive", null), l(this, "_lastProps", null), l(this, "_lastErrors", []), l(this, "_lastContent", null), l(this, "_themeUnsub", null), l(this, "_hostVisible", null), this._internals = this.attachInternals(), this._hostVisible = S(this); - } - /** - * Override to provide the component's configuration. - * Must be static because `observedAttributes` reads it before construction. - * - * Specify the generic to get typed `validateProps`: - * ```ts - * static get wippyConfig(): WippyElementConfig { ... } - * ``` - */ - static get wippyConfig() { - return { propsSchema: { properties: {} } }; - } - /** - * Derived from the props schema + any `extraObservedAttributes`. - */ - static get observedAttributes() { - const r = this.wippyConfig, e = Object.keys(r.propsSchema.properties), t = r.extraObservedAttributes ?? []; - return [.../* @__PURE__ */ new Set([...e, ...t, C])]; - } - /** - * Host-owned logical activity. `null` means no host manages this element. - * It is not CSS visibility or viewport intersection. - */ - get hostVisible() { - return this._hostVisible; - } - /** - * Panel-scoped `host` wrapper attached by the managed-layout shell's - * content resolvers (`ComponentResolver` / `WebComponentPackageLoader`). - * - * Inside a managed-layout panel, this is a wrapper around the universal - * `host` API where context-aware calls (`layout.broadcast / send / on`) - * are routed through the panel-bound bus — so `sourcePanelId` is - * attributed correctly without postMessage indirection. Layout - * mutations and other host methods pass through unchanged. - * - * `null` outside a managed-layout context (compat shell, chat sidebar, - * standalone playground). Subclass code that needs a host in those - * cases can fall back to `import { host } from '@wippy-fe/proxy'`. - */ - get host() { - return A(this); - } - /** - * Emit a CustomEvent that bubbles and crosses shadow DOM boundaries. - */ - emitEvent(r, e) { - this.dispatchEvent(new CustomEvent(r, { - bubbles: !0, - composed: !0, - detail: e - })); - } - /** - * Opt-in reactive adapter — framework-agnostic. Subscribe to prop - * changes, content changes, or emit typed events from a non-Vue - * consumer without re-rolling reactivity. - * - * ```ts - * class MyEl extends WippyElement<{ count: number }, { tick: { n: number } }> { - * protected onMount() { - * const ctrl = new AbortController() - * this.reactive.props.subscribe(({ count }) => { - * this.shadowRoot!.querySelector('.n')!.textContent = String(count) - * }, { signal: ctrl.signal, immediate: true }) - * } - * tick(n: number) { this.reactive.events.emit('tick', { n }) } - * } - * ``` - * - * Allocation cost is zero unless this getter is touched. Disposed on - * `disconnectedCallback`; a fresh adapter is allocated on the next - * access after reconnect. - */ - get reactive() { - if (!this._reactive) { - const r = this.constructor.wippyConfig, e = !!r.contentTemplate; - let t, o; - if (this._lastProps !== null) - t = this._lastProps, o = this._lastErrors; - else { - const i = w(this, r.propsSchema); - r.validateProps && i.errors.push(...r.validateProps(i.props)), t = i.props, o = i.errors, this._lastProps = t, this._lastErrors = o; - } - const s = e ? this._lastContent ?? this._extractContent(r.contentTemplate) : null; - e && this._lastContent === null && (this._lastContent = s), this._reactive = new cr( - { props: t, errors: o, content: s, hasContent: e, hostVisibility: this._hostVisible }, - this.emitEvent.bind(this) - ); - } - return this._reactive; - } - // ── Lifecycle ────────────────────────────────────────────── - connectedCallback() { - this._internals.states.add("loading"); - try { - const r = this.constructor.wippyConfig, e = this._initialized, t = this.shadowRoot ?? this.attachShadow({ mode: r.shadowMode ?? "open" }); - let o; - if (e) - o = this._container; - else { - this.onInit(t), r.inlineCss && ar(t, r.inlineCss); - try { - const c = k?.theming, u = er( - c?.global?.cssVariables, - c?.children?.cssVariables - ); - or(t, u); - } catch { - } - if (r.customCss !== !1) - try { - const c = k?.theming, u = [c?.global?.customCSS, c?.children?.customCSS].filter(Boolean).join(` -`); - u && ir(t, u); - } catch { - } - (r.hostCssKeys === void 0 || r.hostCssKeys.length > 0) && sr(t, r.hostCssKeys), o = document.createElement("div"), o.setAttribute("data-wippy-theme-root", ""); - const a = r.containerClasses ?? []; - a.length > 0 && o.classList.add(...a), t.appendChild(o), this._container = o, B($); - } - this._applyTheme(), this._themeUnsub || (this._themeUnsub = P("@theme", (a) => this._applyTheme(a))); - const { props: s, errors: i } = w(this, r.propsSchema); - r.validateProps && i.push(...r.validateProps(s)); - const p = s; - this._lastProps = p, this._lastErrors = i; - let n = null; - r.contentTemplate && (n = this._extractContent(r.contentTemplate), this._lastContent = n, this._contentObserver = new MutationObserver(() => { - const a = this._extractContent(r.contentTemplate); - this._lastContent = a, this._reactive?.notifyContent(a), this.onContentChanged(a); - }), this._contentObserver.observe(this, { - childList: !0, - characterData: !0, - subtree: !0 - })), this.onMount(t, o, p, i, n, e), this._internals.states.delete("loading"), this._internals.states.add("ready"), e || (this._initialized = !0), this.onReady(), this.emitEvent("load"); - } catch (r) { - this.onError(r), this._internals.states.delete("loading"), this._internals.states.add("error"), this.emitEvent("error", { - message: r instanceof Error ? r.message : String(r), - error: r - }); - } - } - /** - * Applies a forced theme mode to this host element AND the shadow container. - * The container class drives in-shadow selectors (Tailwind `dark:` utilities, - * `.w-theme-dark &`); the host-element class drives `:host(.w-theme-*)` - * selectors. On `@theme` broadcasts the resolved `mode` is passed in directly - * (don't re-read `getThemeMode()` — the proxy's config-vs-emit order isn't a - * contract); on the initial connect it defaults to the host's current mode. - * No-op for an older proxy without `getThemeMode` — `applyThemeMode` clamps - * `undefined` to 'auto'. - */ - _applyTheme(r = W.getThemeMode?.()) { - y(r, this), this._container && y(r, this._container); - } - disconnectedCallback() { - this._themeUnsub && (this._themeUnsub(), this._themeUnsub = null), y("auto", this), this._container && y("auto", this._container), this._contentObserver && (this._contentObserver.disconnect(), this._contentObserver = null), this.onUnmount(), this.emitEvent("unload"), this._internals.states.clear(), this._reactive?.dispose(), this._reactive = null, this._lastProps = null, this._lastErrors = [], this._lastContent = null, delete this.__wippyHost, delete this.__wippyHostBus; - } - attributeChangedCallback(r, e, t) { - if (e === t) - return; - if (r === C) { - const n = this._hostVisible, a = S(this); - if (a === n) - return; - this._hostVisible = a, this.isConnected && this._initialized && (this._reactive?.notifyHostVisibility(a, n), this.onHostVisibilityChanged(a, n)); - return; - } - const o = this.constructor.wippyConfig, { props: s, errors: i } = w(this, o.propsSchema); - o.validateProps && i.push(...o.validateProps(s)); - const p = s; - this._lastProps = p, this._lastErrors = i, this._reactive?.notifyProps(p, i), this.onPropsChanged(p, i); - } - // ── Hooks ────────────────────────────────────────────────── - /** Called right after shadow DOM is attached, before CSS or container. */ - onInit(r) { - } - /** Called after internals state is set to ready, before the `load` event. */ - onReady() { - } - /** Called when connectedCallback throws. Default logs to console. */ - onError(r) { - console.error(`${this.constructor.name} initialization failed:`, r); - } - /** Called when observed attributes change. Override to update framework state. */ - onPropsChanged(r, e) { - } - /** - * Called when the host changes logical activity. `null` means unmanaged. - * This does not imply a DOM remount or a CSS/viewport visibility change. - */ - onHostVisibilityChanged(r, e) { - } - /** - * Extract text from a child `