跳转到内容

Wippy FE Compliance Checklist

此内容尚不支持你的语言。

A single, exhaustive checklist for shipping Wippy child apps (view.page) and web components (view.component) — covering YAML registration, FE source, build pipeline, theming, proxy API, router/host integration, and host-less mode. Every rule cites a source (canonical doc, host contract, gold-standard reference, or real-world incident) and most have a copy-paste verification command.

This doc supersedes the older app-checklist.md for new work. The older checklist remains valid for the micro-frontend-app subset; this one extends it with web components, host-less mode, real-world fixes, and verification recipes.

Gold standards (validated against this checklist):

  • Micro Frontend App: app-template/frontend/applications/main/
  • Web component: app-template/frontend/web-components/mermaid/

When this checklist disagrees with what the gold standards do, the gold standards win. See §14 for the validation report.


  • MUST — REJECT the module if not satisfied
  • SHOULD — WARN; document the deviation if you keep it
  • MAY — informational, no compliance gate
  • VERIFY — copy-paste shell command that confirms the rule

Source references use:

  • docs:<filename>:<line> — canonical FE docs
  • gold:<path> — a file in the gold-standard app-template repo, wippyai/app. Path aliases: gold:main/… = frontend/applications/main/…; gold:mermaid/… = frontend/web-components/mermaid/…. Any other gold: path is repo-root-relative (e.g. gold:src/app/views/_index.yaml)
  • kb:<topic> — Wippy KB
  • incident:<id> — bug found during a real audit

  1. The FE isolation paradigm — read this first
  2. Decide what you’re shipping
  3. YAML registration (host contract)
  4. Micro Frontend Apps — manifest, build, runtime
  5. Web components — manifest, build, runtime
  6. Theming
  7. Proxy API & subscriptions
  8. Router & host integration
  9. Build pipeline & Makefile
  10. Host-less mode
  11. Verification recipes
  12. Acceptance criteria (REJECT rules)
  13. Known intentional deviations
  14. Tooling gotchas
  15. Gold-standard validation report
  16. Appendix A — Window globals & DOM markers
  17. Appendix B — HostApi method signatures
  18. Appendix C — ProxyConfig.injections reference

Cite as: “check FE isolation paradigm is followed”.

A Wippy FE module (view.page or view.component) is a standalone, universal build artifact that has ZERO knowledge of where or how it is served. The _index.yaml registry entries on the BE side are the serving facade — they declare, for THIS Wippy deployment, where the bundle is mounted (meta.url), how it’s reached (meta.entry_point / meta.base_path), and where the bytes come from (fs.directory + http.static, or any other source: fs.embed, in-memory FS, upstream proxy, DB-backed FS).

┌──────────────────────┐ built once, served anywhere ┌──────────────────────┐
│ FE module (universal)│ ──────────────────────────────► │ _index.yaml (per-BE) │
│ │ │ │
│ - vite base: '' │ │ - meta.url │
│ - relative imports │ │ - meta.entry_point │
│ - package.json: │ │ - meta.tag_name │
│ tag, props, events │ │ - fs.directory OR │
│ - NO mount URL │ │ fs.embed OR any │
│ - NO peer-module URL │ │ filesystem source │
└──────────────────────┘ └──────────────────────┘
▲ │
│ built bundle (index.js, sourcemaps, optional │
│ static assets) lands wherever the BE expects ──────┘
│ (configurable via Makefile / build script's `--outDir`)
  • The URL prefix where it will be mounted on this deployment.
  • Whether it’s served from a filesystem, in-memory FS, CDN, or remote registry.
  • Which other modules are co-located on the same Wippy instance, or at what URLs they live.
  • Whether the BE uses one centralized fs.directory or one per module.

What the YAML facade decides (and can override per deployment)

Section titled “What the YAML facade decides (and can override per deployment)”
  • meta.url — the URL prefix at which a router serving this bundle is mounted. Not a physical path. May be backed by /static/..., an in-memory FS, or a remote server.
  • meta.entry_point — the URL relative to the bundle root pointing at the entry (app.html for pages, index.js for components).
  • meta.tag_name (WCs only) — the custom-element tag the bundle registers itself as.
  • fs.directory + http.static (or any equivalent) — where the bytes come from on this deployment. Single-shared-FS, per-module-FS, embedded-FS — all valid, FE doesn’t care.

Corollaries (the rules this paradigm produces)

Section titled “Corollaries (the rules this paradigm produces)”

Bundle portability:

  • vite.config.ts MUST have base: '' so all asset URLs in the build are relative. Hardcoding base: '/app/keeper/' is REJECT — it bakes a deployment-specific URL prefix into a universal artifact. See §3.4 and §9.5.

No cross-module URL hardcoding:

  • A FE module MUST NEVER import('/components/x/dist/index.js') or fetch('/wc/x/...') to reach another WC’s physical URL. REJECT. Such a URL is a presumption about THIS deployment’s serving layout — it breaks the moment the BE relocates the peer module, or runs it from an embedded FS. Consume another WC via the proxy / host pipeline instead:
    • Registry-declared WCs (the common case) — if the peer is auto_register: true + announced: true in its view.component entry, the host eagerly registers it in every iframe at boot. Consumer code just does await customElements.whenDefined('wc-x') and then uses the tag. No URL knowledge required.
    • Artifact-based / dynamically-loaded WCs — call loadWebComponent(componentId, tagName?) from @wippy-fe/proxy. componentId is the artifact UUID (the host fetches the artifact’s package.json via the artifact API, validates it as a Wippy package, then dynamic-imports it). This is the path for user-generated / AI-generated WCs delivered as artifacts.

Build script owns the output path, not vite.config:

  • The deployment’s build script (Makefile / make.ps1 / npm script) passes --outDir <wherever-the-BE-expects> per module. The vite.config.ts does NOT hardcode outDir.

package.json describes the module, not its mount point:

  • Carry wippy.tagName, wippy.type, wippy.props, wippy.events. Do NOT carry wippy.url, wippy.host, wippy.mountPath — those are deployment-specific and belong in the BE’s _index.yaml meta.*.
  • Source-of-truth precedence: the registry entry always wins over package.json. meta.tag_name, meta.props, meta.events on the view.component entry override the corresponding wippy.tagName / wippy.props / wippy.events in package.json for THIS deployment.

Re-mounting under a different URL must work without a rebuild:

  • Because base: '', the bundle’s relative imports resolve against whatever <base> the host injects from meta.url + meta.base_path at serve time. Test in dev by changing meta.url, reloading — no rebuild needed. If it breaks, the paradigm is being violated somewhere.

Audit checklist — “is FE isolation paradigm followed?”

Section titled “Audit checklist — “is FE isolation paradigm followed?””
  • vite.config.ts has base: '' (and not '/something/').
  • vite.config.ts does NOT hardcode outDir — the build script passes --outDir per deployment.
  • package.json carries wippy.tagName / wippy.type / props / events. It does NOT carry any deployment URL or mount path.
  • Source tree has zero hardcoded references to peer modules’ physical URLs.
  • If the module consumes another WC, it does so via await customElements.whenDefined('wc-x') (for registry-declared peers with auto_register: true + announced: true) or loadWebComponent(artifactUUID) (for artifact-delivered peers) — never by hardcoding a URL.
  • No import.meta.url-based path math that presumes a specific serving layout. The pattern define(import.meta.url, …) is fine; import.meta.url.split('/components/')[1] is not.

QuestionUse
Will it be loaded in its own iframe with its own app.html?view.page
Will it be inserted as a <custom-tag> inside another page’s DOM?view.component
Both?ship two registry entries — one for each role

Runtime difference (kb:view-page-vs-component):

  • view.page → host loads it in an iframe with its own app.html. Code runs in iframe document.
  • view.component → host registers a custom tag and inserts the element directly in the host DOM (typically with shadow DOM). Code runs in the same document as the host.

view.page does not imply nav presence. A view.page entry with meta.announced: false is reachable via:

  • a mountRoute someone navigates to,
  • direct host.openSession / host.openArtifact invocation,
  • being used inside a managed-layout panel,
  • being loaded inside a <w-artifact> element by another app.

A “page in iframe with no nav-owner” — common in artifact viewers, embedded demos, sub-tools — is an explicit and supported pattern. Set announced: false to keep it out of nav while leaving it routable.

Conversely, a view.component entry with auto_register: true and announced: true will appear in tag-explorer registries; with announced: false it’s an internal building block.

For view.component entries, announced: true is a HARD requirement to participate in the host’s autoload. The /api/public/components/list endpoint filters server-side by announced == true; auto_register: true alone is not enough to make the host inject the WC’s <script type="module"> tag at boot. Symptom of getting this wrong: customElements.get('your-tag') stays undefined, Vue silently renders an empty <your-tag></your-tag> with no shadow root content, no console error.


A registry.entry is what the host actually reads at navigation/render time. The wippy.* block in your package.json is the host-less mirror. YAML is canonical; package.json is for embedded fallback.

- name: main
kind: registry.entry
meta:
type: view.page
name: main
title: Admin Panel
icon: tabler:layout-dashboard
order: 0
announced: true
secure: false
url: /app
base_path: app/main
entry_point: app.html
mountRoute: /home/:part(.*)*
proxy:
enabled: true
injections:
css:
themeConfig: true
iframe: true
primevue: true
customCss: true
customVariables: true
tailwindConfig: true
iconifyIcons: true

Field-by-field (gold:src/app/views/_index.yaml, kb:view-page-fields):

FieldRequiredPurpose
meta.typeMUST"view.page" literal
meta.nameMUSTPage id (combines with namespace into <ns>:<name>, e.g. app.views:main)
meta.titleMUSTHuman-readable title (used in nav and page tabs)
meta.iconSHOULDIconify icon code (e.g. tabler:home); only relevant if announced: true
meta.urlMUSTURL prefix at which the FS+http.router serving this bundle is mounted. Not a physical path.
meta.base_pathMUSTURL path appended to url to reach the bundle root. Combined with url, becomes the HTML <base> injected into entry_point, so relative module imports inside app.html resolve against the bundle root.
meta.entry_pointMUSTURL path relative to the bundle root pointing at the entry HTML file. e.g. app.html (most common).
meta.mountRouteMAYVue Router 4 path the host claims for this page (e.g. /home/:part(.*)*).
meta.secureSHOULDDefault false. Set true to enforce auth.
meta.announcedSHOULDDefault false. Set true to appear in nav. Pages without nav-owner keep false.
meta.hiddenMAYSoft-hide from announced nav.
meta.orderMAYSort position in nav (when announced).
meta.group / meta.group_icon / meta.group_orderMAYNav grouping.
meta.config_overridesMAYPer-page + sub-tree override. The MAIN way to theme the whole app is the host’s facade module — the facade owns cssVariables / customCSS / host_custom_css / css_variables for the app shell. Reach for config_overrides when you want a page — and everything it embeds — to look or behave differently from the rest of the app: the override is merged into that page’s theming.global and propagates to all its nested children. Typical use: a module that ships pages carrying their own theme (e.g. an admin UI) that cascades to their whole sub-tree.
meta.proxySHOULDPer-entry proxy injection config, nested under meta. camelCase under an injections wrapper — same shape as the package.json wippy.proxy block: injections.css.{themeConfig, primevue, customCss, customVariables}, injections.tailwindConfig, injections.iconifyIcons. Deep-merged over the bundled wippy.proxy; YAML wins per nested key.

VERIFY at runtime that the host registry recognizes your entry:

Terminal window
curl -fsS http://<host>/api/public/pages/list | jq '.pages[] | select(.id=="<namespace>:<name>")'
- name: mermaid
kind: registry.entry
meta:
type: view.component
name: mermaid
title: Mermaid Diagram
tag_name: example-mermaid
entry_point: index.js
announced: true
secure: false
auto_register: true
url: /app/wc/mermaid
props:
type: object
properties:
definition:
type: string
default: ""
description: Mermaid diagram definition string
transparent:
type: boolean
default: true
description: Whether the diagram background is transparent

Field-by-field (gold:src/app/views/_index.yaml, kb:view-component-fields):

FieldRequiredPurpose
meta.typeMUST"view.component" literal
meta.nameMUSTComponent logical name
meta.tag_nameMUSTCustom-element tag. Must contain a hyphen (Custom Elements spec).
meta.entry_pointMUSTURL relative to bundle root pointing at the entry JS file (e.g. index.js).
meta.urlMUSTURL prefix where the bundle is served. Same semantics as for view.page.
meta.base_pathMAYSupported for components too; composes the same way (<url>/<base_path>/<entry_point>). Current app-template WC entries omit it, so they are served as meta.url + meta.entry_point.
meta.announcedMUST per kb:view-component-fieldsDefault false (falls back to meta.public). MUST be true for the component to appear in /api/public/components/list.
meta.secureMUST per kbDefault false.
meta.auto_registerMUST per kbDefault false. MUST be true for the host to autoload it; leave/set false for lazy-loaded WCs.
meta.propsSHOULDJSON Schema mirroring wippy.props from package.json. Each property MUST have type, default, description.
meta.eventsMAYJSON Schema mirroring wippy.events. Omit if the WC has no custom events.
meta.descriptionSHOULDVerbose AI/human-readable usage explanation. Not a one-line label — a paragraph or two that explains: what the WC renders, the intended call shape, what input forms are supported, what edge cases or fallbacks exist, and notable performance characteristics.
meta.title, meta.iconMAYFor browse/registry UIs.

No proxy: block for WCs — they run in the host’s document, not in their own iframe, so proxy injections don’t apply.

Hyphenated prop names (e.g. allow-multiple) are camelCased in Vue (allowMultiple). Non-string props are JSON-encoded in attributes; the WC must JSON.parse them or use WippyVueElement/WippyElement (which handle this automatically).

interface AppConfigOverrides {
customization?: Partial<AppCustomization> // compatibility-shaped override; projected into theming.global for children
axiosDefaults?: Partial<AxiosDefaults> // MERGED into config.axiosDefaults
routePrefix?: string // REPLACES config.routePrefix
apiRoutes?: ApiRoutesOverride // REPLACES config.apiRoutes
}

customization field merge semantics (mergeChildCustomization):

  • cssVariablesREPLACE (override map fully replaces parent map)
  • customCSSREPLACE (new string replaces parent; not concatenated)
  • iconsMERGE shallow (additive)
  • iconSetsMERGE per-prefix (additive)

Isolation depends on the field. config_overrides are NOT uniformly “isolation-only” — behaviour is field-specific:

  • cssVariables / customCSS (REPLACE) → the page’s theme replaces the inherited one and then propagates to everything the page embeds (it is merged into the page’s theming.global, which all nested children inherit). Use it to theme a sub-tree, not just one iframe: a module shipping pages with their own palette (e.g. an admin UI whose theme cascades to its artifacts/sub-apps), demo pages with divergent themes, artifact viewers with a fixed brand, debug pages on alternate API routes.
  • icons / iconSets (MERGE) → additive, NON-isolating. Adding icons via config_overrides.customization.icons augments the child-projected icon set without isolating the iframe. At runtime the page reads the result from config.theming.global.icons / iconSets.

2.4 The meta.proxy: entry-level block (page only)

Section titled “2.4 The meta.proxy: entry-level block (page only)”

For view.page entries, the registry-entry’s proxy: block nested under meta: configures host-side proxy injection per page. It is camelCase and uses the injections wrapper — the same shape as the package.json wippy.proxy block. The host reads entry.meta.proxy and deep-merges it over the bundled wippy.proxy; the YAML wins per nested key.

meta:
type: view.page
# ...
proxy:
enabled: true
injections:
css:
themeConfig: true
iframe: true
primevue: true
customCss: true
customVariables: true
tailwindConfig: true
iconifyIcons: true

All injection flags are technically MAY — the host has sane defaults — but micro frontend apps SHOULD declare them explicitly to avoid invisible drift.


3. Micro Frontend Apps — manifest, build, runtime

Section titled “3. Micro Frontend Apps — manifest, build, runtime”

Reference: gold:main/package.json.

{
"name": "@wippy/app-main",
"version": "1.0.0",
"specification": "wippy-component-1.0",
"title": "Wippy App",
"description": "...",
"files": ["dist/", "src/", "package.json"],
"browser": "dist/app.js",
"wippy": {
"type": "page",
"title": "Wippy App",
"icon": "tabler:home",
"path": "dist/app.html",
"proxy": {
"enabled": true,
"injections": {
"css": {
"themeConfig": true,
"iframe": true,
"primevue": true,
"markdown": true,
"customCss": true,
"customVariables": true
},
"tailwindConfig": false,
"resizeObserver": false,
"preventLinkClicks": false,
"iconifyIcons": false,
"errorCapture": true
}
},
"scripts": {
"build": "build",
"debug": "build:debug",
"test": "lint"
}
},
"scripts": {
"build": "vite build",
"build:debug": "vite build --mode development",
"dev": "vite build --watch",
"type-check": "vue-tsc --build --force",
"lint": "eslint src --ext .ts,.vue",
"lint:fix": "eslint src --ext .ts,.vue --fix"
},
"dependencies": {
"@wippy-fe/theme": "^0.0.28"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"@wippy-fe/types-global-proxy": "^0.0.28",
"@wippy-fe/vite-plugin": "^0.0.32",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.0.0",
"postcss": "^8.4.0",
"primevue": "^4.3.3",
"tailwindcss": "3",
"typescript": "^5.0.0",
"vite": "^6.0.0",
"vue": "^3.5.0",
"vue-eslint-parser": "^9.4.3",
"vue-router": "^4.6.4",
"vue-tsc": "^2.0.0"
},
"peerDependencies": {
"@iconify/vue": "^5.0.0",
"@wippy-fe/pinia-persist": "^0.0.28",
"@wippy-fe/proxy": "^0.0.28",
"@wippy-fe/router": "^0.0.28",
"axios": "^1.0.0",
"luxon": "^3.5.0",
"pinia": "^2.1.0",
"primevue": "^4.3.3",
"vue": "^3.5.0",
"vue-router": "^4.0.0"
}
}

Specificationdocs:app-checklist.md:39, gold:main/package.json:4:

  • MUST set "specification": "wippy-component-1.0".
  • MUST set package name to @<org>/app-<short> (e.g. @wippy/app-main).
  • MUST set top-level "title" (matches wippy.title).

wippy block:

  • MUST set wippy.type: "page".
  • MUST set wippy.title (typically equals top-level title).
  • SHOULD set wippy.icon to an Iconify code (only relevant if the page is announced: true).
  • MUST set wippy.path: "dist/app.html" (or wherever your built entry HTML lives).

wippy.scripts map — entry → npm script binding:

  • MUST set wippy.scripts.build (typically "build").
  • MAY set wippy.scripts.debug (typically "build:debug").
  • MAY set wippy.scripts.test (typically "lint" or "test").

wippy.proxy.injections — full reference: Appendix C.

All flags are technically MAY (host has defaults). The recommended set for a typical micro frontend app is:

FlagRecommendedWhen to set otherwise
proxy.enabledtruefalse for pages with no proxy needs (rare)
injections.css.themeConfigtruefalse only if you don’t use Wippy theming
injections.css.iframetruefalse only outside iframe context
injections.css.primevuetruefalse if you don’t use PrimeVue
injections.css.markdowntrue if app renders any markdownfalse if you have no markdown anywhere
injections.css.customCsstruefalse if you don’t read child-projected theming.global.customCSS
injections.css.customVariablestruefalse if you don’t read child-projected theming.global.cssVariables
injections.tailwindConfigfalsetrue if using Tailwind Play CDN runtime
injections.resizeObserverfalsetrue for widget-style pages needing reported size
injections.preventLinkClicksfalsetrue if you don’t have your own router
injections.iconifyIconsfalsetrue if using CDN Iconify
injections.errorCapturetruefalse if you handle errors fully internally
injections.refreshWhenVisiblefalse (or omit)true for pages that need stale-data refresh
injections.historyPolyfilltrue (or omit)leave on; host installs always-stub

Dependency hygiene:

  • dependencies: only what’s bundled into the page (e.g. @wippy-fe/theme, app-specific libs like chart.js).
  • devDependencies: build toolchain (vite, vue-tsc, typescript, @vitejs/plugin-vue, eslint*, tailwindcss@3, postcss, autoprefixer, primevue for build-time, vue for build-time, vue-router if you build-time-import it, @wippy-fe/types-global-proxy, @wippy-fe/vite-plugin).
  • peerDependencies: every package the host’s import map provides. Canonical set: vue, vue-router, pinia, axios, @iconify/vue, @wippy-fe/proxy, @wippy-fe/router, @wippy-fe/pinia-persist, primevue and any primevue/* you import. Add luxon, nanoevents, @tanstack/vue-query, @tanstack/query-core only if the app actually imports them.
  • Adding to peerDependencies does NOT bundle the package — it’s a host-import-map subscription.

Version alignment: @wippy-fe/* packages SHOULD be on the same minor version (e.g. all ^0.0.28). Ecosystem mismatch causes silent ABI drift.

VERIFY required wippy fields:

Terminal window
node -e 'const p=require("./package.json");const m=["specification","title"].filter(k=>!p[k]).concat(p.wippy?[]:["wippy"]);if(m.length)throw new Error("missing: "+m.join(","));console.log("OK")'

Reference: gold:main/vite.config.ts.

import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import { wippyPagePlugin } from '@wippy-fe/vite-plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('example-'),
},
},
}),
wippyPagePlugin(),
],
base: '',
resolve: {
alias: { '@': resolve(__dirname, './src') },
},
build: {
target: 'esnext',
cssCodeSplit: false,
sourcemap: true,
rollupOptions: {
input: { app: resolve(__dirname, 'app.html') },
external: [
'vue',
'pinia',
'vue-router',
'@iconify/vue',
'nanoevents',
'luxon',
'@wippy-fe/proxy',
'axios',
'@tanstack/vue-query',
'@tanstack/query-core',
],
output: {
entryFileNames: '[name].js',
assetFileNames: '[name]-[hash][extname]',
},
},
},
})

Rules:

  • MUST set base: '' (relative paths; bundle is portable to any URL prefix). A hardcoded absolute base (e.g. /app/keeper/) is REJECT — see §9.5.
  • MUST include vue() plugin.
  • SHOULD include wippyPagePlugin() from @wippy-fe/vite-plugin (default; opt out only if your team explicitly does NOT want host-less mode support — rare).
  • SHOULD pass template.compilerOptions.isCustomElement if your templates render custom-element tags.
  • MUST set build.target: 'esnext'.
  • MAY set build.cssCodeSplit: false to inline all CSS into a single bundle.
  • MAY set build.sourcemap: true for production.
  • MUST set build.rollupOptions.input to your app.html.
  • MUST list every host-provided package in build.rollupOptions.external. Canonical set for a full-featured app: vue, pinia, vue-router, axios, @iconify/vue, @wippy-fe/proxy, nanoevents, luxon.
  • MUST NOT set build.assetsInlineLimit to a large value (incident:1I); leave at the 4 KB default.
  • MUST NOT force define: { 'process.env.NODE_ENV': '"production"' } (incident:7C); it overrides --mode development.

VERIFY base + canonical externals + plugin:

Terminal window
grep -E "base:\s*['\"]" path/to/vite.config.ts # must show: base: ''
grep -A 25 "external:" path/to/vite.config.ts # check coverage of imported host packages
grep -E "wippyPagePlugin" path/to/vite.config.ts # SHOULD be present

Reference: gold:main/app.html.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wippy</title>
<script type="importmap">
{
"imports": {
"vue": "https://esm.sh/vue@3",
"pinia": "https://esm.sh/pinia",
"vue-router": "https://esm.sh/vue-router@4",
"luxon": "https://esm.sh/luxon",
"@iconify/vue": "https://esm.sh/@iconify/vue",
"axios": "https://esm.sh/axios",
"@wippy-fe/markdown-iframe": "https://web-host.wippy.ai/<release-tag>/@wippy-fe/markdown-iframe.js"
}
}
</script>
<script
src="https://web-host.wippy.ai/<release-tag>/dev-proxy.js"
data-role="@wippy/scripts"
></script>
</head>
<body>
<div id="app">
<wippy-loading title="Loading..."></wippy-loading>
</div>
<script type="module" src="./src/app.ts"></script>
</body>
</html>

Rules:

  • MUST contain <!DOCTYPE html>, <html lang="...">, charset, viewport.
  • MUST contain a <title>.
  • MUST contain <script type="importmap"> with at minimum every host-provided package the app imports at runtime.
  • The importmap MUST cover every external the app actually imports at runtime.
  • Importmap URLs SHOULD use https://esm.sh/<pkg>@<major>.
  • MUST contain exactly one <script data-role="@wippy/scripts" src="https://web-host.wippy.ai/<release-tag>/dev-proxy.js">. The URL always requires a release-tag segment.
  • MUST contain <div id="app"></div> mount point.
  • MUST contain <wippy-loading title="..."> inside the mount instead of a hand-rolled spinner.
  • MUST contain <script type="module" src="./src/app.ts"> (or your entry path) at end of body.

VERIFY:

Terminal window
grep -c '<script type="importmap">' app.html # must = 1
grep -c 'data-role="@wippy/scripts"' app.html # must = 1
grep -c '<wippy-loading' app.html # must >= 1

Reference: gold:main/src/app.ts.

import { addCollection } from '@iconify/vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { createWippyPersist, preloadWippyState } from '@wippy-fe/pinia-persist'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { PrimeVuePlugin } from '@wippy-fe/theme/primevue-plugin'
// Sync getters from @wippy-fe/proxy — available immediately, never awaited to obtain.
import { config, host, api, on } from '@wippy-fe/proxy'
import App from './app/app.vue'
import { AXIOS_INSTANCE, HOST_API, WIPPY_INSTANCE } from './constants'
import { createAppRouter } from './router'
import '@wippy-fe/theme/theme-config.css'
import './styles.css'
import './tailwind.css'
export async function createMainApp() {
const routePath = config.context?.route
const initialPath = routePath
? (routePath.startsWith('/') ? routePath : '/' + routePath)
: '/'
if (config.theming.global?.icons) {
addCollection({
prefix: 'custom',
icons: config.theming.global.icons,
})
}
for (const [prefix, icons] of Object.entries(config.theming.global?.iconSets ?? {})) {
addCollection({ prefix, icons })
}
const app = createApp(App)
const preloaded = await preloadWippyState()
const pinia = createPinia()
pinia.use(createWippyPersist(preloaded))
app.use(pinia)
app.use(VueQueryPlugin)
app.use(PrimeVuePlugin)
app.provide(HOST_API, host)
app.provide(AXIOS_INSTANCE, api)
app.provide(WIPPY_INSTANCE, { on })
const router = createAppRouter(host, on, initialPath)
app.use(router)
return app
}
export async function mountApp(elementId: string = '#app') {
const app = await createMainApp()
app.mount(elementId)
return app
}
mountApp()

config, host, api, and on are synchronous getters from @wippy-fe/proxy — the host injects the child config before the runtime loads, so they resolve the moment your code runs. You never await to obtain them (the only await left is preloadWippyState(), an actual async op). Providing them via app.provide(...) is an ergonomics choice so the rest of the app can inject(...); a component can equally import { host, api, on } from '@wippy-fe/proxy' at its own call site. See Proxy API.

Rules:

  • MUST obtain config, host, api, on via import { ... } from '@wippy-fe/proxy' (sync getters — no await to obtain them; never window.$W / getWippyApi).
  • MUST resolve initial path from config.context?.route, then fall back to '/'.
  • MUST normalize the resolved path to start with /.
  • MUST app.provide(HOST_API, ...), app.provide(AXIOS_INSTANCE, ...), app.provide(WIPPY_INSTANCE, ...).
  • MUST app.mount('#app') (or whatever id matches the <div id> in app.html).
  • MUST register the PrimeVue plugin if you use any PrimeVue component.
  • SHOULD register createWippyPersist(preloaded) on pinia for state persistence across iframe destructions.
  • SHOULD register VueQueryPlugin if you use TanStack Query.
  • MUST register config.theming.global?.icons and config.theming.global?.iconSets during bootstrap.
  • MUST NOT console.log boot diagnostics in production (console.warn/console.error allowed).

Reference: gold:main/src/router/index.ts.

Canonical pattern — wrap @wippy-fe/router’s factory:

import type { HostApi } from '../types'
import type { Router } from 'vue-router'
import { createAppRouter as createAppRouterFactory } from '@wippy-fe/router'
type OnSubscription = (
pattern: string,
callback: (event: { path?: string, message?: unknown }) => void,
) => void
const routes = [
{ path: '/', name: 'home', component: () => import('../pages/home.vue') },
{ path: '/users', name: 'users', component: () => import('../pages/users.vue') },
{ path: '/:pathMatch(.*)*', name: 'not-found', redirect: '/' },
]
export function createAppRouter(host: HostApi, on: OnSubscription | null, initialPath: string): Router {
return createAppRouterFactory(routes, {
host: host as never,
on: on as never,
initialPath,
})
}

The factory (@wippy-fe/router) encapsulates:

  • createMemoryHistory() (no arg).
  • if (initialPath) history.replace(initialPath) BEFORE createRouter.
  • setLocalRouter(...) registration so the link classifier prefers your routes.
  • router.afterEach(to => host.onRouteChanged(to.fullPath, navId)) with echo-loop suppression.
  • on('@history', ({ path, navId }) => ...) listener with leading-slash normalization.

Rules (apply whether you use the factory or a hand-rolled body):

  • MUST use createMemoryHistory() — never createWebHistory or createHashHistory.
  • MUST call history.replace(initialPath) BEFORE createRouter (incident:2A).
  • MUST register router.afterEach that calls host.onRouteChanged(to.fullPath, navId?) (incident:2B).
  • MUST register on('@history', ...) listener with null-check on on (incident:2C).
  • MUST guard !path inside the @history handler.
  • MUST normalize leading slash on incoming paths (incident:2E).
  • MUST include catch-all route /:pathMatch(.*)* with name: 'not-found'.
  • SHOULD use navId to suppress the round-trip echo of self-initiated navigation.
  • SHOULD call setLocalRouter(...) so the host’s link classifier can fast-path local routes.

Use @wippy-fe/router@^0.0.28 (or later). That release is the canonical home of the factory body, including setLocalRouter registration and the @history listener. There is no acceptable reason for a new module to hand-roll this.

Reference: gold:main/src/constants.ts, gold:main/src/types.ts.

src/constants.ts
import type { InjectionKey } from 'vue'
import type { HostApi, ProxyApiInstance } from './types'
export const HOST_API = Symbol('host_api') as InjectionKey<HostApi>
export const AXIOS_INSTANCE = Symbol('axios') as InjectionKey<ProxyApiInstance['api']>
export const WIPPY_INSTANCE = Symbol('proxy') as InjectionKey<ProxyApiInstance>
src/types.ts
// HostApi / ProxyApiInstance / AppConfig are not named exports of any @wippy-fe package.
// Derive them at the type level from $W (typeof only — no runtime access to the internal
// global). The $W typings ship with @wippy-fe/types-global-proxy (add it to tsconfig "types").
export type HostApi = Awaited<ReturnType<typeof window.$W.host>>
export type ProxyApiInstance = Awaited<ReturnType<typeof window.$W.instance>>
export type WippyConfig = Awaited<ReturnType<typeof window.$W.config>>

Both files are tiny and stable. Copy verbatim into new apps.

src/styles.css — 9-line boilerplate (gold:main/src/styles.css):

html, body {
height: 100%;
margin: 0;
background: transparent;
}
#app {
height: 100%;
}

Rules:

  • MUST set background: transparent so the host’s iframe styles win.
  • MUST NOT set padding/margin on html, body, #app.
  • MUST NOT redefine --p-surface-N, --p-content-background, --p-text-color, --p-primary-color, etc. at module scope. Host owns them.
  • MUST NOT redefine PrimeVue component tokens (.p-dialog, .p-button, etc.) globally.
  • MUST NOT write raw Tailwind color classes (text-red-500, bg-green-100, etc.) for colors that have semantic meaning. Use severity classes (text-danger-500, bg-success-100) instead. (docs:theming.md)
  • DO put per-app theming in YAML meta.config_overrides (or the package.json wippy.configOverrides mirror) — not in source CSS.

src/tailwind.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

tailwind.config.ts (gold:main/tailwind.config.ts):

import themePreset from '@wippy-fe/theme/tailwind.config'
export default {
presets: [themePreset],
content: ['./src/**/*.{vue,ts}', './app.html'],
}

Note: themePreset is a default import, not a named import.

postcss.config.js (CRITICAL):

module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} },
}

Reference: gold:main/tsconfig.json.

{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client", "@wippy-fe/types-global-proxy"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

Rules:

  • MUST target: "ES2020" (canonical). lib MUST include "ES2020", "DOM", "DOM.Iterable". ES2022+ is allowed but ES2020 is the gold-standard floor.
  • MUST module: "ESNext", moduleResolution: "bundler", strict: true, noEmit: true.
  • MUST include vite/client and @wippy-fe/types-global-proxy in types.
  • MUST include src/**/*.ts, src/**/*.vue, and vite.config.ts in include.
  • ALL .vue files MUST use <script setup lang="ts"> at the top. Place the <script setup> block before <template>.
  • MUST type props with TS interface or generic: defineProps<{ foo: string }>(). Untyped object syntax is REJECT.
  • MUST use Composition API. Use ref over reactive where the semantics are equivalent.
  • Use computed properties for derived state; prefer early returns to reduce nesting.
  • File names SHOULD be kebab-case.
  • MUST avoid any; prefer unknown + guards. Each retained any MUST have a justifying comment. Aim ≤ 50 across an entire app.
  • pages/<x>.vue MUST be lazy-loaded in router: () => import('../pages/x.vue').
  • MUST NOT use console.log in production code. console.warn and console.error are allowed for error reporting.
  • npm run type-check MUST exit 0.
  • Tests SHOULD exist for non-trivial logic and MUST pass.
  • Handle all states in UI components: loading, error, empty, success.
  • Prefix event handlers with handle (e.g., handleClick, handleSubmit).

3.9 Subscription cleanup (the leak avoidance pattern)

Section titled “3.9 Subscription cleanup (the leak avoidance pattern)”

instance.on(pattern, cb) returns an unsubscribe function. ALWAYS store it. ALWAYS call it in onUnmounted. (incident:3A-3I, kb:subscription-cleanup.)

Canonical pattern:

import { onMounted, onUnmounted, inject } from 'vue'
import { WIPPY_INSTANCE } from '../constants'
const instance = inject(WIPPY_INSTANCE)!
let unsub: (() => void) | null = null
onMounted(() => {
unsub = instance.on('keeper.task', () => load())
})
onUnmounted(() => {
unsub?.()
})

For multiple subscriptions:

let unsubs: Array<() => void> = []
onMounted(() => {
unsubs.push(instance.on('keeper.session:message', onMessage))
unsubs.push(instance.on('keeper.session:status', onStatus))
})
onUnmounted(() => {
unsubs.forEach(u => u?.())
unsubs = []
})

Anti-patterns (REJECT):

  • instance.on(...) at module top-level (outside onMounted) — leaks for app lifetime (incident:3A).
  • instance.on(...) with return value discarded — silent leak (incident:3B-3G).
  • instance.off(...) — that method does NOT exist; // @ts-ignore won’t save you (incident:3D).
  • Loop-creating subscriptions without storing all unsubs (incident:3C).
  • window.addEventListener('message', ...) without matching removeEventListener in onUnmounted (incident:3I).
  • Raw new EventSource(...) — bypasses host auth bridge; use instance.on(...) for an equivalent server-side topic (incident:3J).
  • window.addEventListener('error' | 'unhandledrejection', ...) / window.onerror — installing window-global error handlers. The host owns global error capture (the host shell’s error handler + the iframe proxy’s errorCapture injection); a child app or web component adding its own duplicates them. This bites web components especially: multiple instances share one realm, so a single error fires every instance’s handler → doubled error reporting and toasts. For your own reporting use instance.logger.captureException(...); for component-scoped failures use Vue’s onErrorCaptured. MUST NOT install global error/unhandledrejection handlers from a WC.

4. Web components — manifest, build, runtime

Section titled “4. Web components — manifest, build, runtime”

Reference: gold:mermaid/package.json.

{
"name": "@example/mermaid",
"version": "1.0.0",
"specification": "wippy-component-1.0",
"title": "Mermaid Diagram",
"description": "...",
"browser": "dist/index.js",
"files": ["dist/", "src/", "package.json"],
"dependencies": {
"@wippy-fe/theme": "^0.0.28",
"@wippy-fe/webcomponent-core": "^0.0.28",
"@wippy-fe/webcomponent-vue": "^0.0.28",
"mermaid": "^11"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"@wippy-fe/proxy": "^0.0.28",
"@wippy-fe/vite-plugin": "^0.0.32",
"typescript": "^5.0.0",
"vite": "^6.0.0",
"vue": "^3.5.0",
"vue-tsc": "^2.0.0"
},
"peerDependencies": {
"@wippy-fe/proxy": "^0.0.28",
"vue": "^3.5.0"
},
"wippy": {
"tagName": "example-mermaid",
"type": "widget",
"description": "...",
"props": {
"type": "object",
"properties": {
"definition": { "type": "string", "default": "", "description": "..." },
"transparent": { "type": "boolean", "default": true, "description": "..." }
}
},
"scripts": {
"build": "build",
"debug": "build:debug",
"test": "lint"
}
},
"scripts": {
"build": "vite build",
"build:debug": "vite build --mode development",
"dev": "vite build --watch",
"lint": "eslint src --ext .ts,.vue",
"lint:fix": "eslint src --ext .ts,.vue --fix"
}
}

Specification & metadata:

  • MUST "specification": "wippy-component-1.0".
  • MUST name follow @<org>/<short> (e.g. @example/mermaid).
  • MUST set top-level "title" and "description".
  • MUST set "browser": "dist/index.js" pointing at the built entry.
  • MUST list dist/, src/, package.json in files.

wippy block (WC-specific):

  • MUST wippy.type: "widget" OR "component" (NOT "page" or "web-component").
  • MUST wippy.tagName (camelCase) — the custom element tag. Must contain a hyphen.
  • MUST wippy.description — a verbose AI/human-readable usage explanation (not a one-line label). Must explain HOW to use the WC: the expected call shape (which props vs children), supported input forms, fallback paths, notable perf characteristics.
  • MUST wippy.props JSON Schema. Every property MUST have type, default, description.
  • MAY wippy.events JSON Schema (omit if no custom events).
  • MUST NOT have wippy.path (no HTML entry).
  • MUST NOT have wippy.icon (no nav presence).
  • MUST NOT have wippy.proxy block (WCs run in host doc, not iframe).
  • MUST set wippy.scripts.build. MAY set debug and test.

Dependency hygiene:

  • dependencies: bundled-into-WC packages. Canonical: @wippy-fe/theme, @wippy-fe/webcomponent-core, @wippy-fe/webcomponent-vue. Plus the WC’s domain libs (e.g. mermaid, chart.js).
  • devDependencies: build toolchain. Canonical: vite, @vitejs/plugin-vue, typescript, vue-tsc, vue for build-time, eslint*, @wippy-fe/proxy (build-time type imports).
  • peerDependencies: only what the host’s import map provides at runtime. Canonical minimum: @wippy-fe/proxy, vue. Add pinia and @iconify/vue if used.

4.2 vite.config.ts (web component, library mode)

Section titled “4.2 vite.config.ts (web component, library mode)”

Reference: gold:mermaid/vite.config.ts.

import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import { wippyComponentPlugin } from '@wippy-fe/vite-plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue(), wippyComponentPlugin()],
build: {
target: 'esnext',
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MermaidDiagram',
fileName: 'index',
formats: ['es'],
},
rollupOptions: {
input: { index: resolve(__dirname, 'src/index.ts') },
external: [
'vue',
'pinia',
'@iconify/vue',
'@wippy-fe/proxy',
],
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: '[name]-[hash][extname]',
},
// preserveEntrySignatures: false ensures define(import.meta.url, …) stays in
// the entry chunk — required so the ?declare-tag= query the host appends reaches
// import.meta.url and registration doesn't silently no-op.
preserveEntrySignatures: false,
},
sourcemap: true,
},
})

Rules:

  • MUST set build.target: 'esnext'.
  • MUST use build.lib library mode with formats: ['es'] (ESM only).
  • MUST set entry (and input.index) to your src/index.ts.
  • MUST set preserveEntrySignatures: false.
  • MUST set entry/chunk/asset file names: [name].js, [name]-[hash].js, [name]-[hash][extname].
  • MUST include wippyComponentPlugin() from @wippy-fe/vite-plugin in plugins so the build emits dist/wippy-meta.json (see §9.3a).
  • MUST externalize what the host provides: vue, pinia, @iconify/vue, @wippy-fe/proxy.
  • MUST bundle (NOT externalize) @wippy-fe/theme, @wippy-fe/webcomponent-core, @wippy-fe/webcomponent-vue, @wippy-fe/pinia-persist (if used), and your domain libs.
  • DO NOT set base (no HTML entry, base is irrelevant).
  • DO NOT set cssCodeSplit (CSS is ?inline-imported into the JS, see §4.3).

Reference: gold:mermaid/src/index.ts.

import { WippyVueElement, define } from '@wippy-fe/webcomponent-vue'
import type { WippyElementConfig, WippyPropsSchema } from '@wippy-fe/webcomponent-vue'
import type { ComponentProps } from './types.ts'
import type { Events } from './constants.ts'
import MermaidDiagram from './app/mermaid-diagram.vue'
import stylesText from './styles.css?inline'
import pkg from '../package.json'
class MermaidElement extends WippyVueElement<ComponentProps, Events> {
static get wippyConfig(): WippyElementConfig<ComponentProps> {
return {
propsSchema: pkg.wippy.props as WippyPropsSchema,
hostCssKeys: ['themeConfigUrl'] as const,
inlineCss: stylesText,
contentTemplate: 'text/vnd.mermaid', // optional; reads text from a child <template data-type="text/vnd.mermaid"> element
}
}
static get vueConfig() {
return {
rootComponent: MermaidDiagram,
}
}
}
export async function webComponent() {
return MermaidElement
}
define(import.meta.url, MermaidElement)

Rules:

  • MUST extend WippyVueElement<ComponentProps, Events> (Vue) or WippyElement (vanilla).
  • MUST implement static get wippyConfig() returning:
    • propsSchema: pkg.wippy.props as WippyPropsSchema — single source of truth from package.json.
    • hostCssKeys: [...] — which host-provided CSS bundles to inject into the shadow root. Use the const names from @wippy-fe/webcomponent-core: themeConfigUrl (theme tokens), iframeCssUrl (layout), primeVueCssUrl (PrimeVue components), markdownCssUrl (markdown). Pick the minimal set you need. (preflightCssUrl is not a member of the HostCssKey union — Tailwind v3 preflight is reachable only imperatively via loadCss(hostCss.preflightCssUrl).)
    • inlineCss: stylesText — your WC-specific CSS imported via ?inline.
    • contentTemplate?: 'text/vnd.foo' — optional MIME type; when set, the WC reads text from a child <template data-type="<mime>"> element, e.g. <example-mermaid><template data-type="text/vnd.mermaid">graph TD; A --> B</template></example-mermaid> (rare).
  • MUST implement static get vueConfig() returning { rootComponent }. Add plugins: [PrimeVuePlugin, ...] if you use PrimeVue components.
  • MUST export async webComponent() factory function so the host loader can call it.
  • MUST define(import.meta.url, ElementClass) at module level.
  • WC root element MUST NOT have padding or margin. Host controls outer spacing.
  • WC MUST use semantic CSS vars for theme-dependent colors: --p-text-color, --p-content-background, --p-content-border-color, --p-text-muted-color, --p-content-hover-background, --p-primary-color.
  • WC MUST NOT use raw --p-surface-N for theme-dependent purposes — that scale is fixed.
  • For derived shades, use color-mix(in srgb, var(--semantic) X%, transparent).
  • For severity colors, use --p-danger-*, --p-success-*, --p-warn-*, --p-info-*, --p-help-*, --p-accent-* — never raw Tailwind color names.
  • Use <Icon icon="tabler:icon-name" /> from @iconify/vue for all icons — never inline <svg> for reusable iconography.
  • Use semantic HTML elements where possible; include proper ARIA roles and attributes on interactive elements.
@import "@wippy-fe/theme/theme-config.css";
.my-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
box-sizing: border-box;
}

The ?inline import in index.ts reads this file as a string and bakes it into the bundle. Combined with hostCssKeys, the shadow root gets host CSS + your WC-specific CSS.

Reference: gold:mermaid/src/constants.ts.

import { useProps, useEvents, usePropsErrors } from '@wippy-fe/webcomponent-vue'
import type { ComponentProps } from './types.ts'
export interface Events {
load: undefined
unload: undefined
error: { message: string, error: unknown }
invalid: { message: string }
}
export const useComponentProps = () => useProps<ComponentProps>()
export const useComponentEvents = () => useEvents<Events>()
export const useComponentPropsErrors = usePropsErrors

Use useComponentProps() and useComponentEvents() in your Vue components instead of plain defineProps / defineEmits — they integrate with the WC’s prop/event marshalling.

Reference: gold:mermaid/tsconfig.json.

{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "@wippy-fe/proxy"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

Differences vs micro frontend apps:

  • types uses @wippy-fe/proxy (not @wippy-fe/types-global-proxy).
  • Adds useDefineForClassFields, noUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch, allowImportingTsExtensions.
  • references to a tsconfig.node.json.

For state that must survive WC unmount or iframe destruction, use @wippy-fe/pinia-persist:

  • persist-key prop values MUST be globally unique across the app.
  • @wippy-fe/pinia-persist MUST be bundled into the WC (NOT external).

Section titled “5.0 Visual-matching escalation (HEAVILY recommended)”

To match a visual design, escalate in this strict order. Do not skip ahead — most “I want it to look like X” work fits at level 1 or 2.

LevelWhatWhere
1 — CSS variablesOverride existing --p-* semantic vars (primary/content/text/severity) and override the surface scale if the brand needs a different neutral palette. Use Playwright + DevTools getComputedStyle(document.documentElement) to enumerate every --p-* already defined; pick from that menu first.Facade theming.global / theming.children, or per-page config_overrides.customization.cssVariables for isolation. NEVER :root in .css files.
2 — customCSS for PrimeVue componentsAdd design-token overrides (--p-button-border-radius, --p-dialog-shadow, etc.) and selector tweaks (.p-button.p-button-xs { … }, .p-accordionheader::before { … }) when level 1 vars don’t reach.Facade theming.global / theming.children, or per-page config_overrides.customization.customCSS. NEVER raw .p-* rules in .css files.
3 — Custom Vue componentsBuild your own component. Reserved for things PrimeVue genuinely doesn’t offer: novel visualizations (force graph, custom chart), domain-specific layouts, interactions outside PrimeVue’s catalog.Vue source in your app.

REJECT level-3 work that could have been done at level 1 or 2. Examples of “should have been level 1/2”:

  • Custom dropdown when <Select> exists.
  • Custom modal when <Dialog> + useDialog exists.
  • Custom toast when <Toast> + useToast exists (or host.toast()).
  • Custom confirm prompt when <ConfirmDialog> + useConfirm exists (or host.confirm()).
  • Custom tooltip when the v-tooltip directive exists.
  • Custom inline button styled to look like a primary button when a styled <Button> exists.

Examples where level 3 IS legitimate:

  • Force graph for dataflow visualization (no PrimeVue equivalent).
  • Token-bar charts (Chart.js wrapper).
  • Markdown/rich-text renderers (markdown-it / shiki wrappers).
  • Code editor (Monaco WC).
  • Domain-specific shell components in managed-layout panels.

5.1 Facade-first: the main way to theme a Wippy app

Section titled “5.1 Facade-first: the main way to theme a Wippy app”

A Wippy module composes itself from ns.dependency entries. One of those is wippy/facade — the dependency that parameterises the host shell (top bar, nav, login page, layout) and ALL the global theming. The facade is where the main customization lives.

Set theming on the facade dependency, not on individual pages. Parameters of interest:

wippy/facade parameterPurpose
app_title, app_name, app_iconbrand identity
custom_cssglobal CSS — reaches the host chrome, view.page iframes, and view.component shadow roots (1.0.43+). Where 95%+ of your styling lives.
css_variablesJSON map of CSS variable overrides (--p-primary, --p-surface-*, brand-specific --k-* tokens, etc.); custom properties inherit into every surface, shadow roots included.
host_custom_csshost-chrome-only CSS (not delivered to children — scope class rules to .wippy-host-app). Use children_custom_css for CSS that should reach those children but not the host chrome.
hide_nav_bar, show_admin, history_mode, session_type, login_pathUX shell behaviour
fe_mode, host_config_layoutmanaged-layout mode + layout declaration

5.1.1 Three levels of override (priority, low → high)

Section titled “5.1.1 Three levels of override (priority, low → high)”
  1. Facade global — set in the host’s wippy/facade ns.dependency parameters. Affects the whole user shell + every page inheriting from the facade. This is where 95%+ of theming should live.
  2. Page configOverrides — YAML registry entry’s meta.config_overrides (canonical) AND/OR package.json wippy.configOverrides (host-less mirror). For cssVariables/customCSS this replaces the inherited theme for the page and cascades to its nested sub-tree (see §2.3). For icons/iconSets it is the canonical additive registration path.
  3. Runtime overlaywindow.__WIPPY_CONFIG_OVERRIDES__ set BEFORE proxy.js loads. Rare; for query-string or feature-flag theming.

See theming.md for the full three-level guide with examples, escalation criteria, and anti-patterns.

5.1.2 Where each override lives — STRICT placement rule

Section titled “5.1.2 Where each override lives — STRICT placement rule”

Mismatched placement is the #1 source of theme drift. The rule:

Override targetWhere it goesWhere it MUST NOT go
Existing host var (--p-*) — change its valueFacade theming, or config_overrides.customization.cssVariables for per-page isolationNEVER :root { --p-* } in src/styles.css
New derived var your project owns — needed for project useSame place as above; compute via color-mix() or var() referencing host varsNEVER :root { --my-* } in src/styles.css
HOST-owned selector override (.p-button, .p-dialog, .p-inputtext, etc.)Facade theming, or config_overrides.customization.customCSS for per-page isolationNEVER raw .p-* rules in src/styles.css
Project-internal class override (.keeper-nav-btn, .search-wrap)src/styles.css (or facade theming if it must reach the host shell)n/a
Project-scoped non-theme constant (chart bar color, fixed spacing tag)src/styles.css with a clear project prefix (e.g., --keeper-chart-bar-*)n/a

Rationale: theme is a host concern; the host’s CSS pipeline composes facade global + per-page customization in a defined order. CSS files inside the bundle ship AFTER the host’s pipeline and shadow it, breaking the override semantics.

REJECT 42b: any :root { --p-* } (or :root { --<other-host-var> }) redefinition in a child app’s .css file. Move to facade theming or per-page config_overrides.customization.cssVariables.

REJECT 43a: any raw .p-<component> rule in a child app’s .css file. Move to facade theming or per-page config_overrides.customization.customCSS.

VariableFlips in dark mode?Use for
--p-text-coloryesbody text
--p-content-backgroundyescontainer / page background
--p-content-border-coloryesborders
--p-text-muted-coloryessecondary text
--p-content-hover-backgroundyeshover states
--p-primary-coloryesprimary action color
--p-surface-0--p-surface-950NO (fixed scale)only as anchors for color-mix(); avoid for theme-dependent UI
--p-primary-500--p-primary-950NO (fixed scale)only when you need a specific primary shade
--p-danger-color, --p-success-color, --p-warn-color, --p-info-color, --p-help-color, --p-accent-coloryesseverity colors. Use these, NOT raw Tailwind names.

Anti-pattern (REJECT):

.card { background: var(--p-surface-100); } /* fixed; doesn't flip */
.card { background: var(--p-primary); } /* invalid token; --p-primary-color is the right one */

Canonical:

.card {
background: var(--p-content-background);
border: 1px solid var(--p-content-border-color);
color: var(--p-text-color);
}
.muted-card {
background: color-mix(in srgb, var(--p-content-background) 92%, var(--p-text-color) 8%);
}
.danger-banner { background: var(--p-danger-color); }
Field in customizationPer-page semantics
cssVariablesREPLACE — your map fully replaces parent’s
customCSSREPLACE — your string fully replaces parent’s
iconsMERGE shallow — additive
iconSetsMERGE per-prefix — additive

AppConfigOverrides top-level:

FieldSemantics
customizationmerged via mergeChildCustomization (above)
axiosDefaultsMERGE shallow
routePrefixREPLACE
apiRoutesREPLACE

The host SUPPORTS @light and @dark keys in cssVariables maps — they compile to @media (prefers-color-scheme: light/dark) { :root { ... } } blocks ONLY. They are NOT a [data-theme] attribute and do not emit any attribute-scoped selector at injection time — binding is solely on the OS color-scheme preference (see createCssVariables in src/shared/util/createStyle.ts).

Example:

cssVariables:
--p-primary-color: var(--p-primary-500)
--kp-bg: var(--p-content-background)
'@light':
--p-content-background: '#ffffff'
--p-text-color: '#18181b'
'@dark':
--p-content-background: '#1c1a19'
--p-text-color: '#fafafa'

An app that toggles themes via document.documentElement.setAttribute('data-theme', ...) will NOT trigger these overrides; the host injects no [data-theme] CSS. To support a manual toggle, document it as a project-specific extension and emit your own [data-theme]-scoped variable block. See also micro-frontend-app-theming.md and host-less-mode.md.

  • For host-wide customCSS, rules MUST be scoped to .wippy-host-app (or your specific page selector) so they don’t leak into child iframes.
  • For per-page overrides, the host already scopes them; you can write top-level selectors.

Icons in Wippy apps follow a single workflow:

  1. Use @iconify/vue <Icon> for ALL icons. Don’t inline <svg> for reusable iconography. Don’t ship icon-font CSS (Tabler-icons-font, Material Icons font). The proxy and the build assume Iconify; mixing systems creates a/b drift.
  2. Prefer permissive packs. All free for commercial use, all available via Iconify:
    • tabler (MIT, ~5,400 icons) — broad UI coverage; the gold-standard default for keeper-class apps.
    • lucide (ISC, ~1,500 icons) — clean line style.
    • phosphor (MIT, ~7,000 icons) — six weight variants.
    • material-symbols (Apache 2.0, ~3,000+ icons) — Google’s modern set.
    • mdi (Apache 2.0, ~7,000 icons) — Material Design Icons community pack.
    • heroicons (MIT, ~300 icons) — Tailwind team’s set, outline + solid.
  3. Don’t use commercial-licensed packs (FontAwesome Pro, etc.) without licence verification per developer seat. Iconify hosts MIT/CC-BY subsets of FontAwesome (fa6-solid/fa6-regular/fa6-brands) — use those instead.
  4. Custom icons — when no permissive pack covers a symbol:
    • Declare them in theming.global.icons / iconSets when shared, or config_overrides.customization.icons for per-page additions — safe because icons MERGES, not replaces.
    • The bootstrap path (config.theming.global?.icons → addCollection({ prefix: 'custom', icons }) plus iconSets) wires them automatically.
    • NEVER call addCollection() from arbitrary application code. The bootstrap path is canonical; everything else fragments the registry.
    • Mint custom icons sparingly. If you find yourself adding more than a dozen, consider whether a permissive pack already has the symbol.
  5. At call sites, prefer <Icon icon="tabler:home" /> over hardcoded SVG. Use Iconify’s pack:name format consistently. Use aria-hidden="true" for decorative icons, aria-label for meaningful ones.

REJECT (5.6.r): any .vue file that registers icons via addCollection() outside app.ts’s canonical bootstrap. REJECT raw <svg> for reusable iconography (one-off illustrations are OK).


In src/constants.ts (micro frontend apps):

KeyProvidesUse
HOST_APIHostApiinject(HOST_API)
WIPPY_INSTANCEProxyApiInstanceinject(WIPPY_INSTANCE)
AXIOS_INSTANCEpre-configured axios (auth + baseURL)inject(AXIOS_INSTANCE)

For web components, import from @wippy-fe/proxy directly:

import { host, api, on } from '@wippy-fe/proxy'

6.2 host.* methods (full reference: Appendix B)

Section titled “6.2 host.* methods (full reference: Appendix B)”
MethodUse
host.toastreplaces PrimeVue ToastService
host.confirmreplaces window.confirm
host.startChatopen a new chat
host.openSessionnavigate to session
host.openArtifactopen artifact
host.setContextset chat context
host.navigatehost-side navigation
host.onRouteChangedreport router change
host.handleErrorreport error
host.formatUrlprepend routePrefix
host.classifyLinkclassify nav target
host.layoutmanaged-layout API (always present; host.layout.snapshot is null outside managed mode)
host.logoutsign out

Rules:

  • MUST use host.toast not PrimeVue ToastService.
  • MUST use host.confirm not window.confirm.
  • MUST use injected useApi() / AXIOS_INSTANCE not raw axios.create().
  • MUST NOT call sendIframeMessage() directly — go through host.* methods.

VERIFY:

Terminal window
grep -r "axios.create" src/ # should = 0
grep -r "window.confirm" src/ # should = 0

6.3 instance.on(pattern, cb) reserved patterns

Section titled “6.3 instance.on(pattern, cb) reserved patterns”
PatternPayloadMeaning
@history{ path?, navId? }host pushed a route
@visibilitybooleaniframe visibility changed
@layout-changeLayoutSnapshotlayout tree changed
@layout-panel-changed{ panelId, ... }single panel changed
@layout-breakpoint{ name, width }breakpoint changed (name = new breakpoint, width = threshold px)
@messagewildcardcatch all WebSocket messages
@state-error{ error, key }state save failed

Custom topics use colon-separated parts; * is wildcard.

host.layout is always present (a LayoutApi object). Outside managed-layout mode, host.layout.snapshot is null and all mutation/bus calls are silent no-ops — gate on host.layout.snapshot (or isManaged from useWippyLayout) before mutating, not a null-check on host.layout.


(Source body in §3.5; this section is verification-focused.)

#RuleREJECT?
7-1createMemoryHistory() (no arg)yes
7-2history.replace(initialPath) BEFORE createRouteryes
7-3router.afterEach(to => host.onRouteChanged(to.fullPath, navId?))yes
7-4on('@history', ...) listener with null guardyes
7-5catch-all /:pathMatch(.*)* route with name: 'not-found'yes
7-6initial path = config.context?.route ?? '/', normalizedyes
7-7leading-slash normalization in @history handleryes
7-8echo-loop suppression via navId tokenshould
7-9setLocalRouter(...) registration for link classifiershould

If you persist last-route to localStorage, EXCLUDE ID-bearing routes (/session/:id, /changes/:id, etc.) — reload-after-delete lands on stale 404s otherwise (incident:2H).

window.addEventListener('message', ...) for cross-iframe messaging: MUST add if (event.source !== window.parent) return origin check and removeEventListener in onUnmounted.


build-<app>-frontend:
cd <path-to-app> && npm install --no-audit --no-fund --prefer-offline && npm run build -- --outDir <dest> --emptyOutDir

Rules:

  • MUST use npm run build -- --outDir <abs-or-relative> --emptyOutDir.
  • MUST NOT use the rm + mkdir + cp dance — pollutes source tree with dist/ and is not atomic.
  • MUST cd into the app dir.
  • Output dir MUST be relative to static/<embed-name> (or wherever the wippy.yaml embed: paths expect).

Each module that publishes a frontend MUST have its own build-<app>-frontend target. Add to publish-* chains.

8.2 make.bat + make.ps1 are required (every module, every time)

Section titled “8.2 make.bat + make.ps1 are required (every module, every time)”

Every module that ships a Makefile MUST also ship make.bat + make.ps1 next to it. No “if your team runs on Windows” carve-out — Wippy modules are written by mixed teams and audited on mixed machines, and the wrapper is small enough that there is no reason not to have it.

  • make.bat is a thin shim that invokes make.ps1 via powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass.
  • make.ps1 mirrors every Makefile target one-for-one — build-*, lint*, publish*, dev, clean, etc. — so the same workflow runs on Linux, macOS, and Windows.
  • Keep make.ps1 pure ASCII (no em-dashes, smart quotes) — Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and corrupts non-ASCII chars on read.

REJECT a module that ships Makefile without matching make.bat + make.ps1.

8.3 Externals + importmap + peerDeps three-way sync

Section titled “8.3 Externals + importmap + peerDeps three-way sync”

Three lists must coexist:

  • vite.config.ts external: array (what NOT to bundle)
  • app.html <script type="importmap"> keys (host-less resolution)
  • package.json peerDependencies (npm install hint)

Rule: every package the app actually imports at runtime MUST be resolvable via the importmap (or by the host’s runtime importmap, when running under a real host).

Mismatch symptom: Failed to resolve module specifier 'pinia' (incident:8A).

VERIFY:

Terminal window
grep -A 25 "external:" vite.config.ts | grep -oE "'[^']+'" | tr -d "'" | sort -u > /tmp/ext
node -e 'const fs=require("fs");const m=fs.readFileSync("app.html","utf8").match(/<script type="importmap">([\s\S]+?)<\/script>/);console.log(Object.keys(JSON.parse(m[1]).imports).join("\n"))' | sort -u > /tmp/imp
diff /tmp/ext /tmp/imp # may show divergences (host's runtime importmap may add more); investigate each line
  • npm run type-check MUST exit 0.
  • npm test MUST pass if any tests exist.
  • npm run build MUST succeed.
  • npm run lint SHOULD exit 0 if you have eslint configured.

Host-less = boot the SPA via a static HTTP server with no real Wippy host running. dev-proxy.js provides a host shim plus a “dev overlay” UI for accepting/editing the config. Host-less mode is the default supported workflow for new apps; the wippyPagePlugin() and importmap+<wippy-loading> patterns described below should be present unless a team has a very good reason to opt out (rare). (See host-less-mode.md for full detail.)

<script type="importmap">
{
"imports": {
"vue": "https://esm.sh/vue@3",
"pinia": "https://esm.sh/pinia",
"vue-router": "https://esm.sh/vue-router@4",
"luxon": "https://esm.sh/luxon",
"@iconify/vue": "https://esm.sh/@iconify/vue",
"axios": "https://esm.sh/axios"
}
}
</script>

Rules:

  • MUST exist in app.html.
  • MUST cover every package the app imports at runtime.
  • SHOULD use esm.sh URLs with major version pin (@3, @4).
  • MUST NOT include @wippy-fe/proxy — the real host or dev-proxy injects it.

9.2 dev-proxy.js + @wippy/scripts data-role

Section titled “9.2 dev-proxy.js + @wippy/scripts data-role”
<script
src="https://web-host.wippy.ai/<release-tag>/dev-proxy.js"
data-role="@wippy/scripts"
></script>

Production CDN form:

<script
src="https://web-host.wippy.ai/<release-tag>/dev-proxy.js"
data-role="@wippy/scripts"
></script>

Rules:

  • MUST have data-role="@wippy/scripts" so the host (when running) can find and replace it.
  • MUST have src= set; raw <script data-role="@wippy/scripts"></script> placeholder is acceptable only when a real host injects the src at boot.
vite.config.ts
import { wippyPagePlugin } from '@wippy-fe/vite-plugin'
export default defineConfig({
plugins: [vue(), wippyPagePlugin(), /* … */],
/* … */
})

The plugin’s transformIndexHtml hook injects the package.json wippy block into the built HTML at the top of <head>:

<script type="application/json" data-role="@wippy/package">
{
"name": "...",
"wippy": { "proxy": { "injections": { ... } }, "configOverrides": { ... } }
}
</script>

Dev-proxy reads the JSON synchronously at boot and seeds:

  • proxy injection defaults from wippy.proxy.injections
  • per-page customization from wippy.configOverrides.customization

so the dev-overlay shows the correct values pre-populated.

Rules:

  • SHOULD include wippyPagePlugin() in vite.config.ts. This is the default for new apps; opt out only with a very good reason (e.g. shipping a host-only bundle that explicitly does not support host-less dev), and document the reason in your project’s CLAUDE.md.
  • MUST install @wippy-fe/vite-plugin@^0.0.32 or later in devDependencies. The 0.0.32 release adds strict build-time validation — bad package.json shape FAILS the build with an actionable error.
  • The plugin is harmless under a real host (the host ignores the @wippy/package script tag).

VERIFY the script is in the built HTML:

Terminal window
npm run build && grep -c 'data-role="@wippy/package"' dist/app.html # SHOULD = 1

9.3a wippyComponentPlugin (web components)

Section titled “9.3a wippyComponentPlugin (web components)”

view.component packages have no HTML entry to inject into, so the page plugin doesn’t apply. Use wippyComponentPlugin() from the same @wippy-fe/vite-plugin package — it’s emit-only:

// vite.config.ts (web component)
import { wippyComponentPlugin } from '@wippy-fe/vite-plugin'
export default defineConfig({
plugins: [vue(), wippyComponentPlugin(), /* … */],
/* … */
})

The component plugin emits dist/wippy-meta.json (the resolved wippy block) only — no HTML transform, no inline script tag.

Rules:

  • MUST be present in every view.component build that ships against wippy/views ≥ 0.5.0.
  • MUST install @wippy-fe/vite-plugin@^0.0.32 or later in devDependencies.

VERIFY the meta file is in the dist:

Terminal window
npm run build && test -f dist/wippy-meta.json && echo "OK: wippy-meta emitted" || echo "MISSING: wippy-meta.json"

9.3b The wippy-meta.json contract + version correlation

Section titled “9.3b The wippy-meta.json contract + version correlation”

The presence of dist/wippy-meta.json next to the served entry is a hard requirement for wippy/views ≥ 0.5.0. The file is the resolved wippy block from package.json as a single JSON object — with every "file://<rel>" string replaced by the referenced file’s UTF-8 contents at build time.

Two endpoints read it:

EndpointWhat it serves
GET /api/public/pages/content/{id}resolved wippy-meta.json next to the served app.html (view.page)
GET /api/public/components/list + /components/by-tag/{tag}resolved wippy-meta.json next to each index.js (view.component). {tag} is the WC’s custom-element tag name (e.g. example-mermaid), resolved via loadByTagName() — not the CDN git release tag.

YAML-first priority: the operator’s _index.yaml registry entry overlays the bundled meta per-field. If meta.tag_name, meta.title, meta.description, meta.props, meta.events, or meta.entry_point is set in YAML, that wins. Otherwise the bundled meta fills in.

Fallback when missing: if wippy-meta.json is absent next to the entry, views falls back to a deprecated YAML-synthesis path AND emits a per-process deprecation warning. Treat the warning as a release-blocker.

wippy/views (BE module)@wippy-fe/vite-plugin (FE plugin)Contract
< 0.4.32< 0.0.31Legacy YAML-synthesis only. wippy-meta.json not consumed even if present.
0.4.320.0.31 (transitional)Plugin emits wippy-meta.json; views still synthesizes from YAML. No package.json validation.
≥ 0.5.00.0.31Views reads wippy-meta.json as source-of-truth with YAML-overlay. No validation.
≥ 0.5.0≥ 0.0.32Canonical contract. Strict build-time package.json validation — plugin throws on missing name/version/wippy block, wrong wippy.type, missing/forbidden wippy.path or wippy.tagName, malformed Custom Element tagName, and file:// basenames that don’t follow the *.do-not-link.<ext> convention.
Future(TBD)YAML-synthesis fallback removed. wippy-meta.json becomes truly mandatory.

Roll-out pattern: ship FE plugin first (0.0.32), then bump BE views (0.5.0).

VERIFY the meta file is in the dist and contains resolved content (no file:// strings):

Terminal window
npm run build
test -f dist/wippy-meta.json && echo "OK: emitted" || echo "REJECT: missing"
grep -c 'file://' dist/wippy-meta.json | { read n; [ "$n" = "0" ] && echo "OK: all file:// resolved" || echo "REJECT: $n unresolved file:// refs"; }
<div id="app">
<wippy-loading title="Loading..."></wippy-loading>
</div>

The <wippy-loading> element is auto-registered by dev-proxy (and by the real host) before the body parses.

REJECT custom hand-rolled spinners.

9.5 base: ” (relative paths) — REJECT if hardcoded

Section titled “9.5 base: ” (relative paths) — REJECT if hardcoded”

base: '' in vite.config produces relative ./app.js, ./assets/... paths in the built HTML/JS. The bundle is portable to any URL prefix and any mount point — host-managed, host-less dev, or moved between projects.

A hardcoded absolute base (e.g. base: '/app/keeper/') ties the bundle to a specific mount point and breaks portability. This is a 100% REJECT — there is no acceptable “documented exception”. A child app must not assume its own URL prefix; the prefix is a host-side meta.url + meta.base_path concern, and the host injects the appropriate <base> into the HTML at serve time. Set base: '' and let the host do its job.

  • <wippy-dev-overlay> shadow-DOM web component, FAB in bottom-right.
  • Manual mode blocks boot until “Accept config” clicked.
  • Auto-accept via localStorage['@wippy-dev/auto-accept'] === 'true'.
  • Stored config: localStorage['@wippy-dev/config'], localStorage['@wippy-dev/proxy-config'].
  • Reset clears all @wippy-dev/* keys + reloads.

Run these before submitting. Each maps to a section.

Terminal window
# 10.1.1 — wippy.specification + wippy.type
node -e 'const p=require("./package.json"); if(p.specification!=="wippy-component-1.0") throw new Error("bad specification"); const t=p.wippy?.type; if(t!=="page" && t!=="widget" && t!=="component") throw new Error("bad wippy.type"); console.log("OK")'
# 10.1.2 — markdown injection if app uses markdown (micro frontend apps only)
grep -A 10 'wippy.proxy.injections.css' package.json | grep -c '"markdown": true' # 1 if uses markdown, 0 otherwise
# 10.1.3 — base relative
grep -E "base:\s*['\"]" vite.config.ts # must show: base: ''
# 10.1.4 — wippyPagePlugin present (host-less default)
grep -c "wippyPagePlugin" vite.config.ts # SHOULD = 1
# 10.1.5 — type-check
npx vue-tsc --build --force || npx vue-tsc --noEmit # exit 0

10.2 Router & host integration (micro frontend apps)

Section titled “10.2 Router & host integration (micro frontend apps)”
Terminal window
# 10.2.1 — createMemoryHistory only
grep -c "createMemoryHistory" src/router/index.ts # >= 1
grep -c "createWebHistory" src/router/index.ts # 0
# 10.2.2 — using @wippy-fe/router factory (canonical)
grep -c "from '@wippy-fe/router'" src/router/index.ts # 1 if canonical
# 10.2.3 — afterEach calls onRouteChanged (if not using factory)
grep -A 10 "router.afterEach" src/router/index.ts | grep -c "host.onRouteChanged" # >= 1 if hand-rolled
# 10.2.4 — @history listener
grep -c "@history" src/router/index.ts # >= 1
# 10.2.5 — catch-all + name
grep -E "pathMatch.*not-found|name:.*not-found" src/router/index.ts # >= 1
Terminal window
# 10.3.1 — no module-scope instance.on
grep -n "^instance\.on" src/**/*.{ts,vue} # should be empty
# 10.3.2 — every instance.on has matching onUnmounted in same file
for f in $(grep -rl "instance\.on(" src --include="*.vue"); do
o=$(grep -c "instance\.on(" "$f"); u=$(grep -c "onUnmounted" "$f")
[ "$o" -gt 0 ] && [ "$u" -eq 0 ] && echo "FAIL: $f"
done
# 10.3.3 — no instance.off
grep -r "instance\.off" src # should be empty
# 10.3.4 — no raw axios.create
grep -r "axios.create" src # should be empty
# 10.3.5 — no raw EventSource
grep -r "new EventSource" src # should be empty
# 10.3.6 — no window.confirm
grep -r "window\.confirm" src # should be empty
# 10.3.7 — addEventListener pairs with removeEventListener
for f in $(grep -rl "addEventListener" src --include="*.vue"); do
a=$(grep -c "addEventListener" "$f"); r=$(grep -c "removeEventListener" "$f")
[ "$a" -ne "$r" ] && echo "FAIL: $f add=$a remove=$r"
done
Terminal window
# 10.4.1 — no theme-dependent --p-surface-N use (informational; document exceptions)
grep -r "var(--p-surface-[0-9]" src/**/*.vue | wc -l # aim for 0; project minimum acceptable
# 10.4.2 — no invalid --p-primary token
grep -rE "var\(--p-primary\)[^-]" src/**/*.vue | wc -l # must = 0
# 10.4.3 — no module-level redefinition of host tokens
grep -rE ":root\s*\{[^}]*--p-(content-background|text-color|primary)" src/styles.css # must = 0
Terminal window
# 10.5.1 — every .vue starts with <script setup lang="ts">
find src -name "*.vue" | while read f; do
grep -q '<script setup lang="ts">' "$f" || echo "FAIL: $f"
done
# 10.5.2 — count any-casts (informational; aim ≤ 50)
grep -rE ":\s*any|as\s*any" src | wc -l
# 10.5.3 — no console.log
grep -rE "console\.log" src # should be empty
Terminal window
npm run build
# checks on dist/app.html
grep -c 'data-role="@wippy/scripts"' dist/app.html # must = 1
grep -c 'data-role="@wippy/package"' dist/app.html # SHOULD = 1 (when wippyPagePlugin enabled)
grep -c '<script type="importmap">' dist/app.html # must = 1
grep -c '<wippy-loading' dist/app.html # must >= 1
grep 'src="./app.js"' dist/app.html # match (relative path)
# live boot test: serve dist/ via http-server with dev-proxy.js from the Wippy Web Host CDN.
# Browser: see <wippy-loading>, then dev-overlay FAB → Accept → app boots.
Section titled “10.7 Browser-emulator dark/light + contrast check (recommended)”

Static checks catch token misuse but not actual rendering. Before shipping any non-trivial visual change, verify the app in a browser emulator (Playwright or equivalent) under both dark and light theme, and check contrast on both.

Recommended Playwright recipe:

// dark + light snapshot pair
for (const scheme of ['dark', 'light']) {
await page.emulateMedia({ colorScheme: scheme })
await page.goto('http://localhost:<port>/<route>')
await page.waitForLoadState('networkidle')
await page.screenshot({ path: `.local/snap-${scheme}.png`, fullPage: true })
}
// contrast smoke — flag any element with computed text vs background
// contrast ratio < 4.5 (WCAG AA body) or < 3 (WCAG AA large text).
// Use `axe-core`, `@axe-core/playwright`, or `pa11y` for a real audit.

Verify visually: text legibility on both schemes, no light-only assumptions (white-on-white panels), severity colours readable on both, hover/active states visible in both.

REJECT a page that renders correctly in dark mode but is broken in light mode (or vice versa).

Terminal window
npx vue-tsc --build --force && \
npm test --if-present -- --run && \
npm run build && \
grep -c 'data-role="@wippy/scripts"' dist/app.html && \
grep -c '<wippy-loading' dist/app.html && \
echo "ALL GATES PASS"

REJECT a submission if any of the following are true.

  1. package.json.specification is not "wippy-component-1.0".
  2. wippy.type is not "page" (micro frontend apps), "widget" (web components, historical), or "component" (web components, newer alias accepted by the vite plugin validator).
  3. Micro Frontend App: wippy.path does not point to the actual built artifact (e.g. dist/app.html).
  4. WC: wippy.tagName is missing or does not contain a hyphen.
  5. WC: wippy.props is missing OR has properties without type/default/description. 5a. WC: wippy.description is missing OR is a one-line label. It MUST be a verbose usage explanation — see §4.1 / §2.2.
  6. peerDependencies is missing @wippy-fe/proxy and vue (both kinds).
  7. Micro Frontend App: peerDependencies is missing vue-router, axios, or @iconify/vue if the app imports them.
  8. WC: dependencies is missing @wippy-fe/webcomponent-core or @wippy-fe/webcomponent-vue.
  1. Micro Frontend App: base is not ''. Hardcoded absolute base (e.g. /app/keeper/) is REJECT with no documented-exception escape hatch — see §9.5.
  2. Micro Frontend App: build.rollupOptions.external does not include vue and @wippy-fe/proxy.
  3. WC: build.lib library mode is missing OR formats: ['es'] is missing.
  4. WC: build.rollupOptions.preserveEntrySignatures is not false.
  5. WC: @wippy-fe/proxy is not in externals (must be external, never bundled).
  6. WC: @wippy-fe/theme, @wippy-fe/webcomponent-core, @wippy-fe/webcomponent-vue are listed in externals (must be bundled, never external).
  1. strict is not true.
  2. target is older than ES2020.
  3. Micro Frontend App: types is missing vite/client or @wippy-fe/types-global-proxy.
  4. WC: types is missing vite/client or @wippy-fe/proxy.
  5. vue-tsc does not exit 0.
  1. No <script data-role="@wippy/scripts">.
  2. No <script type="importmap"> covering host-provided packages the app imports.
  3. No <div id="app"> mount.
  4. No <wippy-loading> (uses custom spinner instead).
  1. app.ts obtains config / host / api / on from anything other than sync @wippy-fe/proxy imports (e.g. reaches for window.$W / getWippyApi, or awaits to obtain a getter).
  2. app.ts does not provide HOST_API, AXIOS_INSTANCE, WIPPY_INSTANCE injections.
  3. app.ts resolves initial path from a non-canonical source (must be config.context?.route ?? '/', with documented project-specific extensions).
  1. Uses createWebHistory or createHashHistory (must be createMemoryHistory).
  2. Calls history.replace(initialPath) AFTER createRouter instead of before.
  3. router.afterEach does not call host.onRouteChanged(to.fullPath, navId?).
  4. No on('@history', ...) listener.
  5. No catch-all /:pathMatch(.*)* route OR catch-all has no name.
  6. @history handler does not normalize leading slash on incoming paths.
  1. Any instance.on(...) at module scope (outside onMounted).
  2. Any instance.on(...) whose return value is discarded.
  3. Any onUnmounted block missing the matching unsubscribe call(s).
  4. Any reference to instance.off(...) (the method does not exist).
  5. Any window.addEventListener('message', ...) without matching removeEventListener in onUnmounted.
  6. Any raw new EventSource(...).
  7. Any raw axios.create(...).
  8. Any window.confirm(...).
  1. html, body, #app set non-zero padding/margin.
  2. styles.css redefines --p-content-background, --p-text-color, --p-content-border-color, --p-primary-color, or --p-surface-* at module scope. 42b. ANY child-app .css file contains :root { --p-* … } or :root { --<other-host-var> … } redefinition (§5.1.2). Move to facade theming or per-page config_overrides.customization.cssVariables.
  3. PrimeVue component tokens are restyled with !important in styles.css. 43a. ANY child-app .css file contains a raw .p-<component> (e.g. .p-button, .p-dialog, .p-inputtext) selector rule (§5.1.2). Move to facade theming or per-page config_overrides.customization.customCSS.
  4. Any Vue file uses var(--p-primary) (invalid token; must be --p-primary-color).
  5. Any Vue file uses raw Tailwind color names (bg-red-*, bg-sky-*, etc.) for semantic meaning.
  6. Any hardcoded hex/rgb in Vue source for semantic colors (use --p-danger-* etc., or color-mix()). 46a. Page renders correctly in only one of prefers-color-scheme: dark / light. Verify in a browser emulator before claiming the page is shippable (§10.7). 46b. Custom Vue component reimplements something PrimeVue already ships (e.g. custom dropdown when <Select> exists, custom modal when <Dialog> exists, custom toast when <Toast> exists, custom confirm when <ConfirmDialog> exists). Use the PrimeVue component, possibly with §5.0 level-1 / level-2 customization.

46c. Reusable iconography uses raw <svg> instead of <Icon> from @iconify/vue. 46d. Custom icon collection registered via addCollection() outside the canonical app.ts bootstrap path. 46e. Icon font CSS (Tabler-icons-font, Material Icons font, FontAwesome CSS, etc.) shipped alongside Iconify.

  1. .vue file does not use <script setup lang="ts">.
  2. defineProps uses untyped object syntax instead of TS generic.
  3. Production code contains console.log.
  1. WC root element has padding or margin.
  2. WC does not extend WippyVueElement or WippyElement.
  3. WC static get wippyConfig() is missing OR doesn’t return propsSchema/hostCssKeys/inlineCss.
  4. WC static get vueConfig() is missing OR doesn’t return rootComponent.
  5. WC entry does not call define(import.meta.url, ElementClass) at module level.
  6. WC has wippy.path (page-only field) or wippy.proxy (page-only block).

55a. Module ships Makefile without matching make.bat + make.ps1 wrappers (§8.2).

  1. Icon-only <button> lacks aria-label.
  2. Clickable <div @click> lacks role="button" + aria-label + keyboard handler (should be <button>).

When you knowingly diverge from canonical, document it in your project’s CLAUDE.md. Real examples:

DeviationReasonAcceptable?
Triple-source initial path (config.context.route → parent window URL → localStorage)Full-page reload recovery on apps that reload outside the host’s normal navigationYES
createPinia() registered but no defineStore yetReserved for upcoming storesBORDERLINE — clean up if no stores planned
No PrimeVue plugin in appApp uses raw HTML buttons + custom CSSYES (intentional UI choice)
Custom inlineCssPlugin in vite.configSingle-file deploymentYES
Raw localStorage.* for ad-hoc persistence keysAvoid pinia overhead for one or two keysDISCOURAGED. Prefer the canonical stack: facade module owns theme; @wippy-fe/router factory owns route restoration; @wippy-fe/pinia-persist owns durable state. Raw localStorage should be a measured exception in a leaf component, not the default.
Skip wippyPagePlugin()Want a very-good-reason: e.g. shipping a host-only bundle that explicitly does not support host-less devRARELY YES. Default is to include it. Document the very-good reason in CLAUDE.md.

13.1 Wippy CLI port already in use (:8080, :5173)

Section titled “13.1 Wippy CLI port already in use (:8080, :5173)”

Symptom: EADDRINUSE when starting ./wippy.exe run -c.

Fix: override the gateway port via the -o flag. Examples:

Terminal window
# Pick a different port for the wippy gateway:
./wippy.exe run -c -o app:gateway:addr=:8086
# Combine multiple overrides — gateway port + facade fe_facade_url default:
./wippy.exe run -c -o app:gateway:addr=:9000 -o wippy.facade:fe_facade_url:default=http://localhost:5173

The -o <module>:<entry>:<property>=<value> form patches the registry entry’s property at boot — no source edits required. To set a requirement default (rather than overriding a configured value), use the :default suffix on the property name.

For Vite (5173), kill the existing process or choose a different port via vite --port <n>.

Symptom: migration on first run succeeds, on second run fails with “table already exists”.

Fix: delete .wippy/app.db* between fresh runs. For test harnesses, prefer :memory:.

Symptom: npm install fails with ERESOLVE after bumping @wippy-fe/proxy (e.g. 0.0.12 → 0.0.27).

Fix: delete node_modules/ AND package-lock.json, then npm install.

Symptom: Failed to resolve module specifier 'pinia'.

Fix: keep peerDependencies, vite externals, and importmap in sync. Verification recipe in §10.1 / §8.3.


The checklist’s REJECT rules were validated against the two gold standards.

app-template/frontend/applications/main/ — micro-frontend-app gold standard

Section titled “app-template/frontend/applications/main/ — micro-frontend-app gold standard”
RuleStatusNotes
1 (specification)PASSwippy-component-1.0
2 (wippy.type)PASS"page"
3 (wippy.path)PASSdist/app.html
6 (peerDeps include @wippy-fe/proxy + vue)PASSboth present
7 (peerDeps include vue-router, axios, @iconify/vue)PASSall present
9 (base: ”)PASSbase: '' in vite.config.ts:15
10 (vite externals include vue + @wippy-fe/proxy)PASSboth present
15 (tsconfig strict)PASSstrict: true
16 (target ≥ ES2020)PASStarget: "ES2020" (canonical floor)
17 (types include vite/client + types-global-proxy)PASSboth listed
19 (vue-tsc exit 0)not-run(live check)
20 (@wippy/scripts data-role)PASSpresent
21 (importmap exists)PASScovers vue, pinia, vue-router, luxon, @iconify/vue, axios, @wippy-fe/markdown-iframe
22 (<div id="app">)PASSpresent
23 (<wippy-loading>)PASSpresent
24 (await all 4 $W calls)PASSconfirmed in src/app.ts:16-19
25 (provide HOST_API/AXIOS/WIPPY)PASSconfirmed
26 (initial path resolution)PASSconfig.context?.route || '/' then leading-slash normalize
27-32 (router rules)PASS via @wippy-fe/router factorygold uses canonical factory
41 (no padding/margin on html/body)PASSonly margin: 0; height: 100%
42 (no host token redefinition in styles.css)PASS9-line boilerplate
47 (<script setup lang="ts"> everywhere)PASS-by-conventionnot exhaustively grepped
48 (typed defineProps)PASS-by-convention

No REJECTs. Gold standard passes the entire checklist.

Note on §9.3 wippyPagePlugin(): gold standard predates this enhancement and does NOT yet include the plugin. New apps SHOULD include it (default for host-less mode support).

app-template/frontend/web-components/mermaid/ — WC gold standard

Section titled “app-template/frontend/web-components/mermaid/ — WC gold standard”
RuleStatusNotes
1 (specification)PASSwippy-component-1.0
2 (wippy.type)PASS"widget"
4 (wippy.tagName has hyphen)PASSexample-mermaid
5 (wippy.props well-formed)PASSboth props have type/default/description
6 (peerDeps include @wippy-fe/proxy + vue)PASSboth present
8 (deps include @wippy-fe/webcomponent-core + -vue)PASSboth present
11 (build.lib + formats: [‘es’])PASSconfirmed
12 (preserveEntrySignatures: false)PASSconfirmed with comment explaining why
13 (@wippy-fe/proxy in externals)PASSpresent
14 (@wippy-fe/theme/-core/-vue NOT in externals)PASSnone in externals (correctly bundled)
15-18 (tsconfig + types)PASSuses @wippy-fe/proxy (correct for WCs)
50 (no root padding/margin)PASS-by-convention.mermaid-container uses width:100%; height:100%; box-sizing:border-box
51 (extends WippyVueElement)PASSclass MermaidElement extends WippyVueElement<ComponentProps, Events>
52 (wippyConfig static getter)PASSreturns propsSchema/hostCssKeys/inlineCss/contentTemplate
53 (vueConfig static getter)PASSreturns rootComponent
54 (define(import.meta.url, …) at module level)PASSdefine(import.meta.url, MermaidElement)
55 (no wippy.path or wippy.proxy)PASSneither present

No REJECTs. Gold standard passes the entire checklist.

If this checklist’s rules ever flag a gold standard as REJECT, the rule is wrong — not the gold standard. Update this doc; do NOT change the gold standard.


Appendix A — Window globals & DOM markers

Section titled “Appendix A — Window globals & DOM markers”

Constants exported from @wippy-fe/shared:

ConstantValueWho writesWho reads
GLOBAL_CONFIG_VAR__WIPPY_APP_CONFIG__host entry pointchild app, dev-proxy
GLOBAL_PROXY_CONFIG_VAR__WIPPY_PROXY_CONFIG__hostdev-proxy boot
GLOBAL_API_PROVIDER__WIPPY_APP_API__hostchild app
GLOBAL_WEB_COMPONENT_CACHE__WIPPY_WEB_COMPONENT_CACHE__wc loaderwc loader
WIPPY_SCRIPTS_DATA_ROLE@wippy/scriptsauthor (in app.html)host injects scripts adjacent to it
WIPPY_PACKAGE_DATA_ROLE@wippy/package@wippy-fe/vite-plugin (build time)dev-proxy boot

Authors should NEVER reference window.__WIPPY_* directly — always import from @wippy-fe/shared (constants) or use @wippy-fe/proxy API helpers.


interface HostApi {
toast(opts: ToastMessageOptions): void
confirm(opts: LimitedConfirmationOptions): Promise<boolean>
startChat(token: string, opts?: { sidebar?: boolean }): void
openSession(uuid: string, opts?: { sidebar?: boolean }): void
openArtifact(uuid: string, opts?: { target: 'modal' | 'sidebar' }): void
setContext(
context: Record<string, unknown>,
sessionUUID?: string,
source?: { type: string; uuid: string; instanceUUID?: string },
): void
navigate(url: string): void
onRouteChanged(internalRoute: string, navId?: number): void
handleError(code: 'auth-expired' | 'other', error: Record<string, unknown>): void
formatUrl(relativeUrl: string): string
classifyLink(href: string | null | undefined): LinkClassification
layout: LayoutApi
logout(): void
}

LayoutApi:

interface LayoutApi {
readonly snapshot: LayoutSnapshot | null
resizePanel(panelId: string, size: SizeValue): void
collapsePanel(panelId: string): void
expandPanel(panelId: string): void
openDrawer(panelId: string): void
closeDrawer(panelId: string): void
toggleDrawer(panelId: string): void
movePanel(panelId: string, target: PanelTarget): void
removePanel(panelId: string): void
updatePanel(panelId: string, def: Partial<HostPanelDef>): void
openModal(id: string, def: HostModalDef): void
closeModal(modalId: string): void
addFloating(id: string, def: HostFloatingDef): void
removeFloating(floatingId: string): void
broadcast(channel: string, payload: unknown): void
send(target: string, channel: string, payload: unknown): void
on(channel: string, handler: (env: BroadcastEnvelope) => void): () => void
}

host.layout is always present (a LayoutApi object). Outside managed-layout mode, host.layout.snapshot is null and all mutation/bus calls are silent no-ops — gate on host.layout.snapshot (or isManaged from useWippyLayout) before mutating, not a null-check on host.layout.


Appendix C — ProxyConfig.injections reference

Section titled “Appendix C — ProxyConfig.injections reference”
interface ProxyConfig {
enabled: boolean
injections: {
css: {
themeConfig: boolean // semantic CSS vars
iframe: boolean // iframe layout/containment
primevue: boolean // PrimeVue component CSS
markdown: boolean // markdown typography
customCss: boolean // theming.global.customCSS
customVariables: boolean // theming.global.cssVariables → :root
}
tailwindConfig: boolean // window.tailwind.config
resizeObserver: boolean // report iframe size
preventLinkClicks: boolean // intercept <a> clicks
iconifyIcons: boolean // register iconify-icon WC + icons
refreshWhenVisible: boolean // reload on @visibility(true)
historyPolyfill: boolean // history() stub (always installed)
errorCapture: boolean // unhandledrejection + onerror → host
}
}

The YAML registry-entry meta.proxy: block uses the same camelCase flags under an injections wrapper (deep-merged over the bundled wippy.proxy):

meta:
type: view.page
# ...
proxy:
enabled: true
injections:
css:
themeConfig: true
iframe: true
primevue: true
customCss: true
customVariables: true
tailwindConfig: true
iconifyIcons: true

CSS injections are applied in this order: themeConfig → iframe → primevue → markdown → customVariables → customCss. A MutationObserver pins the customCss <style> tag to the end of <head> to preserve precedence.