From 326043a79e78820f19623be573fa20773d9364ff Mon Sep 17 00:00:00 2001
From: Jesse Wright <63333554+jeswr@users.noreply.github.com>
Date: Fri, 17 Jul 2026 12:28:14 +0000
Subject: [PATCH] feat(solid-showcase-kit): disclaimer/branding/trust pack
(sm-75)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New package @jeswr/solid-showcase-kit — the generic, domain-agnostic
trust/disclaimer layer for walking people through a multi-stakeholder
pod-data interaction flow:
- zod contracts the kit OWNS (schema-first, types via z.infer):
bannedMarkSchema / themeSpecSchema / brandingConfigSchema
(convener/description reject blank input; no built-in domain presets).
- createDisclaimerPack(branding): frozen copy factories for banner
(full/compact), footer legal line, four-paragraph consent interstitial,
demo metadata (noindex), illustrative tag, and demo-field hint.
domainNegations[0] is the primary negation (banner/footer, with a
"Nothing here is…" → "Not…" compact derivation); later entries feed the
interstitial's simulation paragraph. Interstitial paragraphs are fresh
deep-frozen copies per call — safety copy cannot be mutated away.
- Ten components (AppShell, ConceptDemoBanner, ConsentInterstitial,
HonestyPanel, IllustrativeFigure, DemoLockedField, CredentialCard,
ReceiptCard, StatCard) behind ShowcaseTrustProvider (pack + theme via
context, explicit pack prop override, fail-closed without either).
- Themes: paletteTokens / themeCssProperties / themeFromSpec(spec,
modelledOn); palette DATA stays with consumers.
- ./testing subpath (node-only): a bright-line never-render scanner
MECHANISM (content + file-path + directory-name checks; cross-line
whitespace normalisation; symlinks in scanned trees rejected) with NO
built-in banned-marks roster — consumers supply their own — plus
disclaimerAssertions e2e helpers. Root export is browser-safe
(unit-enforced: no node builtins outside src/testing).
Invariants in code, not config: AppShell renders the banner adjacent and
unconditionally; fixed safety copy is non-removable (negations only
extend it); DemoLockedField's hint always renders; the interstitial keeps
its four-paragraph structure. The kit's golden tests pin the exact
strings a fictional reference branding produces (proving the
parameterisation); each real consumer keeps its own literal golden
vectors and banned-marks roster in its own repository.
Model: claude-fable-5
Co-Authored-By: Claude Fable 5
---
packages/solid-showcase-kit/LICENSE | 21 ++
packages/solid-showcase-kit/README.md | 75 ++++
packages/solid-showcase-kit/SKILL.md | 105 ++++++
packages/solid-showcase-kit/package.json | 57 +++
packages/solid-showcase-kit/src/app-shell.tsx | 99 ++++++
packages/solid-showcase-kit/src/branding.ts | 76 ++++
.../src/concept-demo-banner.tsx | 110 ++++++
.../src/consent-interstitial.tsx | 208 +++++++++++
.../src/credential-card.tsx | 77 +++++
.../src/demo-locked-field.tsx | 64 ++++
.../solid-showcase-kit/src/disclaimers.ts | 327 ++++++++++++++++++
.../solid-showcase-kit/src/honesty-panel.tsx | 45 +++
.../src/illustrative-figure.tsx | 35 ++
packages/solid-showcase-kit/src/index.ts | 62 ++++
.../solid-showcase-kit/src/receipt-card.tsx | 62 ++++
packages/solid-showcase-kit/src/stat-card.tsx | 30 ++
.../src/testing/assertions.ts | 72 ++++
.../solid-showcase-kit/src/testing/index.ts | 19 +
.../src/testing/insignia.ts | 259 ++++++++++++++
packages/solid-showcase-kit/src/themes.ts | 103 ++++++
.../solid-showcase-kit/src/trust-context.tsx | 55 +++
.../solid-showcase-kit/test/banner.test.tsx | 69 ++++
.../test/browser-safety.test.ts | 46 +++
.../test/components.test.tsx | 168 +++++++++
.../test/disclaimers.test.ts | 163 +++++++++
.../solid-showcase-kit/test/insignia.test.ts | 155 +++++++++
.../test/interstitial.test.tsx | 111 ++++++
.../test/safety-invariants.test.tsx | 115 ++++++
.../test/support/fixtures.ts | 46 +++
.../solid-showcase-kit/test/themes.test.ts | 62 ++++
.../solid-showcase-kit/tsconfig.build.json | 14 +
packages/solid-showcase-kit/tsconfig.json | 23 ++
packages/solid-showcase-kit/vitest.config.ts | 13 +
pnpm-lock.yaml | 111 +++++-
suite.json | 1 +
35 files changed, 3046 insertions(+), 12 deletions(-)
create mode 100644 packages/solid-showcase-kit/LICENSE
create mode 100644 packages/solid-showcase-kit/README.md
create mode 100644 packages/solid-showcase-kit/SKILL.md
create mode 100644 packages/solid-showcase-kit/package.json
create mode 100644 packages/solid-showcase-kit/src/app-shell.tsx
create mode 100644 packages/solid-showcase-kit/src/branding.ts
create mode 100644 packages/solid-showcase-kit/src/concept-demo-banner.tsx
create mode 100644 packages/solid-showcase-kit/src/consent-interstitial.tsx
create mode 100644 packages/solid-showcase-kit/src/credential-card.tsx
create mode 100644 packages/solid-showcase-kit/src/demo-locked-field.tsx
create mode 100644 packages/solid-showcase-kit/src/disclaimers.ts
create mode 100644 packages/solid-showcase-kit/src/honesty-panel.tsx
create mode 100644 packages/solid-showcase-kit/src/illustrative-figure.tsx
create mode 100644 packages/solid-showcase-kit/src/index.ts
create mode 100644 packages/solid-showcase-kit/src/receipt-card.tsx
create mode 100644 packages/solid-showcase-kit/src/stat-card.tsx
create mode 100644 packages/solid-showcase-kit/src/testing/assertions.ts
create mode 100644 packages/solid-showcase-kit/src/testing/index.ts
create mode 100644 packages/solid-showcase-kit/src/testing/insignia.ts
create mode 100644 packages/solid-showcase-kit/src/themes.ts
create mode 100644 packages/solid-showcase-kit/src/trust-context.tsx
create mode 100644 packages/solid-showcase-kit/test/banner.test.tsx
create mode 100644 packages/solid-showcase-kit/test/browser-safety.test.ts
create mode 100644 packages/solid-showcase-kit/test/components.test.tsx
create mode 100644 packages/solid-showcase-kit/test/disclaimers.test.ts
create mode 100644 packages/solid-showcase-kit/test/insignia.test.ts
create mode 100644 packages/solid-showcase-kit/test/interstitial.test.tsx
create mode 100644 packages/solid-showcase-kit/test/safety-invariants.test.tsx
create mode 100644 packages/solid-showcase-kit/test/support/fixtures.ts
create mode 100644 packages/solid-showcase-kit/test/themes.test.ts
create mode 100644 packages/solid-showcase-kit/tsconfig.build.json
create mode 100644 packages/solid-showcase-kit/tsconfig.json
create mode 100644 packages/solid-showcase-kit/vitest.config.ts
diff --git a/packages/solid-showcase-kit/LICENSE b/packages/solid-showcase-kit/LICENSE
new file mode 100644
index 000000000..2f396fc37
--- /dev/null
+++ b/packages/solid-showcase-kit/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Jesse Wright
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/solid-showcase-kit/README.md b/packages/solid-showcase-kit/README.md
new file mode 100644
index 000000000..e9f5f3c4d
--- /dev/null
+++ b/packages/solid-showcase-kit/README.md
@@ -0,0 +1,75 @@
+
+
+# @jeswr/solid-showcase-kit
+
+De-branded disclaimer/branding/trust pack for Solid concept demonstrations. One validated
+branding config drives every trust surface a showcase renders: the unremovable
+concept-demo banner, the consent interstitial, the footer legal line, demo metadata
+(noindex), illustrative-figure tags, demo-locked form fields, and honesty panels — plus
+per-organisation theming and, under `./testing`, an insignia scanner and e2e assertion
+helpers.
+
+The kit's opinion is that a demo must never be mistakable for the real organisations it
+is modelled on. The safety copy is therefore **non-removable by construction**: no prop or
+configuration path can hide the banner, drop the "all data simulated" / "do not enter real
+personal information" copy, or break the interstitial's four-paragraph structure. The
+branding config only substitutes the convener, the domain-specific offer negations, and
+the purpose clause.
+
+## Usage
+
+```tsx
+import {
+ AppShell,
+ createDisclaimerPack,
+ ShowcaseTrustProvider,
+ themeFromSpec,
+} from "@jeswr/solid-showcase-kit";
+
+const pack = createDisclaimerPack({
+ convener: "Open Data Institute",
+ description: "show how a vehicle-hire journey could work on Solid personal data stores",
+ domainNegations: ["Nothing here is an offer of hire or insurance."],
+});
+const theme = themeFromSpec(
+ { hue: 210, primary: "oklch(0.42 0.08 210)", accent: "oklch(0.7 0.11 85)", role: "hire desk" },
+ "Acme Hire",
+);
+
+export default function Layout({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+```
+
+Components: `AppShell`, `ConceptDemoBanner`, `ConsentInterstitial`, `HonestyPanel`,
+`IllustrativeFigure`, `DemoLockedField`, `CredentialCard`, `ReceiptCard`, `StatCard`.
+
+Contracts (zod, schema-first): `brandingConfigSchema`, `themeSpecSchema`,
+`bannedMarkSchema` with inferred types `BrandingConfig`, `ThemeSpec`, `BannedMark`.
+
+## `@jeswr/solid-showcase-kit/testing`
+
+Node-only helpers for repo gates and e2e suites: `insigniaFindings` /
+`insigniaPathFindings` / `scanInsigniaTree` (a bright-line never-render scanner — content,
+file-name, and directory-name checks with no allowlist and no inline suppression), and
+`disclaimerAssertions` (runner-agnostic selectors and expected copy for
+banner/footer/interstitial assertions).
+
+The scanner is a MECHANISM only: the kit ships no banned-marks roster. Each consumer
+supplies its own `bannedMarks` (regulatory insignia, third-party product marks,
+certification badges — whatever its domain forbids), typically from its
+`BrandingConfig`.
+
+The package root is browser-safe (no node builtins); only `./testing` touches the
+filesystem.
+
+See `SKILL.md` for the full knob surface and the list of what is deliberately not
+configurable.
+
+## License
+
+MIT © Jesse Wright
diff --git a/packages/solid-showcase-kit/SKILL.md b/packages/solid-showcase-kit/SKILL.md
new file mode 100644
index 000000000..5ee7e3f1a
--- /dev/null
+++ b/packages/solid-showcase-kit/SKILL.md
@@ -0,0 +1,105 @@
+---
+name: solid-showcase-kit
+description: Use when building or modifying the trust/disclaimer surfaces of a Solid concept demo with @jeswr/solid-showcase-kit — branding configs, disclaimer packs, the concept-demo banner/interstitial/app shell, org theming, or the insignia scanner and e2e assertions under ./testing.
+---
+
+
+# Work with `@jeswr/solid-showcase-kit`
+
+The de-branded disclaimer/branding/trust pack every showcase surface renders from. One
+validated `BrandingConfig` drives ALL trust copy; components refuse to render without it.
+The kit is the BASE of the dependency edge: it never imports from any showcase/renderer
+package, and document schemas downstream compose from the zod schemas exported here.
+
+## The one wiring pattern
+
+```tsx
+import {
+ AppShell, brandingConfigSchema, createDisclaimerPack,
+ ShowcaseTrustProvider, themeFromSpec,
+} from "@jeswr/solid-showcase-kit";
+
+const branding = brandingConfigSchema.parse(walkthrough.branding); // or a literal
+const pack = createDisclaimerPack(branding); // frozen copy factories
+const theme = themeFromSpec(spec, "Modelled Org"); // spec is registry data
+
+// Root layout, once per app:
+
+ {children}
+ ;
+
+// Layout metadata (server side, no provider needed):
+export const metadata = pack.demoMetadata({ appName, organization });
+```
+
+- Schemas are the runtime source of truth: `bannedMarkSchema`, `themeSpecSchema`,
+ `brandingConfigSchema`; the types (`BannedMark`, `ThemeSpec`, `BrandingConfig`) are
+ `z.infer` of them. Compose downstream document schemas from the schemas, never redeclare.
+- Components resolve the pack from an explicit `pack` prop, else the nearest
+ `ShowcaseTrustProvider`; with neither they THROW (fail closed — a trust surface must
+ never render bare). `IllustrativeFigure` alone falls back to the default tag so the
+ qualifier always renders.
+- Palette DATA lives with consumers (registry/walkthrough documents); the kit ships only
+ the factories (`paletteTokens`, `themeFromSpec`, `themeCssProperties`). Original colour
+ values only — never brand-guideline colours.
+
+## The knob surface (the R1–R8 trust rules, generalised)
+
+| Rule | Knob in `BrandingConfig` | What stays fixed in code |
+|---|---|---|
+| R1 banner copy | `convener`; `domainNegations[0]` (primary negation); `aboutHref` | Lead structure ("Concept demo — this is not {Org}."), "All data is simulated.", the About link, rendering itself |
+| R2/R3 insignia + product-mark bans | `bannedMarks` — the consumer's own never-render roster (`pattern` casing: uppercase source ⇒ case-sensitive) | The scanner mechanism; no allowlist, no inline suppression. The kit ships NO built-in roster — what is banned is domain knowledge and lives with the consumer |
+| R4 neutral hostnames | none — an operator/scaffolder rule, not kit code | — |
+| R5 illustrative tag | `illustrativeTag` text (non-blank; blank falls back) | The tag always renders inside the same element as the figure |
+| R6 metadata | derived from `convener` only | "— Concept Demo (not {Org})" title suffix; noindex/nofollow |
+| R7 demo-locked fields | supplemental `hint` prop only | `DEMO_FIELD_HINT` always renders; read-only default |
+| R8 interstitial | `consentCookiePrefix`; `description` (purpose clause); negations | Four-paragraph structure, heading/labels, affirmative-continue, Escape never dismisses |
+
+`domainNegations` semantics: full sentences. `[0]` is the primary negation — banner
+(verbatim) + compact/footer (a leading "Nothing here is …" becomes "Not …"). Entries
+after the first replace the primary inside the interstitial's simulation paragraph,
+joined in order; a single entry serves both. `description` completes "…built by the
+{convener} to {description}." — no trailing period.
+
+## Deliberately NOT configurable
+
+- The banner cannot be hidden: no prop suppresses it, and `AppShell` renders it directly
+ below the header (four-Ps adjacency) unconditionally, plus the footer legal line.
+- The fixed safety copy always renders: "All data is simulated." / "Simulated data." /
+ "All data simulated" / "Do not enter real personal information" / "Everything here is
+ simulated." / "Do not enter real personal or financial information. Use the demo
+ personas provided." — `domainNegations` only EXTENDS it (empty array ⇒ safety copy
+ still renders).
+- The interstitial's four-paragraph structure and its "own"-variant collective negation.
+- `demoMetadata` is always `noindex, nofollow`.
+- The pack object is frozen; copy cannot be patched out after creation.
+
+Do not fork copy into consumers: unit and e2e suites must assert against the SAME pack
+the components render from (that is the whole point of the pack).
+
+## `@jeswr/solid-showcase-kit/testing` (node-only subpath)
+
+- `insigniaFindings(text, { bannedMarks })` — content scan of a text blob (per-line plus
+ a whitespace-normalised cross-line pass, so marks split across line breaks are caught);
+ `insigniaPathFindings(path, …)` — file-path check (catches a revealing asset name or
+ directory even when the binary content cannot be grepped); `scanInsigniaTree(dirs,
+ { rootDir, … })` — full walker (path + content, skipping
+ `node_modules`/`dist`/`.next`/`.turbo`; symlinks in a scanned tree are REJECTED, never
+ silently skipped). A repo's `check-insignia` script is a thin wrapper: scan
+ rendered-source dirs with the repo's own `bannedMarks`, exit 1 on findings.
+- `disclaimerAssertions` — runner-agnostic selectors + expected copy derived from a pack
+ (`expectedBannerText`, `expectedFooterText`, `expectedNegationParagraph`).
+- The package ROOT is browser-safe by contract (no node builtins) — never re-export
+ testing modules from it; a unit test enforces this.
+
+## Golden-test split (kit vs consumer)
+
+The kit's own golden suite (`test/disclaimers.test.ts`) pins the strings a fully
+FICTIONAL branding fixture produces — it proves the PARAMETERISATION mechanism, not any
+real project's copy. Each real consumer must additionally keep its own golden vectors in
+its own repository: pass its real `BrandingConfig` to `createDisclaimerPack` and assert
+its exact rendered strings literally (its adoption step / copy-drift gate). Never move a
+real project's branding or copy into this package's tests or docs.
+
+Verify API usage against the published dist, run the workspace gate after changes, and
+never weaken the golden copy tests or the safety-invariant suite.
diff --git a/packages/solid-showcase-kit/package.json b/packages/solid-showcase-kit/package.json
new file mode 100644
index 000000000..42e4493dc
--- /dev/null
+++ b/packages/solid-showcase-kit/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@jeswr/solid-showcase-kit",
+ "version": "0.1.0",
+ "description": "De-branded disclaimer/branding/trust pack for Solid concept demos — banner, interstitial, honesty and demo-safety React components parameterised by a zod BrandingConfig, plus an insignia scanner and e2e assertion helpers under ./testing.",
+ "author": "Claude Fable 5 (@jeswr solid-sdk agent)",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./testing": {
+ "types": "./dist/testing/index.d.ts",
+ "import": "./dist/testing/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "src",
+ "README.md",
+ "SKILL.md",
+ "LICENSE"
+ ],
+ "engines": {
+ "node": ">=20.12"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json",
+ "lint": "biome check src test",
+ "lint:fix": "biome check --write src test",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "peerDependencies": {
+ "react": ">=18"
+ },
+ "dependencies": {
+ "@jeswr/app-shell": "workspace:*",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.5.1",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.3.0",
+ "@types/node": "^25.9.2",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^5.2.0",
+ "jsdom": "^26.0.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.9"
+ }
+}
diff --git a/packages/solid-showcase-kit/src/app-shell.tsx b/packages/solid-showcase-kit/src/app-shell.tsx
new file mode 100644
index 000000000..41a62c728
--- /dev/null
+++ b/packages/solid-showcase-kit/src/app-shell.tsx
@@ -0,0 +1,99 @@
+// AUTHORED-BY Claude Fable 5
+"use client";
+
+import { cn } from "@jeswr/app-shell";
+import type { ReactNode } from "react";
+import { ConceptDemoBanner } from "./concept-demo-banner.js";
+import type { DisclaimerPack, DisclaimerVariant } from "./disclaimers.js";
+import { type OrgTheme, themeCssProperties } from "./themes.js";
+import { useDisclaimerPack, useShowcaseTrust } from "./trust-context.js";
+
+export interface AppShellProps {
+ appName: string;
+ /**
+ * Org theme supplying `modelledOn`, `role`, and the palette tokens; defaults to the
+ * nearest `ShowcaseTrustProvider`'s theme.
+ */
+ theme?: OrgTheme | undefined;
+ /** "own" only for surfaces published under the convener's own branding. */
+ variant?: DisclaimerVariant | undefined;
+ /** Destination of the banner's "About this demo" link; defaults to the pack's. */
+ aboutHref?: string | undefined;
+ /** Optional right-hand header slot (nav, account menu, …). */
+ headerActions?: ReactNode | undefined;
+ children: ReactNode;
+ className?: string | undefined;
+ /** Explicit pack; defaults to the nearest `ShowcaseTrustProvider`. */
+ pack?: DisclaimerPack | undefined;
+}
+
+/**
+ * Shared app frame: brand header with role-first framing, the concept-demo banner
+ * rendered directly below the header (four-Ps adjacency — the banner is not a removable
+ * slot), and the footer legal line. Applies the org theme's CSS custom properties to the
+ * whole frame.
+ */
+export function AppShell({
+ appName,
+ theme,
+ variant = "modelled",
+ aboutHref,
+ headerActions,
+ children,
+ className,
+ pack,
+}: AppShellProps) {
+ const resolvedPack = useDisclaimerPack(pack);
+ const trust = useShowcaseTrust();
+ const resolvedTheme = theme ?? trust?.theme;
+ if (resolvedTheme === undefined) {
+ throw new Error(
+ "AppShell needs an OrgTheme: pass the `theme` prop or provide one via .",
+ );
+ }
+ const framing =
+ variant === "own"
+ ? `${resolvedTheme.role} · ${resolvedTheme.modelledOn}`
+ : `${resolvedTheme.role} · modelled on ${resolvedTheme.modelledOn}`;
+ return (
+
+
+ Skip to main content
+
+
+
+
+
{appName}
+
{framing}
+
+ {headerActions}
+
+
+
+ {/* The skip link's target lives HERE so every consumer gets a working
+ "Skip to main content" without declaring its own #main-content. */}
+
+ {children}
+
+
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/branding.ts b/packages/solid-showcase-kit/src/branding.ts
new file mode 100644
index 000000000..3e5dc293c
--- /dev/null
+++ b/packages/solid-showcase-kit/src/branding.ts
@@ -0,0 +1,76 @@
+// AUTHORED-BY Claude Fable 5
+/**
+ * Branding/theming contracts OWNED by this kit — the single runtime source of truth that
+ * downstream document schemas (e.g. a walkthrough document's `branding`/`theme` fields)
+ * compose from. Schema-first: each schema is a plain `z.object` with NO type annotation,
+ * and the TypeScript contract is INFERRED from it — never the other way around.
+ *
+ * The kit is the base of the dependency edge: it never imports from any showcase/renderer
+ * package. It is also domain-generic by design: nothing in these contracts (or anywhere
+ * in the kit) is specific to any industry — domain knowledge enters only through the
+ * values a consumer supplies.
+ */
+import { z } from "zod";
+
+/**
+ * A mark that must never render on a demo surface (regulatory insignia, third-party
+ * product marks, certification badges — whatever the consumer's domain forbids).
+ *
+ * `pattern` is a JavaScript regular-expression SOURCE string. It compiles
+ * case-insensitively unless it contains an uppercase character, in which case it compiles
+ * case-SENSITIVELY (so a standalone-token rule like `\bXYZ\b` does not match prose
+ * lowercase "xyz").
+ */
+export const bannedMarkSchema = z.object({
+ pattern: z.string(),
+ reason: z.string(),
+});
+
+/**
+ * A per-organisation palette spec: a neutral hue anchor plus primary/accent colours.
+ * Palette-INSPIRED, adjacent-but-distinct by policy — original colour values only, never
+ * brand-guideline colours.
+ */
+export const themeSpecSchema = z.object({
+ hue: z.number(),
+ primary: z.string(),
+ accent: z.string(),
+ primaryForeground: z.string().optional(),
+ accentForeground: z.string().optional(),
+ role: z.string(),
+});
+
+/**
+ * Everything a use case may configure about the trust/disclaimer surfaces.
+ *
+ * - `convener`: display name substituted through every disclaimer string ("A research
+ * prototype by the {convener}", "built by the {convener}", …). Trimmed; must be
+ * non-blank.
+ * - `domainNegations`: full-sentence offer negations appended to the FIXED safety copy.
+ * The first entry is the primary negation (banner + footer; compact surfaces derive a
+ * "Not …" form from a leading "Nothing here is …"). Entries after the first replace the
+ * primary inside the consent interstitial's simulation paragraph, joined in order —
+ * when only one entry is given it serves both surfaces.
+ * - `description`: purpose clause completing "This site is a research prototype built by
+ * the {convener} to {description}." (no trailing period). Trimmed; must be non-blank.
+ * - `bannedMarks`: the consumer's own never-render roster for the `./testing` insignia
+ * scanner. The kit ships NO built-in list — domain rosters live with consumers.
+ * - The fixed safety copy ("All data simulated", "Do not enter real personal
+ * information", …) is NOT configurable and always renders.
+ */
+export const brandingConfigSchema = z.object({
+ convener: z.string().trim().min(1),
+ domainNegations: z.array(z.string()),
+ description: z.string().trim().min(1),
+ aboutHref: z.string().optional(),
+ consentCookiePrefix: z
+ .string()
+ .regex(/^[a-z0-9-]+-$/)
+ .optional(),
+ illustrativeTag: z.string().optional(),
+ bannedMarks: z.array(bannedMarkSchema).optional(),
+});
+
+export type BannedMark = z.infer;
+export type ThemeSpec = z.infer;
+export type BrandingConfig = z.infer;
diff --git a/packages/solid-showcase-kit/src/concept-demo-banner.tsx b/packages/solid-showcase-kit/src/concept-demo-banner.tsx
new file mode 100644
index 000000000..757eac7ee
--- /dev/null
+++ b/packages/solid-showcase-kit/src/concept-demo-banner.tsx
@@ -0,0 +1,110 @@
+// AUTHORED-BY Claude Fable 5
+"use client";
+
+import type { CSSProperties } from "react";
+import type { DisclaimerPack, DisclaimerVariant } from "./disclaimers.js";
+import { useDisclaimerPack } from "./trust-context.js";
+
+export interface ConceptDemoBannerProps {
+ organization: string;
+ /**
+ * Journey role, e.g. "consumer data custodian". Omitted → the role clause is omitted.
+ * (Named `orgRole` rather than `role` so it cannot be confused with the ARIA attribute.)
+ */
+ orgRole?: string | undefined;
+ /** "own" only for surfaces published under the convener's own branding. */
+ variant?: DisclaimerVariant | undefined;
+ /** Destination of the "About this demo" link; defaults to the pack's `aboutHref`. */
+ aboutHref?: string | undefined;
+ /**
+ * "fixed" pins the banner to the bottom of the viewport (stub-compatible default for
+ * layouts without `AppShell`); "static" renders it in flow — `AppShell` uses this to
+ * place it directly below the brand header (four-Ps adjacency).
+ */
+ placement?: "fixed" | "static" | undefined;
+ /** Explicit pack; defaults to the nearest `ShowcaseTrustProvider`. */
+ pack?: DisclaimerPack | undefined;
+}
+
+const BASE_STYLE: CSSProperties = {
+ background: "#0f172a",
+ color: "#f8fafc",
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
+ fontSize: "0.875rem",
+ lineHeight: 1.5,
+ padding: "0.625rem 1rem",
+ textAlign: "center",
+};
+
+const FIXED_STYLE: CSSProperties = {
+ ...BASE_STYLE,
+ bottom: 0,
+ left: 0,
+ position: "fixed",
+ right: 0,
+ zIndex: 50,
+};
+
+const LINK_STYLE: CSSProperties = {
+ color: "#bae6fd",
+ fontWeight: 600,
+ marginLeft: "0.375rem",
+ textDecorationLine: "underline",
+ textUnderlineOffset: "2px",
+};
+
+/**
+ * Deliberately self-contained CSS (no Tailwind dependency) so the banner renders correctly
+ * on error pages and in apps whose stylesheet pipeline failed — the compliance surface must
+ * never depend on app CSS configuration.
+ */
+const RESPONSIVE_CSS = [
+ "[data-concept-demo-banner] [data-cdb-compact]{display:none}",
+ "@media (max-width: 639px){",
+ "[data-concept-demo-banner] [data-cdb-full]{display:none}",
+ "[data-concept-demo-banner] [data-cdb-compact]{display:inline}",
+ "}",
+].join("");
+
+/**
+ * Persistent source- and offer-negation banner on every route including error pages.
+ * Unremovable by design: there is no prop that hides it, and the copy comes exclusively
+ * from the disclaimer pack.
+ */
+export function ConceptDemoBanner({
+ organization,
+ orgRole,
+ variant = "modelled",
+ aboutHref,
+ placement = "fixed",
+ pack,
+}: ConceptDemoBannerProps) {
+ const resolvedPack = useDisclaimerPack(pack);
+ const href = aboutHref ?? resolvedPack.aboutHref;
+ const full = resolvedPack.bannerFullCopy({ organization, role: orgRole, variant });
+ const compact = resolvedPack.bannerCompactCopy({ organization, role: orgRole, variant });
+ return (
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/consent-interstitial.tsx b/packages/solid-showcase-kit/src/consent-interstitial.tsx
new file mode 100644
index 000000000..e866b340e
--- /dev/null
+++ b/packages/solid-showcase-kit/src/consent-interstitial.tsx
@@ -0,0 +1,208 @@
+// AUTHORED-BY Claude Fable 5
+"use client";
+
+import {
+ type CSSProperties,
+ Fragment,
+ type KeyboardEvent as ReactKeyboardEvent,
+ useCallback,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from "react";
+import {
+ type DisclaimerPack,
+ type DisclaimerVariant,
+ INTERSTITIAL_CONTINUE_LABEL,
+ INTERSTITIAL_HEADING,
+ INTERSTITIAL_LEARN_MORE_LABEL,
+} from "./disclaimers.js";
+import { useDisclaimerPack } from "./trust-context.js";
+
+export interface ConsentInterstitialProps {
+ organization: string;
+ /** Stable per-app slug used in the consent cookie name, e.g. "wallet". */
+ appId: string;
+ /** "own" only for surfaces published under the convener's own branding. */
+ variant?: DisclaimerVariant | undefined;
+ /** Destination of "Learn more about the project" (the tour/shell app). */
+ learnMoreHref?: string | undefined;
+ /** Explicit pack; defaults to the nearest `ShowcaseTrustProvider`. */
+ pack?: DisclaimerPack | undefined;
+}
+
+const CONSENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
+
+function hasConsentCookie(cookiePrefix: string, appId: string): boolean {
+ return document.cookie.split("; ").some((entry) => entry.startsWith(`${cookiePrefix}${appId}=`));
+}
+
+const OVERLAY_STYLE: CSSProperties = {
+ alignItems: "center",
+ background: "rgba(15, 23, 42, 0.6)",
+ display: "flex",
+ inset: 0,
+ justifyContent: "center",
+ overflowY: "auto",
+ padding: "1.5rem",
+ position: "fixed",
+ zIndex: 100,
+};
+
+const PANEL_STYLE: CSSProperties = {
+ background: "#ffffff",
+ borderRadius: "0.75rem",
+ boxShadow: "0 20px 50px rgba(15, 23, 42, 0.35)",
+ color: "#0f172a",
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
+ fontSize: "0.9375rem",
+ lineHeight: 1.6,
+ margin: "auto",
+ maxHeight: "100%",
+ maxWidth: "40rem",
+ overflowY: "auto",
+ padding: "2rem",
+};
+
+const HEADING_STYLE: CSSProperties = {
+ fontSize: "1.375rem",
+ fontWeight: 700,
+ lineHeight: 1.3,
+ margin: "0 0 1rem",
+};
+
+const PARAGRAPH_STYLE: CSSProperties = {
+ margin: "0 0 0.875rem",
+};
+
+const ACTIONS_STYLE: CSSProperties = {
+ alignItems: "center",
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "1rem",
+ marginTop: "1.25rem",
+};
+
+const CONTINUE_STYLE: CSSProperties = {
+ background: "#0f172a",
+ border: "none",
+ borderRadius: "0.5rem",
+ color: "#f8fafc",
+ cursor: "pointer",
+ fontSize: "0.9375rem",
+ fontWeight: 600,
+ padding: "0.625rem 1.25rem",
+};
+
+const LEARN_MORE_STYLE: CSSProperties = {
+ color: "#1d4ed8",
+ fontWeight: 500,
+ textDecorationLine: "underline",
+ textUnderlineOffset: "2px",
+};
+
+/**
+ * Consent interstitial: first visit per app, cookie-persisted, affirmative continue.
+ * Server-renders open (fail-safe: shows when consent is unknown) and closes on mount when
+ * the consent cookie is present. Blocks interaction until acknowledged; Escape does not
+ * dismiss it — continuing must be affirmative. The four-paragraph copy structure comes
+ * from the disclaimer pack and is fixed by design.
+ */
+export function ConsentInterstitial({
+ organization,
+ appId,
+ variant = "modelled",
+ learnMoreHref = "/",
+ pack,
+}: ConsentInterstitialProps) {
+ const resolvedPack = useDisclaimerPack(pack);
+ const cookiePrefix = resolvedPack.consentCookiePrefix;
+ const [open, setOpen] = useState(true);
+ const panelRef = useRef(null);
+ const previouslyFocused = useRef(null);
+ const headingId = useId();
+
+ useEffect(() => {
+ // Re-evaluate consent for the CURRENT appId/cookiePrefix: close when this app has
+ // stored consent, and RE-OPEN when it does not — a component reused across apps must
+ // enforce the per-app acknowledgement, not inherit a previous app's dismissal.
+ setOpen(!hasConsentCookie(cookiePrefix, appId));
+ }, [cookiePrefix, appId]);
+
+ useEffect(() => {
+ if (!open) return;
+ previouslyFocused.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ panelRef.current?.focus();
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ previouslyFocused.current?.focus();
+ };
+ }, [open]);
+
+ const accept = useCallback(() => {
+ // biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API is not available in all supported browsers; this writes same-origin consent state only.
+ document.cookie = `${cookiePrefix}${appId}=1; path=/; max-age=${CONSENT_COOKIE_MAX_AGE_SECONDS}; samesite=lax`;
+ setOpen(false);
+ }, [cookiePrefix, appId]);
+
+ const trapFocus = useCallback((event: ReactKeyboardEvent) => {
+ if (event.key !== "Tab") return;
+ const panel = panelRef.current;
+ if (panel === null) return;
+ const focusable = panel.querySelectorAll("a[href], button:not([disabled])");
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (first === undefined || last === undefined) return;
+ const active = document.activeElement;
+ if (event.shiftKey && (active === first || active === panel)) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && active === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }, []);
+
+ if (!open) return null;
+
+ return (
+
+
+
+ {INTERSTITIAL_HEADING}
+
+ {resolvedPack.interstitialParagraphs(organization, variant).map((paragraph) => (
+
segment.text).join("")} style={PARAGRAPH_STYLE}>
+ {paragraph.map((segment) =>
+ segment.strong === true ? (
+ {segment.text}
+ ) : (
+ {segment.text}
+ ),
+ )}
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/credential-card.tsx b/packages/solid-showcase-kit/src/credential-card.tsx
new file mode 100644
index 000000000..9006fbe11
--- /dev/null
+++ b/packages/solid-showcase-kit/src/credential-card.tsx
@@ -0,0 +1,77 @@
+// AUTHORED-BY Claude Fable 5
+import { cn } from "@jeswr/app-shell";
+import type { CSSProperties, ReactNode } from "react";
+
+export type CredentialStatus = "valid" | "pending" | "expired" | "revoked";
+
+export interface CredentialCardProps {
+ /** Credential type, e.g. "Employment and income". */
+ title: string;
+ /** Issuer display name (role-first framing, e.g. "employment and income verifier"). */
+ issuer: string;
+ /** Pre-formatted validity-window bounds (this component does no date localisation). */
+ validFrom?: string | undefined;
+ validUntil?: string | undefined;
+ status: CredentialStatus;
+ /** Optional extra claim rows. */
+ children?: ReactNode | undefined;
+ className?: string | undefined;
+}
+
+const STATUS_PRESENTATION: Record = {
+ expired: { color: "#64748b", label: "Expired" },
+ pending: { color: "#d97706", label: "Pending" },
+ revoked: { color: "#dc2626", label: "Revoked" },
+ valid: { color: "#16a34a", label: "Valid" },
+};
+
+function dotStyle(color: string): CSSProperties {
+ return {
+ background: color,
+ borderRadius: "9999px",
+ display: "inline-block",
+ height: "0.5rem",
+ width: "0.5rem",
+ };
+}
+
+/**
+ * Visual for a verifiable credential: issuer, validity window, and a status dot that is
+ * never colour-only (the status label is always rendered as text).
+ */
+export function CredentialCard({
+ title,
+ issuer,
+ validFrom,
+ validUntil,
+ status,
+ children,
+ className,
+}: CredentialCardProps) {
+ const presentation = STATUS_PRESENTATION[status];
+ return (
+
+
+
+
{title}
+
Issued by {issuer}
+
+
+
+ {presentation.label}
+
+
+ {(validFrom !== undefined || validUntil !== undefined) && (
+
+ {validFrom !== undefined && `Valid from ${validFrom}`}
+ {validFrom !== undefined && validUntil !== undefined && " · "}
+ {validUntil !== undefined && `until ${validUntil}`}
+
+ )}
+ {children !== undefined &&
{children}
}
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/demo-locked-field.tsx b/packages/solid-showcase-kit/src/demo-locked-field.tsx
new file mode 100644
index 000000000..7d343110a
--- /dev/null
+++ b/packages/solid-showcase-kit/src/demo-locked-field.tsx
@@ -0,0 +1,64 @@
+// AUTHORED-BY Claude Fable 5
+"use client";
+
+import { cn } from "@jeswr/app-shell";
+import { useId } from "react";
+import { DEMO_FIELD_HINT } from "./disclaimers.js";
+
+export interface DemoLockedFieldProps {
+ label: string;
+ /** The prefilled demo-persona value. */
+ value: string;
+ name?: string | undefined;
+ /**
+ * Locked (default) renders a read-only input. Editable fields stay prefilled and keep
+ * the "do not enter real information" hint. Free-entry fields for government-ID or
+ * date-of-birth formats are banned outright — do not build them with or without this
+ * component.
+ */
+ editable?: boolean | undefined;
+ /**
+ * Supplemental hint text appended AFTER the mandatory hint. The
+ * "do not enter real information" warning itself always renders and cannot be
+ * replaced or removed.
+ */
+ hint?: string | undefined;
+ className?: string | undefined;
+}
+
+/**
+ * Demo-locked form field: prefilled from a demo persona, read-only by default, and
+ * always accompanied by a "do not enter real information" hint wired via aria-describedby.
+ */
+export function DemoLockedField({
+ label,
+ value,
+ name,
+ editable = false,
+ hint,
+ className,
+}: DemoLockedFieldProps) {
+ const inputId = useId();
+ const hintId = useId();
+ return (
+
+
+ {label}
+
+
+
+ {hint === undefined ? DEMO_FIELD_HINT : `${DEMO_FIELD_HINT} ${hint}`}
+
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/disclaimers.ts b/packages/solid-showcase-kit/src/disclaimers.ts
new file mode 100644
index 000000000..b58c8753e
--- /dev/null
+++ b/packages/solid-showcase-kit/src/disclaimers.ts
@@ -0,0 +1,327 @@
+// AUTHORED-BY Claude Fable 5
+/**
+ * Disclaimer copy pack — the single source of truth for the exact trust wording every
+ * showcase surface renders (banner, footer legal line, consent interstitial, metadata,
+ * figure tags, demo-field hints).
+ *
+ * `createDisclaimerPack(branding)` closes over a {@link BrandingConfig} and returns the
+ * frozen copy factories. Components in this package render these strings; unit tests and
+ * shared e2e suites consume the SAME pack so asserted copy can never drift from rendered
+ * copy.
+ *
+ * Opinionated invariants preserved in code, not config:
+ * - the fixed safety copy ("All data is simulated", "Do not enter real personal
+ * information", …) always renders — `domainNegations` only EXTENDS it;
+ * - the interstitial keeps its four-paragraph structure with only convener / negation /
+ * description substitutions;
+ * - metadata is always noindex/nofollow.
+ *
+ * `variant`:
+ * - "modelled" (default) — branded surfaces modelled on a real organisation; copy negates
+ * source ("this is not {Org}") and offer (the domain negations).
+ * - "own" — surfaces published under the convener's own branding (e.g. a tour shell),
+ * where source-negation against the convener would be self-contradictory.
+ */
+import { type BrandingConfig, brandingConfigSchema } from "./branding.js";
+
+export type DisclaimerVariant = "modelled" | "own";
+
+/** A run of copy; `strong` marks the phrases rendered bold. */
+export interface CopySegment {
+ text: string;
+ strong?: boolean | undefined;
+}
+
+/** Flatten copy segments to the plain text an e2e assertion sees. */
+export function copyText(segments: CopySegment[]): string {
+ return segments.map((segment) => segment.text).join("");
+}
+
+export interface BannerCopyOptions {
+ organization: string;
+ /** Journey role, e.g. "consumer data custodian"; rendered as "modelled on the {role} role". */
+ role?: string | undefined;
+ variant?: DisclaimerVariant | undefined;
+}
+
+export interface BannerCopy {
+ /** Leading phrase, rendered bold (the four-Ps "headline corrector"). */
+ lead: string;
+ /** Remainder of the sentence(s), rendered at the same prominence tier. */
+ rest: string;
+ /** Visible text of the "About this demo" link. */
+ aboutLabel: string;
+}
+
+export interface FooterCopyOptions {
+ organization: string;
+ variant?: DisclaimerVariant | undefined;
+}
+
+export interface DemoMetadataOptions {
+ appName: string;
+ organization: string;
+ variant?: DisclaimerVariant | undefined;
+}
+
+/** Structurally compatible with Next.js `Metadata` without depending on `next`. */
+export interface DemoMetadata {
+ title: string;
+ description: string;
+ openGraph: { title: string; description: string };
+ robots: { index: false; follow: false };
+}
+
+/** Interstitial heading — fixed copy, not configurable. */
+export const INTERSTITIAL_HEADING = "You're entering a concept demonstration";
+/** Interstitial affirmative-continue label — fixed copy, not configurable. */
+export const INTERSTITIAL_CONTINUE_LABEL = "I understand — enter the demo";
+/** Interstitial learn-more label — fixed copy, not configurable. */
+export const INTERSTITIAL_LEARN_MORE_LABEL = "Learn more about the project";
+
+/** Default inline tag rendered adjacent to every rate/fee/payment/decision figure. */
+export const ILLUSTRATIVE_FIGURE_TAG = "illustrative figure, not an offer";
+
+/** Mandatory hint for demo form fields — fixed by design, only extendable. */
+export const DEMO_FIELD_HINT = "Demo field — do not enter real information.";
+
+/** Default consent-cookie prefix when the branding config does not set one. */
+export const DEFAULT_CONSENT_COOKIE_PREFIX = "demo-consent-";
+
+/** Simulation paragraph lead — fixed safety copy shared verbatim by both variants. */
+const simulatedLead = (): CopySegment => ({
+ strong: true,
+ text: "Everything here is simulated.",
+});
+
+/** Do-not-enter paragraph — fixed safety copy shared verbatim by both variants. */
+const doNotEnterParagraph = (): CopySegment[] => [
+ { strong: true, text: "Do not enter real personal or financial information." },
+ { text: " Use the demo personas provided." },
+];
+
+/**
+ * Deep-freeze a paragraph set. `interstitialParagraphs` returns FRESH, deep-frozen
+ * copies on every call so no caller can mutate a result and drop mandatory safety copy —
+ * neither for itself nor for any later caller.
+ */
+function freezeParagraphs(paragraphs: CopySegment[][]): CopySegment[][] {
+ for (const paragraph of paragraphs) {
+ for (const segment of paragraph) Object.freeze(segment);
+ Object.freeze(paragraph);
+ }
+ return Object.freeze(paragraphs) as CopySegment[][];
+}
+
+/**
+ * Compact form of a full-sentence negation for the mobile banner and the footer legal
+ * line: a leading "Nothing here/on this site/in this demo is …" becomes "Not …"
+ * ("Nothing here is an offer of hire." → "Not an offer of hire."). Sentences that do
+ * not match are used verbatim.
+ */
+function compactNegation(sentence: string): string {
+ const match = /^nothing (?:here|on this site|in this demo) is\s+(.+)$/i.exec(sentence);
+ const rest = match?.[1];
+ return rest === undefined ? sentence : `Not ${rest}`;
+}
+
+/**
+ * The frozen copy-factory pack every trust component renders from. All copy that is not
+ * derived from the branding config is fixed here and cannot be removed or replaced by
+ * any prop or configuration path.
+ */
+export interface DisclaimerPack {
+ /** The validated branding config the pack was created from. */
+ readonly branding: BrandingConfig;
+ readonly convener: string;
+ /** Destination of the "About this demo" link (default "/"). */
+ readonly aboutHref: string;
+ /** Consent-cookie name prefix, always ending in "-". */
+ readonly consentCookiePrefix: string;
+ /** Inline qualifier for rendered figures; never blank. */
+ readonly illustrativeTag: string;
+ /** The mandatory demo-field hint — always {@link DEMO_FIELD_HINT}. */
+ readonly demoFieldHint: string;
+ /** Banner copy, desktop variant. */
+ bannerFullCopy(options: BannerCopyOptions): BannerCopy;
+ /** Banner copy, mobile-compressed variant (same element, small screens). */
+ bannerCompactCopy(options: BannerCopyOptions): BannerCopy;
+ /** Full desktop banner text as a single string (for assertions). */
+ bannerFullText(options: BannerCopyOptions): string;
+ /** Compact banner text as a single string (for assertions). */
+ bannerCompactText(options: BannerCopyOptions): string;
+ /** Footer legal line, every page. */
+ footerLegalLine(options: FooterCopyOptions): string;
+ /**
+ * The four interstitial paragraphs, with the bold phrases marked. The four-paragraph
+ * structure is fixed; only the convener, description, and negations vary.
+ *
+ * `variant: "own"` (convener-branded surfaces) keeps the structure but replaces the
+ * org-negation paragraph — negating the convener against itself would be
+ * self-contradictory — with a collective negation of every organisation named inside
+ * the demo. The simulation and do-not-enter paragraphs are shared verbatim with the
+ * modelled variant so the core copy cannot drift between variants.
+ */
+ interstitialParagraphs(organization: string, variant?: DisclaimerVariant): CopySegment[][];
+ /** `` — suffixed "— Concept Demo (not {Org})" on every branded surface. */
+ demoTitle(options: DemoMetadataOptions): string;
+ /** Meta/OG description — leads with the non-affiliation line. */
+ demoDescription(options: DemoMetadataOptions): string;
+ /**
+ * Metadata helper for app layouts: title suffix + OG description + noindex.
+ *
+ * Usage: `export const metadata: Metadata = pack.demoMetadata({ appName, organization });`
+ */
+ demoMetadata(options: DemoMetadataOptions): DemoMetadata;
+}
+
+/**
+ * Build the disclaimer pack for a branding config. The config is validated with
+ * {@link brandingConfigSchema} (throws on violation) and the returned pack is frozen.
+ */
+export function createDisclaimerPack(branding: BrandingConfig): DisclaimerPack {
+ const parsed = brandingConfigSchema.parse(branding);
+ const convener = parsed.convener;
+ const negations = parsed.domainNegations
+ .map((sentence) => sentence.trim())
+ .filter((sentence) => sentence.length > 0);
+ const primaryNegation = negations[0];
+ const primaryCompact =
+ primaryNegation === undefined ? undefined : compactNegation(primaryNegation);
+ /** Entries after the first replace the primary in the interstitial (see branding.ts). */
+ const interstitialNegations = negations.length > 1 ? negations.slice(1) : negations;
+ const purpose = parsed.description.trim().replace(/\.\s*$/, "");
+ const illustrativeTag =
+ parsed.illustrativeTag !== undefined && parsed.illustrativeTag.trim().length > 0
+ ? parsed.illustrativeTag
+ : ILLUSTRATIVE_FIGURE_TAG;
+
+ /** " A research prototype by the {convener}{roleClause}. All data is simulated.[ {negation}]" */
+ const fullRest = (roleClause: string): string =>
+ ` A research prototype by the ${convener}${roleClause}. All data is simulated.${
+ primaryNegation === undefined ? "" : ` ${primaryNegation}`
+ }`;
+
+ const bannerFullCopy = (options: BannerCopyOptions): BannerCopy => {
+ if (options.variant === "own") {
+ return { aboutLabel: "About this demo", lead: "Concept demo.", rest: fullRest("") };
+ }
+ const roleClause = options.role === undefined ? "" : `, modelled on the ${options.role} role`;
+ return {
+ aboutLabel: "About this demo",
+ lead: `Concept demo — this is not ${options.organization}.`,
+ rest: fullRest(roleClause),
+ };
+ };
+
+ const bannerCompactCopy = (options: BannerCopyOptions): BannerCopy => {
+ const rest = ` Simulated data.${primaryCompact === undefined ? "" : ` ${primaryCompact}`}`;
+ if (options.variant === "own") {
+ return { aboutLabel: "About", lead: "Concept demo.", rest };
+ }
+ return { aboutLabel: "About", lead: `Concept demo — not ${options.organization}.`, rest };
+ };
+
+ const interstitialParagraphs = (
+ organization: string,
+ variant: DisclaimerVariant = "modelled",
+ ): CopySegment[][] => {
+ const builtBy: CopySegment[] = [
+ { text: "This site is a research prototype built by the " },
+ { strong: true, text: convener },
+ { text: ` to ${purpose}.` },
+ ];
+ const simulated: CopySegment[] =
+ interstitialNegations.length === 0
+ ? [simulatedLead()]
+ : [simulatedLead(), { text: ` ${interstitialNegations.join(" ")}` }];
+ if (variant === "own") {
+ return freezeParagraphs([
+ builtBy,
+ [
+ { text: "The organisations named inside the demo " },
+ { strong: true, text: "illustrate market roles only" },
+ { text: ". " },
+ { strong: true, text: "None of them built this demo" },
+ { text: ", and " },
+ { strong: true, text: "none is affiliated with, sponsors, or endorses it" },
+ { text: "." },
+ ],
+ simulated,
+ doNotEnterParagraph(),
+ ]);
+ }
+ return freezeParagraphs([
+ builtBy,
+ [
+ { text: "It is modelled on the role " },
+ { strong: true, text: organization },
+ { text: " plays in that journey. It was " },
+ { strong: true, text: `not built by ${organization}` },
+ { text: " and is " },
+ {
+ strong: true,
+ text: `not affiliated with, sponsored by, or endorsed by ${organization}`,
+ },
+ { text: `. The ${organization} name is used only to identify that market role.` },
+ ],
+ simulated,
+ doNotEnterParagraph(),
+ ]);
+ };
+
+ const demoTitle = (options: DemoMetadataOptions): string => {
+ if (options.variant === "own") return `${options.appName} — Concept Demo`;
+ return `${options.appName} — Concept Demo (not ${options.organization})`;
+ };
+
+ const demoDescription = (options: DemoMetadataOptions): string => {
+ if (options.variant === "own") {
+ return `Concept demonstration by the ${convener}. All data simulated.`;
+ }
+ return `Concept demonstration by the ${convener} — not affiliated with ${options.organization}. All data simulated.`;
+ };
+
+ const pack: DisclaimerPack = {
+ aboutHref: parsed.aboutHref ?? "/",
+ bannerCompactCopy,
+ bannerCompactText: (options) => {
+ const copy = bannerCompactCopy(options);
+ return `${copy.lead}${copy.rest}`;
+ },
+ bannerFullCopy,
+ bannerFullText: (options) => {
+ const copy = bannerFullCopy(options);
+ return `${copy.lead}${copy.rest}`;
+ },
+ branding: parsed,
+ consentCookiePrefix: parsed.consentCookiePrefix ?? DEFAULT_CONSENT_COOKIE_PREFIX,
+ convener,
+ demoDescription,
+ demoFieldHint: DEMO_FIELD_HINT,
+ demoMetadata: (options) => {
+ const title = demoTitle(options);
+ const description = demoDescription(options);
+ return {
+ description,
+ openGraph: { description, title },
+ robots: { follow: false, index: false },
+ title,
+ };
+ },
+ demoTitle,
+ footerLegalLine: (options) => {
+ const segments = ["Concept demonstration", convener];
+ if (options.variant !== "own") {
+ segments.push(`Not affiliated with, sponsored by, or endorsed by ${options.organization}`);
+ }
+ segments.push("All data simulated");
+ if (primaryCompact !== undefined) segments.push(primaryCompact.replace(/\.$/, ""));
+ segments.push("Do not enter real personal information");
+ return segments.join(" · ");
+ },
+ illustrativeTag,
+ interstitialParagraphs,
+ };
+ return Object.freeze(pack);
+}
diff --git a/packages/solid-showcase-kit/src/honesty-panel.tsx b/packages/solid-showcase-kit/src/honesty-panel.tsx
new file mode 100644
index 000000000..d94a34f03
--- /dev/null
+++ b/packages/solid-showcase-kit/src/honesty-panel.tsx
@@ -0,0 +1,45 @@
+// AUTHORED-BY Claude Fable 5
+import { cn } from "@jeswr/app-shell";
+import type { ReactNode } from "react";
+
+export interface HonestyPanelProps {
+ /** What actually happens (real Solid reads/writes, real VC signatures, …). */
+ real: ReactNode;
+ /** What is simulated (rates, decisions, personas, org involvement, …). */
+ simulated: ReactNode;
+ defaultOpen?: boolean | undefined;
+ className?: string | undefined;
+}
+
+/**
+ * Collapsible "what is real / what is simulated" panel; content is injected per app.
+ * Built on native / so keyboard and screen-reader behaviour come free.
+ */
+export function HonestyPanel({
+ real,
+ simulated,
+ defaultOpen = false,
+ className,
+}: HonestyPanelProps) {
+ return (
+
+
+ What is real and what is simulated?
+
+
+
+
+ Simulated
+ {simulated}
+
+
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/illustrative-figure.tsx b/packages/solid-showcase-kit/src/illustrative-figure.tsx
new file mode 100644
index 000000000..4c79bf3c1
--- /dev/null
+++ b/packages/solid-showcase-kit/src/illustrative-figure.tsx
@@ -0,0 +1,35 @@
+// AUTHORED-BY Claude Fable 5
+"use client";
+
+import type { CSSProperties, ReactNode } from "react";
+import { type DisclaimerPack, ILLUSTRATIVE_FIGURE_TAG } from "./disclaimers.js";
+import { useShowcaseTrust } from "./trust-context.js";
+
+export interface IllustrativeFigureProps {
+ /** The rendered rate/fee/payment/decision, e.g. "6.875%". */
+ children: ReactNode;
+ /** Explicit pack; defaults to the nearest `ShowcaseTrustProvider`, then the default tag. */
+ pack?: DisclaimerPack | undefined;
+}
+
+const TAG_STYLE: CSSProperties = {
+ fontSize: "0.75em",
+ fontWeight: 400,
+};
+
+/**
+ * Illustrative-figure tag: wraps any rendered rate, fee, payment, or decision so the
+ * qualifier sits in the same element and travels with the number into any screenshot.
+ * Self-contained inline styles by design. The tag ALWAYS renders — without a provider it
+ * falls back to the default wording rather than dropping the qualifier.
+ */
+export function IllustrativeFigure({ children, pack }: IllustrativeFigureProps) {
+ const trust = useShowcaseTrust();
+ const tag = pack?.illustrativeTag ?? trust?.pack.illustrativeTag ?? ILLUSTRATIVE_FIGURE_TAG;
+ return (
+
+ {children}
+ {` — ${tag}`}
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/index.ts b/packages/solid-showcase-kit/src/index.ts
new file mode 100644
index 000000000..5cef802d2
--- /dev/null
+++ b/packages/solid-showcase-kit/src/index.ts
@@ -0,0 +1,62 @@
+// AUTHORED-BY Claude Fable 5
+/**
+ * @jeswr/solid-showcase-kit — de-branded disclaimer/branding/trust pack for Solid concept
+ * demos. Browser-safe: this root export must never import node builtins (the insignia
+ * scanner and e2e helpers live under `@jeswr/solid-showcase-kit/testing`).
+ */
+
+export { AppShell, type AppShellProps } from "./app-shell.js";
+export {
+ type BannedMark,
+ type BrandingConfig,
+ bannedMarkSchema,
+ brandingConfigSchema,
+ type ThemeSpec,
+ themeSpecSchema,
+} from "./branding.js";
+export { ConceptDemoBanner, type ConceptDemoBannerProps } from "./concept-demo-banner.js";
+export { ConsentInterstitial, type ConsentInterstitialProps } from "./consent-interstitial.js";
+export {
+ CredentialCard,
+ type CredentialCardProps,
+ type CredentialStatus,
+} from "./credential-card.js";
+export { DemoLockedField, type DemoLockedFieldProps } from "./demo-locked-field.js";
+export {
+ type BannerCopy,
+ type BannerCopyOptions,
+ type CopySegment,
+ copyText,
+ createDisclaimerPack,
+ DEFAULT_CONSENT_COOKIE_PREFIX,
+ DEMO_FIELD_HINT,
+ type DemoMetadata,
+ type DemoMetadataOptions,
+ type DisclaimerPack,
+ type DisclaimerVariant,
+ type FooterCopyOptions,
+ ILLUSTRATIVE_FIGURE_TAG,
+ INTERSTITIAL_CONTINUE_LABEL,
+ INTERSTITIAL_HEADING,
+ INTERSTITIAL_LEARN_MORE_LABEL,
+} from "./disclaimers.js";
+export { HonestyPanel, type HonestyPanelProps } from "./honesty-panel.js";
+export { IllustrativeFigure, type IllustrativeFigureProps } from "./illustrative-figure.js";
+export { type ReceiptAction, ReceiptCard, type ReceiptCardProps } from "./receipt-card.js";
+export { StatCard, type StatCardProps } from "./stat-card.js";
+export {
+ type OrgTheme,
+ type OrgThemeTokens,
+ type PaletteOptions,
+ paletteTokens,
+ type ThemeStyleProperties,
+ themeCssProperties,
+ themeFromSpec,
+} from "./themes.js";
+export {
+ ShowcaseTrustProvider,
+ type ShowcaseTrustProviderProps,
+ type ShowcaseTrustValue,
+ useDisclaimerPack,
+ useShowcaseTrust,
+} from "./trust-context.js";
diff --git a/packages/solid-showcase-kit/src/receipt-card.tsx b/packages/solid-showcase-kit/src/receipt-card.tsx
new file mode 100644
index 000000000..727c39982
--- /dev/null
+++ b/packages/solid-showcase-kit/src/receipt-card.tsx
@@ -0,0 +1,62 @@
+// AUTHORED-BY Claude Fable 5
+import { cn } from "@jeswr/app-shell";
+
+export type ReceiptAction = "grant" | "revoke";
+
+export interface ReceiptCardProps {
+ action: ReceiptAction;
+ /** WebID of the person who approved the change. */
+ actor: string;
+ /** WebID of the organisation whose access changed. */
+ recipient: string;
+ /** Pod resource covered by the consent change. */
+ resource: string;
+ /** ISO 8601 receipt timestamp. */
+ issuedAt: string;
+ className?: string | undefined;
+}
+
+/**
+ * One app-level DPV consent receipt. The card deliberately calls itself an app receipt:
+ * WAC enforces access, but neither WAC nor the Solid protocol supplies this audit trail.
+ */
+export function ReceiptCard({
+ action,
+ actor,
+ recipient,
+ resource,
+ issuedAt,
+ className,
+}: ReceiptCardProps) {
+ const label = action === "grant" ? "Access granted" : "Access revoked";
+ return (
+
+
+
{label}
+
+ {new Date(issuedAt).toLocaleString("en-US", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: "UTC",
+ })}
+ {" UTC"}
+
+
+
+ Approved by
+ {actor}
+ Organisation
+ {recipient}
+ Resource
+ {resource}
+
+
+ DPV consent record written by this application to the data subject's pod.
+
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/stat-card.tsx b/packages/solid-showcase-kit/src/stat-card.tsx
new file mode 100644
index 000000000..727dfb2f7
--- /dev/null
+++ b/packages/solid-showcase-kit/src/stat-card.tsx
@@ -0,0 +1,30 @@
+// AUTHORED-BY Claude Fable 5
+import { cn } from "@jeswr/app-shell";
+import type { ReactNode } from "react";
+import { IllustrativeFigure } from "./illustrative-figure.js";
+
+export interface StatCardProps {
+ label: string;
+ value: ReactNode;
+ /** Secondary line below the value. */
+ detail?: ReactNode | undefined;
+ /**
+ * Set for any rate/fee/payment/decision value: wraps it in the illustrative-figure
+ * tag so the qualifier travels with the number.
+ */
+ illustrative?: boolean | undefined;
+ className?: string | undefined;
+}
+
+/** Compact stat tile for dashboards; money/rate values must set `illustrative`. */
+export function StatCard({ label, value, detail, illustrative = false, className }: StatCardProps) {
+ return (
+
+
{label}
+
+ {illustrative ? {value} : value}
+
+ {detail !== undefined &&
{detail}
}
+
+ );
+}
diff --git a/packages/solid-showcase-kit/src/testing/assertions.ts b/packages/solid-showcase-kit/src/testing/assertions.ts
new file mode 100644
index 000000000..98029a1e7
--- /dev/null
+++ b/packages/solid-showcase-kit/src/testing/assertions.ts
@@ -0,0 +1,72 @@
+// AUTHORED-BY Claude Fable 5
+/**
+ * Framework-agnostic e2e assertion helpers for the trust surfaces: stable selectors +
+ * the exact expected copy, derived from the SAME disclaimer pack the components render
+ * from, so asserted copy can never drift from rendered copy.
+ *
+ * Deliberately not coupled to any e2e runner — a Playwright suite uses them as:
+ *
+ * ```ts
+ * const banner = page.locator(disclaimerAssertions.bannerSelector).first();
+ * await expect(banner).toContainText(
+ * disclaimerAssertions.expectedBannerText(pack, { organization, role }),
+ * );
+ * ```
+ */
+import {
+ copyText,
+ type DisclaimerPack,
+ type DisclaimerVariant,
+ INTERSTITIAL_CONTINUE_LABEL,
+ INTERSTITIAL_HEADING,
+} from "../disclaimers.js";
+
+/** One branded surface's banner identity (org/role/variant). */
+export interface BannerIdentity {
+ organization: string;
+ role?: string | undefined;
+ variant?: DisclaimerVariant | undefined;
+}
+
+export const disclaimerAssertions = {
+ /** ARIA name of the banner `role="note"` element. */
+ bannerAriaLabel: "Concept demonstration notice",
+ /** The unremovable concept-demo banner (pages may carry extra per-section banners; the shell banner renders first). */
+ bannerSelector: "[data-concept-demo-banner]",
+ /** Visible label of the banner's about link (desktop variant). */
+ bannerAboutLabel: "About this demo",
+ /** The `AppShell` frame. */
+ appShellSelector: "[data-app-shell]",
+ /** The footer carrying the legal line. */
+ footerSelector: "[data-demo-footer]",
+ /** The consent interstitial overlay. */
+ interstitialSelector: "[data-consent-interstitial]",
+ /** ARIA name of the interstitial dialog. */
+ interstitialHeading: INTERSTITIAL_HEADING,
+ /** Label of the affirmative-continue button. */
+ interstitialContinueLabel: INTERSTITIAL_CONTINUE_LABEL,
+ /** Full required desktop banner copy for a surface. */
+ expectedBannerText(pack: DisclaimerPack, identity: BannerIdentity): string {
+ return pack.bannerFullText({
+ organization: identity.organization,
+ role: identity.role,
+ variant: identity.variant,
+ });
+ },
+ /** Full required footer legal line for a surface. */
+ expectedFooterText(pack: DisclaimerPack, identity: BannerIdentity): string {
+ return pack.footerLegalLine({
+ organization: identity.organization,
+ variant: identity.variant,
+ });
+ },
+ /** The verbatim negation paragraph the interstitial must contain. */
+ expectedNegationParagraph(
+ pack: DisclaimerPack,
+ organization: string,
+ variant: DisclaimerVariant = "modelled",
+ ): string {
+ const paragraph = pack.interstitialParagraphs(organization, variant)[1];
+ return paragraph === undefined ? "" : copyText(paragraph);
+ },
+} as const;
diff --git a/packages/solid-showcase-kit/src/testing/index.ts b/packages/solid-showcase-kit/src/testing/index.ts
new file mode 100644
index 000000000..1883d0c3c
--- /dev/null
+++ b/packages/solid-showcase-kit/src/testing/index.ts
@@ -0,0 +1,19 @@
+// AUTHORED-BY Claude Fable 5
+/**
+ * `@jeswr/solid-showcase-kit/testing` — node-side helpers for repo gates and e2e suites.
+ * This subpath MAY use node builtins; the package root must stay browser-safe.
+ */
+
+export { type BannerIdentity, disclaimerAssertions } from "./assertions.js";
+export {
+ type InsigniaFinding,
+ type InsigniaOptions,
+ type InsigniaRule,
+ type InsigniaTreeOptions,
+ insigniaFindings,
+ insigniaPathFindings,
+ insigniaRules,
+ SCANNED_EXTENSIONS,
+ SKIPPED_DIRECTORIES,
+ scanInsigniaTree,
+} from "./insignia.js";
diff --git a/packages/solid-showcase-kit/src/testing/insignia.ts b/packages/solid-showcase-kit/src/testing/insignia.ts
new file mode 100644
index 000000000..2a27671a2
--- /dev/null
+++ b/packages/solid-showcase-kit/src/testing/insignia.ts
@@ -0,0 +1,259 @@
+// AUTHORED-BY Claude Fable 5
+/**
+ * Bright-line insignia guard — the generic scanner MECHANISM behind per-repo
+ * `check-insignia` scripts.
+ *
+ * Scans rendered-source text and file trees for marks that must never render on any demo
+ * surface. There is deliberately no allowlist and no inline-suppression mechanism: banned
+ * marks are bright lines.
+ *
+ * The kit ships NO built-in banned-marks roster — it is domain-generic. Consumers supply
+ * their own {@link BannedMark} list (typically from their `BrandingConfig.bannedMarks`);
+ * what is banned is domain knowledge that lives with the consumer.
+ *
+ * Node-only (fs walker) — this module lives under the `./testing` subpath and must never
+ * be re-exported from the browser-safe root.
+ */
+import { lstatSync, readdirSync, readFileSync } from "node:fs";
+import { join, relative } from "node:path";
+import type { BannedMark } from "../branding.js";
+
+export interface InsigniaRule {
+ id: string;
+ pattern: RegExp;
+ reason: string;
+}
+
+export interface InsigniaFinding {
+ /** Path relative to `rootDir` for tree scans; the `file` option (or "") for text scans. */
+ file: string;
+ /** 1-based line for content findings; 0 for file-path findings. */
+ line: number;
+ id: string;
+ reason: string;
+ excerpt: string;
+}
+
+export interface InsigniaOptions {
+ /** The consumer's never-render roster (see {@link BannedMark} for pattern casing). */
+ bannedMarks?: BannedMark[] | undefined;
+}
+
+/**
+ * Compile a {@link BannedMark} pattern source. Case-insensitive unless the source
+ * contains an uppercase character — an uppercase source is a standalone-token rule
+ * (e.g. `\bXYZ\b`) whose casing is deliberate.
+ */
+function compileMark(source: string): RegExp {
+ return new RegExp(source, /[A-Z]/.test(source) ? "" : "i");
+}
+
+/** The effective rule list for the caller-supplied banned marks. */
+export function insigniaRules(options: InsigniaOptions = {}): InsigniaRule[] {
+ return (options.bannedMarks ?? []).map((mark, index) => ({
+ id: `banned-mark-${index + 1}`,
+ pattern: compileMark(mark.pattern),
+ reason: mark.reason,
+ }));
+}
+
+/**
+ * Whitespace-normalise `text` (all runs of whitespace, INCLUDING newlines, collapse to a
+ * single space) while tracking the original 1-based line of every kept character — so a
+ * banned phrase split across a line break cannot evade a `\s+` rule.
+ */
+function normalizeWithLines(text: string): { normalized: string; lineOfChar: number[] } {
+ const chars: string[] = [];
+ const lineOfChar: number[] = [];
+ let line = 1;
+ let pendingSpace = false;
+ for (const ch of text) {
+ if (ch === "\n") {
+ line += 1;
+ pendingSpace = true;
+ continue;
+ }
+ if (/\s/.test(ch)) {
+ pendingSpace = true;
+ continue;
+ }
+ if (pendingSpace && chars.length > 0) {
+ chars.push(" ");
+ lineOfChar.push(line);
+ }
+ pendingSpace = false;
+ chars.push(ch);
+ lineOfChar.push(line);
+ }
+ return { lineOfChar, normalized: chars.join("") };
+}
+
+/**
+ * Scan a text blob; returns one finding per (rule, line) hit with 1-based line numbers
+ * and a ≤120-character excerpt. Two passes: per line (precise excerpts), then over the
+ * whitespace-normalised whole text so marks split across line breaks are still caught
+ * (attributed to the line where the match starts).
+ */
+export function insigniaFindings(
+ text: string,
+ options: InsigniaOptions & { file?: string | undefined } = {},
+): InsigniaFinding[] {
+ const rules = insigniaRules(options);
+ const file = options.file ?? "";
+ const findings: InsigniaFinding[] = [];
+ const seen = new Set();
+ const lines = text.split(/\r?\n/);
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index] as string;
+ for (const { id, pattern, reason } of rules) {
+ if (!pattern.test(line)) continue;
+ seen.add(`${id}@${index + 1}`);
+ findings.push({
+ excerpt: line.trim().slice(0, 120),
+ file,
+ id,
+ line: index + 1,
+ reason,
+ });
+ }
+ }
+ const { normalized, lineOfChar } = normalizeWithLines(text);
+ for (const { id, pattern, reason } of rules) {
+ const global = new RegExp(pattern.source, `${pattern.flags}g`);
+ for (const match of normalized.matchAll(global)) {
+ const startLine = lineOfChar[match.index] ?? 1;
+ const key = `${id}@${startLine}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ findings.push({
+ excerpt: normalized
+ .slice(match.index, match.index + 120)
+ .trim()
+ .slice(0, 120),
+ file,
+ id,
+ line: startLine,
+ reason,
+ });
+ }
+ }
+ return findings;
+}
+
+/**
+ * Check a (relative) file PATH against the rules — catches prohibited imagery whose
+ * binary content cannot be grepped (a revealing file name, or a generic basename under a
+ * revealing directory). Path separators and `-_.` are normalised to spaces; the
+ * uppercased form additionally lets case-sensitive standalone-token rules catch
+ * conventionally lowercase path segments. Findings carry `line: 0`.
+ */
+export function insigniaPathFindings(
+ relativePath: string,
+ options: InsigniaOptions = {},
+): InsigniaFinding[] {
+ const rules = insigniaRules(options);
+ const normalizedPath = relativePath.replace(/[\\/\-_.]/g, " ");
+ const findings: InsigniaFinding[] = [];
+ for (const { id, pattern, reason } of rules) {
+ if (
+ !pattern.test(relativePath) &&
+ !pattern.test(normalizedPath) &&
+ !pattern.test(normalizedPath.toUpperCase())
+ )
+ continue;
+ findings.push({
+ excerpt: `(file path) ${relativePath}`,
+ file: relativePath,
+ id,
+ line: 0,
+ reason,
+ });
+ }
+ return findings;
+}
+
+/** Text-like extensions whose CONTENT is scanned (paths are checked for every file). */
+export const SCANNED_EXTENSIONS: ReadonlySet = new Set([
+ ".cjs",
+ ".css",
+ ".html",
+ ".js",
+ ".json",
+ ".jsx",
+ ".md",
+ ".mdx",
+ ".mjs",
+ ".svg",
+ ".ts",
+ ".tsx",
+ ".txt",
+ ".yaml",
+ ".yml",
+]);
+
+/** Build/dependency directories never scanned. */
+export const SKIPPED_DIRECTORIES: ReadonlySet = new Set([
+ ".next",
+ ".turbo",
+ "dist",
+ "node_modules",
+]);
+
+function* walkFiles(directory: string): Generator {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
+ const path = join(directory, entry.name);
+ if (entry.isSymbolicLink()) {
+ // Fail closed: a symlink could smuggle content past the scan (its target lives
+ // outside the walked tree) or hide a banned real path behind a clean link name.
+ throw new Error(
+ `insignia scan refuses symlink: ${path} — scanned trees must contain regular files only`,
+ );
+ }
+ if (entry.isDirectory()) yield* walkFiles(path);
+ else if (entry.isFile()) yield path;
+ }
+}
+
+function fileExtension(path: string): string {
+ const dot = path.lastIndexOf(".");
+ return dot === -1 ? "" : path.slice(dot).toLowerCase();
+}
+
+export interface InsigniaTreeOptions extends InsigniaOptions {
+ /** Root that findings' `file` paths are made relative to; default `process.cwd()`. */
+ rootDir?: string | undefined;
+}
+
+/**
+ * Walk directories and scan every file: the NAME/path of every file is checked
+ * regardless of extension; text files ({@link SCANNED_EXTENSIONS}) are additionally
+ * content-scanned. Symlinks anywhere in a scanned tree are rejected (throw) — fail
+ * closed rather than silently skipping or following them. Returns findings in walk
+ * order — path findings before content findings per file.
+ */
+export function scanInsigniaTree(
+ directories: string[],
+ options: InsigniaTreeOptions = {},
+): InsigniaFinding[] {
+ const rootDir = options.rootDir ?? process.cwd();
+ const findings: InsigniaFinding[] = [];
+ for (const directory of directories) {
+ // Fail closed at the ROOT too: readdirSync would silently follow a symlinked scan
+ // root, bypassing the in-tree rejection below.
+ if (lstatSync(directory).isSymbolicLink()) {
+ throw new Error(
+ `insignia scan refuses symlink: ${directory} — scanned trees must contain regular files only`,
+ );
+ }
+ for (const file of walkFiles(directory)) {
+ const relativePath = relative(rootDir, file);
+ findings.push(...insigniaPathFindings(relativePath, options));
+ if (!SCANNED_EXTENSIONS.has(fileExtension(file))) continue;
+ findings.push(
+ ...insigniaFindings(readFileSync(file, "utf8"), { ...options, file: relativePath }),
+ );
+ }
+ }
+ return findings;
+}
diff --git a/packages/solid-showcase-kit/src/themes.ts b/packages/solid-showcase-kit/src/themes.ts
new file mode 100644
index 000000000..ac9b55d39
--- /dev/null
+++ b/packages/solid-showcase-kit/src/themes.ts
@@ -0,0 +1,103 @@
+// AUTHORED-BY Claude Fable 5
+/**
+ * Per-organisation theme factory.
+ *
+ * Policy the factory encodes: palette-INSPIRED, adjacent-but-distinct — a theme sits in
+ * the hue neighbourhood of the modelled organisation's public palette family but
+ * deliberately does NOT reproduce any brand-guideline colour value; every token should be
+ * an original (e.g. oklch) value. No logos or trade dress anywhere; naming is role-first
+ * ("modelled on X", never "by X"). The palette DATA stays with consumers (registry /
+ * walkthrough documents) — this module ships only the generic factory.
+ *
+ * Consumption: the tokens are CSS custom properties matching the common `globals.css`
+ * convention (`--background`, `--primary`, …) which each app maps to Tailwind via
+ * `@theme inline` (`--color-background: var(--background)`, …). Apply a theme by
+ * spreading `themeCssProperties(theme)` onto a wrapper's `style` — `AppShell` does this
+ * for you. Apps that use the `accent` tokens must add the
+ * `--color-accent`/`--color-accent-foreground` mappings to their `@theme inline` block.
+ */
+import { type ThemeSpec, themeSpecSchema } from "./branding.js";
+
+export interface OrgThemeTokens {
+ background: string;
+ foreground: string;
+ card: string;
+ "card-foreground": string;
+ muted: string;
+ "muted-foreground": string;
+ border: string;
+ primary: string;
+ "primary-foreground": string;
+ accent: string;
+ "accent-foreground": string;
+}
+
+export interface OrgTheme {
+ /** Optional stable identifier (consumers may key registries by it). */
+ id?: string | undefined;
+ /** Display name for the "modelled on {X}" framing. */
+ modelledOn: string;
+ /** Journey role; reads well in "the {role} role" and in header framing. */
+ role: string;
+ tokens: OrgThemeTokens;
+}
+
+const LIGHT_FOREGROUND = "oklch(0.985 0.003 250)";
+
+export interface PaletteOptions {
+ /** Neutral hue anchor for background/foreground/muted/border. */
+ hue: number;
+ primary: string;
+ primaryForeground?: string | undefined;
+ accent: string;
+ accentForeground?: string | undefined;
+}
+
+/** Derive the full token set from a hue anchor + primary/accent colours. */
+export function paletteTokens(options: PaletteOptions): OrgThemeTokens {
+ const { hue } = options;
+ return {
+ accent: options.accent,
+ "accent-foreground": options.accentForeground ?? LIGHT_FOREGROUND,
+ background: `oklch(0.985 0.004 ${hue})`,
+ border: `oklch(0.87 0.018 ${hue})`,
+ card: "oklch(1 0 0)",
+ "card-foreground": `oklch(0.21 0.03 ${hue})`,
+ foreground: `oklch(0.21 0.03 ${hue})`,
+ muted: `oklch(0.93 0.012 ${hue})`,
+ "muted-foreground": `oklch(0.45 0.03 ${hue})`,
+ primary: options.primary,
+ "primary-foreground": options.primaryForeground ?? LIGHT_FOREGROUND,
+ };
+}
+
+/**
+ * Build an {@link OrgTheme} from a data-driven {@link ThemeSpec} (validated with
+ * {@link themeSpecSchema}; throws on violation) and the modelled organisation's display
+ * name.
+ */
+export function themeFromSpec(spec: ThemeSpec, modelledOn: string): OrgTheme {
+ const parsed = themeSpecSchema.parse(spec);
+ return {
+ modelledOn,
+ role: parsed.role,
+ tokens: paletteTokens({
+ accent: parsed.accent,
+ accentForeground: parsed.accentForeground,
+ hue: parsed.hue,
+ primary: parsed.primary,
+ primaryForeground: parsed.primaryForeground,
+ }),
+ };
+}
+
+export type ThemeStyleProperties = Record<`--${string}`, string>;
+
+/** CSS custom properties for a theme, ready to spread onto a `style` prop. */
+export function themeCssProperties(theme: OrgTheme): ThemeStyleProperties {
+ const style: ThemeStyleProperties = {};
+ for (const [token, value] of Object.entries(theme.tokens)) {
+ style[`--${token}`] = value;
+ }
+ return style;
+}
diff --git a/packages/solid-showcase-kit/src/trust-context.tsx b/packages/solid-showcase-kit/src/trust-context.tsx
new file mode 100644
index 000000000..5aff1d331
--- /dev/null
+++ b/packages/solid-showcase-kit/src/trust-context.tsx
@@ -0,0 +1,55 @@
+// AUTHORED-BY Claude Fable 5
+"use client";
+
+import { createContext, type ReactNode, useContext, useMemo } from "react";
+import type { DisclaimerPack } from "./disclaimers.js";
+import type { OrgTheme } from "./themes.js";
+
+export interface ShowcaseTrustValue {
+ pack: DisclaimerPack;
+ theme?: OrgTheme | undefined;
+}
+
+const ShowcaseTrustContext = createContext(null);
+
+export interface ShowcaseTrustProviderProps {
+ /** The pack from `createDisclaimerPack(branding)`. */
+ pack: DisclaimerPack;
+ /** Optional org theme consumed by `AppShell` when no `theme` prop is passed. */
+ theme?: OrgTheme | undefined;
+ children: ReactNode;
+}
+
+/**
+ * Provides the disclaimer pack (and optionally the org theme) to every trust component
+ * below it. Wrap each app's root layout once:
+ *
+ * ```tsx
+ * {children}
+ * ```
+ */
+export function ShowcaseTrustProvider({ pack, theme, children }: ShowcaseTrustProviderProps) {
+ const value = useMemo(() => ({ pack, theme }), [pack, theme]);
+ return {children} ;
+}
+
+/** The current trust context, or `null` outside a provider. */
+export function useShowcaseTrust(): ShowcaseTrustValue | null {
+ return useContext(ShowcaseTrustContext);
+}
+
+/**
+ * Resolve the disclaimer pack for a trust component: an explicit `pack` prop wins, then
+ * the nearest {@link ShowcaseTrustProvider}. Fail-closed: with neither, this THROWS —
+ * a trust surface must never render without its required copy.
+ */
+export function useDisclaimerPack(explicit?: DisclaimerPack | undefined): DisclaimerPack {
+ const context = useContext(ShowcaseTrustContext);
+ const pack = explicit ?? context?.pack;
+ if (pack === undefined) {
+ throw new Error(
+ "No DisclaimerPack available: pass the `pack` prop or wrap the tree in .",
+ );
+ }
+ return pack;
+}
diff --git a/packages/solid-showcase-kit/test/banner.test.tsx b/packages/solid-showcase-kit/test/banner.test.tsx
new file mode 100644
index 000000000..7d2040a5f
--- /dev/null
+++ b/packages/solid-showcase-kit/test/banner.test.tsx
@@ -0,0 +1,69 @@
+// AUTHORED-BY Claude Fable 5
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, expect, test } from "vitest";
+import { ConceptDemoBanner, ShowcaseTrustProvider } from "../src/index.js";
+import { referencePack as pack } from "./support/fixtures.js";
+
+afterEach(cleanup);
+
+function renderWithPack(ui: ReactNode) {
+ return render({ui} );
+}
+
+test("renders both banner variants with the pack copy and the about link", () => {
+ renderWithPack(
+ ,
+ );
+ const banner = screen.getByRole("note", { name: "Concept demonstration notice" });
+ expect(banner.textContent).toContain(
+ pack.bannerFullText({ organization: "Acme Vehicle Hire", role: "vehicle hire operator" }),
+ );
+ expect(banner.textContent).toContain(
+ pack.bannerCompactText({ organization: "Acme Vehicle Hire", role: "vehicle hire operator" }),
+ );
+ // At desktop width only the full variant is in the accessibility tree; the compact
+ // variant exists in the DOM but is display:none via the embedded media query.
+ const aboutLinks = screen.getAllByRole("link");
+ expect(aboutLinks.map((link) => link.textContent)).toEqual(["About this demo"]);
+ const compactLink = banner.querySelector("[data-cdb-compact] a");
+ expect(compactLink?.textContent).toBe("About");
+ expect(aboutLinks[0]?.getAttribute("href")).toBe("/");
+ expect(compactLink?.getAttribute("href")).toBe("/");
+});
+
+test("stub-compatible: organization-only props render source and offer negation", () => {
+ renderWithPack( );
+ const banner = screen.getByRole("note", { name: "Concept demonstration notice" });
+ expect(banner.textContent).toContain("Concept demo — this is not Initech Logistics.");
+ expect(banner.textContent).toContain("Nothing here is an offer of hire or insurance.");
+ expect(banner.textContent).not.toContain("modelled on the");
+});
+
+test("own-branded variant drops the self-negation but keeps the offer negation", () => {
+ renderWithPack( );
+ const banner = screen.getByRole("note", { name: "Concept demonstration notice" });
+ expect(banner.textContent).not.toContain("this is not");
+ expect(banner.textContent).toContain("Nothing here is an offer of hire or insurance.");
+});
+
+test("placement: fixed-bottom by default, in-flow when static (AppShell)", () => {
+ const { unmount } = renderWithPack( );
+ expect(screen.getByRole("note", { name: "Concept demonstration notice" }).style.position).toBe(
+ "fixed",
+ );
+ unmount();
+ renderWithPack( );
+ expect(screen.getByRole("note", { name: "Concept demonstration notice" }).style.position).toBe(
+ "",
+ );
+});
+
+test("an explicit pack prop works without a provider (error-page usage)", () => {
+ const { container } = render( );
+ expect(container.querySelector("[data-concept-demo-banner]")).not.toBeNull();
+ // The component API is copy-only: no prop combination may suppress rendering.
+ expect(container.textContent).toContain("Concept demo");
+});
diff --git a/packages/solid-showcase-kit/test/browser-safety.test.ts b/packages/solid-showcase-kit/test/browser-safety.test.ts
new file mode 100644
index 000000000..f70253d7d
--- /dev/null
+++ b/packages/solid-showcase-kit/test/browser-safety.test.ts
@@ -0,0 +1,46 @@
+// AUTHORED-BY Claude Fable 5
+// The root export is browser-safe by contract: no node builtin may be imported anywhere
+// in the module graph reachable from src/index.ts. Node usage is confined to the
+// ./testing subpath.
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, test } from "vitest";
+
+const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..", "src");
+
+function* walk(directory: string): Generator {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) yield* walk(path);
+ else if (/\.(?:ts|tsx)$/.test(entry.name)) yield path;
+ }
+}
+
+test("no module outside src/testing imports node builtins", () => {
+ const offenders: string[] = [];
+ for (const file of walk(SRC)) {
+ if (file.startsWith(join(SRC, "testing"))) continue;
+ const source = readFileSync(file, "utf8");
+ if (/from\s+["']node:|require\(\s*["']node:/.test(source)) offenders.push(file);
+ }
+ expect(offenders).toEqual([]);
+});
+
+test("root src modules never import from the testing subpath", () => {
+ const offenders: string[] = [];
+ for (const file of walk(SRC)) {
+ if (file.startsWith(join(SRC, "testing"))) continue;
+ const source = readFileSync(file, "utf8");
+ if (/from\s+["']\.\/testing\//.test(source)) offenders.push(file);
+ }
+ expect(offenders).toEqual([]);
+});
+
+test("package exports split the browser-safe root from the node-only testing subpath", () => {
+ const manifest = JSON.parse(readFileSync(resolve(SRC, "..", "package.json"), "utf8")) as {
+ exports: Record;
+ };
+ expect(manifest.exports["."]?.import).toBe("./dist/index.js");
+ expect(manifest.exports["./testing"]?.import).toBe("./dist/testing/index.js");
+});
diff --git a/packages/solid-showcase-kit/test/components.test.tsx b/packages/solid-showcase-kit/test/components.test.tsx
new file mode 100644
index 000000000..55327ee7f
--- /dev/null
+++ b/packages/solid-showcase-kit/test/components.test.tsx
@@ -0,0 +1,168 @@
+// AUTHORED-BY Claude Fable 5
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, test } from "vitest";
+import {
+ AppShell,
+ CredentialCard,
+ createDisclaimerPack,
+ DEMO_FIELD_HINT,
+ DemoLockedField,
+ HonestyPanel,
+ IllustrativeFigure,
+ ReceiptCard,
+ ShowcaseTrustProvider,
+ StatCard,
+} from "../src/index.js";
+import { referencePack as pack, tourTheme, walletTheme } from "./support/fixtures.js";
+
+afterEach(cleanup);
+
+test("IllustrativeFigure keeps the tag in the same element as the figure", () => {
+ const { container } = render(6.875% );
+ const figure = container.querySelector("[data-illustrative-figure]");
+ expect(figure?.textContent).toBe("6.875% — illustrative figure, not an offer");
+});
+
+test("IllustrativeFigure renders a branding-config tag override from the provider", () => {
+ const quotePack = createDisclaimerPack({
+ ...pack.branding,
+ illustrativeTag: "illustrative figure, not a quote",
+ });
+ const { container } = render(
+
+ £120
+ ,
+ );
+ expect(container.querySelector("[data-illustrative-figure]")?.textContent).toBe(
+ "£120 — illustrative figure, not a quote",
+ );
+});
+
+test("DemoLockedField is read-only and hint-described by default", () => {
+ render( );
+ const input = screen.getByLabelText("Full name");
+ expect(input.readOnly).toBe(true);
+ expect(input.value).toBe("Jordan Demo");
+ const hintId = input.getAttribute("aria-describedby");
+ expect(hintId).not.toBeNull();
+ expect(document.getElementById(hintId as string)?.textContent).toBe(DEMO_FIELD_HINT);
+});
+
+test("DemoLockedField editable variant stays prefilled and keeps the hint", () => {
+ render( );
+ const input = screen.getByLabelText("Preferred contact");
+ expect(input.readOnly).toBe(false);
+ expect(input.value).toBe("demo@example.org");
+ expect(screen.getByText(DEMO_FIELD_HINT)).toBeDefined();
+});
+
+test("AppShell frames the app with role-first header, adjacent banner, footer, and theme", () => {
+ const { container } = render(
+
+
+ content
+
+ ,
+ );
+ expect(screen.getByText("Data Vault")).toBeDefined();
+ expect(screen.getByText("consumer data custodian · modelled on Globex Telecom")).toBeDefined();
+ const banner = container.querySelector("[data-concept-demo-banner]");
+ expect(banner?.textContent).toContain(
+ pack.bannerFullText({ organization: walletTheme.modelledOn, role: walletTheme.role }),
+ );
+ expect(container.querySelector("[data-demo-footer]")?.textContent).toBe(
+ pack.footerLegalLine({ organization: walletTheme.modelledOn }),
+ );
+ const shell = container.querySelector("[data-app-shell]");
+ expect(shell?.style.getPropertyValue("--primary")).toBe(walletTheme.tokens.primary);
+});
+
+test("AppShell accepts an explicit theme prop over the provider theme", () => {
+ render(
+
+
+ content
+
+ ,
+ );
+ expect(screen.getByText("convener · Example Demo Collective")).toBeDefined();
+});
+
+test("AppShell own-branded variant frames without source-negation", () => {
+ const { container } = render(
+
+
+ content
+
+ ,
+ );
+ expect(container.querySelector("[data-concept-demo-banner]")?.textContent).not.toContain(
+ "this is not",
+ );
+ expect(container.querySelector("[data-demo-footer]")?.textContent).not.toContain(
+ "Not affiliated",
+ );
+});
+
+test("StatCard wraps illustrative values in the qualifier tag", () => {
+ const { container } = render(
+ ,
+ );
+ expect(screen.getByText("Quoted rate")).toBeDefined();
+ expect(container.querySelector("[data-illustrative-figure]")?.textContent).toBe(
+ "6.875% — illustrative figure, not an offer",
+ );
+ expect(screen.getByText("illustrative daily figure")).toBeDefined();
+});
+
+test("CredentialCard shows issuer, validity window, and a non-colour-only status", () => {
+ const { container } = render(
+ ,
+ );
+ expect(screen.getByText("Issued by employment and income verifier (demo)")).toBeDefined();
+ expect(screen.getByText("Valid from 2026-01-01 · until 2027-01-01")).toBeDefined();
+ const status = container.querySelector("[data-credential-status='revoked']");
+ expect(status?.textContent).toContain("Revoked");
+});
+
+test("HonestyPanel is a native disclosure with injected real/simulated content", () => {
+ const { container } = render(
+ Actual Solid pod reads } simulated={Rates and decisions
} />,
+ );
+ const details = container.querySelector("details[data-honesty-panel]");
+ expect(details?.open).toBe(false);
+ expect(screen.getByText("What is real and what is simulated?")).toBeDefined();
+ expect(screen.getByText("Actual Solid pod reads")).toBeDefined();
+ expect(screen.getByText("Rates and decisions")).toBeDefined();
+ cleanup();
+ const { container: openContainer } = render( );
+ expect(openContainer.querySelector("details")?.open).toBe(true);
+});
+
+test("ReceiptCard renders the app-level who, what, and when trail", () => {
+ const { container } = render(
+ ,
+ );
+ expect(screen.getByRole("heading", { name: "Access revoked", level: 3 })).toBeDefined();
+ expect(screen.getByText("https://pod.example/profile/card#me")).toBeDefined();
+ expect(screen.getByText("https://operator.example/profile/card#service")).toBeDefined();
+ expect(screen.getByText("https://pod.example/consents/closing-record")).toBeDefined();
+ expect(container.querySelector("time")?.getAttribute("datetime")).toBe(
+ "2026-07-16T14:30:00.000Z",
+ );
+ expect(screen.getByText(/written by this application/)).toBeDefined();
+});
diff --git a/packages/solid-showcase-kit/test/disclaimers.test.ts b/packages/solid-showcase-kit/test/disclaimers.test.ts
new file mode 100644
index 000000000..c249f9f50
--- /dev/null
+++ b/packages/solid-showcase-kit/test/disclaimers.test.ts
@@ -0,0 +1,163 @@
+// AUTHORED-BY Claude Fable 5
+import { expect, test } from "vitest";
+import { copyText, createDisclaimerPack } from "../src/index.js";
+import { referencePack as pack, referenceBranding } from "./support/fixtures.js";
+
+// GOLDEN TESTS — literal assertions pinning the exact strings the fictional
+// `referenceBranding` fixture produces, across every surface the pack parameterises:
+// convener substitution, the purpose clause, primary-negation placement (banner), the
+// "Nothing … is" → "Not …" compact derivation (compact banner + footer), the
+// interstitial's negation expansion, and both disclaimer variants.
+//
+// A real consumer proves ITS exact copy in its own repository by passing its real
+// BrandingConfig and asserting its own golden vectors — do not add real-project branding
+// here. Do not weaken these assertions; a copy-mechanism change must be deliberate.
+
+test("banner copy — desktop, modelled with role", () => {
+ expect(
+ pack.bannerFullText({ organization: "Acme Vehicle Hire", role: "vehicle hire operator" }),
+ ).toBe(
+ "Concept demo — this is not Acme Vehicle Hire. A research prototype by the Example Demo Collective, modelled on the vehicle hire operator role. All data is simulated. Nothing here is an offer of hire or insurance.",
+ );
+});
+
+test("banner copy — desktop, modelled without a role clause", () => {
+ expect(pack.bannerFullText({ organization: "Globex Telecom" })).toBe(
+ "Concept demo — this is not Globex Telecom. A research prototype by the Example Demo Collective. All data is simulated. Nothing here is an offer of hire or insurance.",
+ );
+});
+
+test("banner copy — mobile-compressed variant derives the compact negation", () => {
+ expect(pack.bannerCompactText({ organization: "Globex Telecom" })).toBe(
+ "Concept demo — not Globex Telecom. Simulated data. Not an offer of hire or insurance.",
+ );
+});
+
+test("banner copy — own-branded (convener) variant drops the self-negation", () => {
+ expect(pack.bannerFullText({ organization: "Example Demo Collective", variant: "own" })).toBe(
+ "Concept demo. A research prototype by the Example Demo Collective. All data is simulated. Nothing here is an offer of hire or insurance.",
+ );
+ expect(pack.bannerCompactText({ organization: "Example Demo Collective", variant: "own" })).toBe(
+ "Concept demo. Simulated data. Not an offer of hire or insurance.",
+ );
+});
+
+test("footer legal line", () => {
+ expect(pack.footerLegalLine({ organization: "Globex Telecom" })).toBe(
+ "Concept demonstration · Example Demo Collective · Not affiliated with, sponsored by, or endorsed by Globex Telecom · All data simulated · Not an offer of hire or insurance · Do not enter real personal information",
+ );
+ expect(pack.footerLegalLine({ organization: "Anyone", variant: "own" })).not.toContain(
+ "Not affiliated",
+ );
+});
+
+test("interstitial copy — four paragraphs: purpose clause, negation, expansion, safety", () => {
+ const paragraphs = pack.interstitialParagraphs("Globex Telecom").map(copyText);
+ expect(paragraphs).toEqual([
+ "This site is a research prototype built by the Example Demo Collective to show how a vehicle-hire journey could work on Solid personal data stores.",
+ "It is modelled on the role Globex Telecom plays in that journey. It was not built by Globex Telecom and is not affiliated with, sponsored by, or endorsed by Globex Telecom. The Globex Telecom name is used only to identify that market role.",
+ "Everything here is simulated. Rates, fees, hire decisions, documents, and personas are fictitious and illustrative. Nothing on this site is an offer or solicitation of hire, insurance, or of any product or service.",
+ "Do not enter real personal or financial information. Use the demo personas provided.",
+ ]);
+});
+
+test("illustrative-figure tag and demo-field hint defaults", () => {
+ expect(pack.illustrativeTag).toBe("illustrative figure, not an offer");
+ expect(pack.demoFieldHint).toBe("Demo field — do not enter real information.");
+});
+
+test("demoMetadata — title suffix, OG description, noindex", () => {
+ expect(pack.demoTitle({ appName: "Data Vault", organization: "Globex Telecom" })).toBe(
+ "Data Vault — Concept Demo (not Globex Telecom)",
+ );
+ expect(pack.demoDescription({ appName: "Data Vault", organization: "Globex Telecom" })).toBe(
+ "Concept demonstration by the Example Demo Collective — not affiliated with Globex Telecom. All data simulated.",
+ );
+ const metadata = pack.demoMetadata({ appName: "Data Vault", organization: "Globex Telecom" });
+ expect(metadata.openGraph).toEqual({
+ description: metadata.description,
+ title: metadata.title,
+ });
+ expect(metadata.robots).toEqual({ follow: false, index: false });
+});
+
+test("demoMetadata — own-branded variant", () => {
+ const metadata = pack.demoMetadata({
+ appName: "Journey Tour",
+ organization: "Example Demo Collective",
+ variant: "own",
+ });
+ expect(metadata.title).toBe("Journey Tour — Concept Demo");
+ expect(metadata.description).toBe(
+ "Concept demonstration by the Example Demo Collective. All data simulated.",
+ );
+ expect(metadata.robots).toEqual({ follow: false, index: false });
+});
+
+test("interstitial own variant: shared core copy + collective negation", () => {
+ const own = pack.interstitialParagraphs("Example Demo Collective", "own").map(copyText);
+ const modelled = pack.interstitialParagraphs("Globex Telecom").map(copyText);
+ expect(own).toHaveLength(4);
+ // First, third, and fourth paragraphs are shared verbatim with the modelled variant.
+ expect(own[0]).toBe(modelled[0]);
+ expect(own[2]).toBe(modelled[2]);
+ expect(own[3]).toBe(modelled[3]);
+ // The negation paragraph negates the organisations named inside the demo, not the convener.
+ expect(own[1]).toBe(
+ "The organisations named inside the demo illustrate market roles only. None of them built this demo, and none is affiliated with, sponsors, or endorses it.",
+ );
+ expect(own[1]).not.toContain("Example Demo Collective");
+});
+
+test("a single-negation branding degrades gracefully across all surfaces", () => {
+ const bikes = createDisclaimerPack({
+ convener: "Example Research Lab",
+ description: "show how a bicycle-share journey could work on Solid personal data stores",
+ domainNegations: ["Nothing here is an offer of transport services."],
+ });
+ expect(bikes.bannerFullText({ organization: "Acme Bikes" })).toBe(
+ "Concept demo — this is not Acme Bikes. A research prototype by the Example Research Lab. All data is simulated. Nothing here is an offer of transport services.",
+ );
+ expect(bikes.bannerCompactText({ organization: "Acme Bikes" })).toBe(
+ "Concept demo — not Acme Bikes. Simulated data. Not an offer of transport services.",
+ );
+ expect(bikes.footerLegalLine({ organization: "Acme Bikes" })).toContain(
+ " · Not an offer of transport services · ",
+ );
+ // With one entry, the same negation serves the interstitial's simulation paragraph.
+ expect(copyText(bikes.interstitialParagraphs("Acme Bikes")[2] ?? [])).toBe(
+ "Everything here is simulated. Nothing here is an offer of transport services.",
+ );
+});
+
+test("createDisclaimerPack validates the branding config with the zod schema", () => {
+ expect(() =>
+ createDisclaimerPack({ ...referenceBranding, consentCookiePrefix: "NoTrailingDash" }),
+ ).toThrow();
+ expect(() => createDisclaimerPack({ ...referenceBranding, convener: "" })).toThrow();
+});
+
+test("pack surface defaults: aboutHref and consent cookie prefix", () => {
+ expect(pack.aboutHref).toBe("/");
+ expect(pack.consentCookiePrefix).toBe("ex-demo-consent-");
+ const bare = createDisclaimerPack({
+ convener: "Anyone",
+ description: "show a thing",
+ domainNegations: [],
+ });
+ expect(bare.consentCookiePrefix).toBe("demo-consent-");
+});
+
+test("FIX D: whitespace-only convener or description is rejected (no malformed copy)", () => {
+ expect(() => createDisclaimerPack({ ...referenceBranding, convener: " " })).toThrow();
+ expect(() => createDisclaimerPack({ ...referenceBranding, description: " \n " })).toThrow();
+ // Surrounding whitespace is trimmed before templating.
+ const padded = createDisclaimerPack({
+ ...referenceBranding,
+ convener: " Example Demo Collective ",
+ });
+ expect(padded.convener).toBe("Example Demo Collective");
+ expect(padded.bannerFullText({ organization: "Acme Bikes" })).toContain(
+ "by the Example Demo Collective.",
+ );
+});
diff --git a/packages/solid-showcase-kit/test/insignia.test.ts b/packages/solid-showcase-kit/test/insignia.test.ts
new file mode 100644
index 000000000..8be0f0205
--- /dev/null
+++ b/packages/solid-showcase-kit/test/insignia.test.ts
@@ -0,0 +1,155 @@
+// AUTHORED-BY Claude Fable 5
+// The insignia scanner MECHANISM, pinned against runtime-generated fictional fixtures:
+// content rules, file-path/directory-name detection, casing negatives, skipped dirs,
+// cross-line evasion, and symlink rejection. The kit ships no banned-marks roster —
+// every rule here is caller-supplied and fictional.
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, expect, test } from "vitest";
+import {
+ type InsigniaOptions,
+ insigniaFindings,
+ insigniaPathFindings,
+ insigniaRules,
+ scanInsigniaTree,
+} from "../src/testing/index.js";
+
+/** A fictional consumer roster exercising both casing modes of the pattern compiler. */
+const MARKS: InsigniaOptions = {
+ bannedMarks: [
+ { pattern: "\\bacme\\s+corp\\s+(?:seal|badge)\\b", reason: "fictional certification seal" },
+ { pattern: "\\bXYZ\\b", reason: "fictional standalone product token" },
+ ],
+};
+
+const VIOLATIONS = [
+ "Rendered with the Acme Corp seal in the header",
+ "an ACME CORP BADGE watermark",
+ "Submit the file to XYZ for a decision",
+].join("\n");
+
+const CLEAN = [
+ "Concept demo — this is a fictional walkthrough surface.",
+ "The word xylophone contains x-y but not the standalone token.",
+ "xyz in lowercase inside prose should not match the standalone-token rule.",
+ "Acme Corp alone, without seal or badge wording, is fine.",
+].join("\n");
+
+const ROOT = mkdtempSync(join(tmpdir(), "showcase-kit-insignia-"));
+const FIXTURES = join(ROOT, "fixtures");
+mkdirSync(join(FIXTURES, "acme-corp-seal"), { recursive: true });
+writeFileSync(join(FIXTURES, "violations.txt"), VIOLATIONS);
+writeFileSync(join(FIXTURES, "clean.txt"), CLEAN);
+writeFileSync(join(FIXTURES, "xyz-report.svg"), " ");
+writeFileSync(join(FIXTURES, "acme-corp-seal", "asset.svg"), " ");
+// Skipped directories are never scanned, even when their contents would match.
+mkdirSync(join(FIXTURES, "node_modules"), { recursive: true });
+writeFileSync(join(FIXTURES, "node_modules", "vendored.txt"), VIOLATIONS);
+
+afterAll(() => rmSync(ROOT, { recursive: true, force: true }));
+
+test("flags every caller-supplied banned mark in the violation fixture", () => {
+ const findings = scanInsigniaTree([FIXTURES], { ...MARKS, rootDir: ROOT });
+ const violationFindings = findings.filter((finding) => finding.file.endsWith("violations.txt"));
+ expect(violationFindings.map((finding) => [finding.id, finding.line])).toEqual([
+ ["banned-mark-1", 1],
+ ["banned-mark-1", 2],
+ ["banned-mark-2", 3],
+ ]);
+});
+
+test("flags banned filenames even when the file content is clean (image assets)", () => {
+ const findings = scanInsigniaTree([FIXTURES], { ...MARKS, rootDir: ROOT });
+ const filenameFindings = findings.filter((finding) => finding.file.endsWith("xyz-report.svg"));
+ expect(filenameFindings.map((finding) => finding.id)).toContain("banned-mark-2");
+ expect(filenameFindings.every((finding) => finding.line === 0)).toBe(true);
+});
+
+test("flags prohibited directory names carrying the mark past a generic basename", () => {
+ const findings = scanInsigniaTree([FIXTURES], { ...MARKS, rootDir: ROOT });
+ const pathFindings = findings.filter((finding) =>
+ finding.file.endsWith(join("acme-corp-seal", "asset.svg")),
+ );
+ expect(pathFindings.map((finding) => finding.id)).toContain("banned-mark-1");
+ expect(pathFindings.every((finding) => finding.line === 0)).toBe(true);
+});
+
+test("does not flag the clean fixture (word-boundary and casing negatives)", () => {
+ const findings = scanInsigniaTree([FIXTURES], { ...MARKS, rootDir: ROOT });
+ expect(findings.filter((finding) => finding.file.endsWith("clean.txt"))).toEqual([]);
+});
+
+test("never descends into skipped directories (node_modules, dist, .next, .turbo)", () => {
+ const findings = scanInsigniaTree([FIXTURES], { ...MARKS, rootDir: ROOT });
+ expect(findings.filter((finding) => finding.file.includes("node_modules"))).toEqual([]);
+});
+
+test("insigniaFindings scans a text blob with 1-based lines and trimmed excerpts", () => {
+ const findings = insigniaFindings("clean line\n the Acme Corp seal \n", MARKS);
+ expect(findings).toEqual([
+ {
+ excerpt: "the Acme Corp seal",
+ file: "",
+ id: "banned-mark-1",
+ line: 2,
+ reason: "fictional certification seal",
+ },
+ ]);
+});
+
+test("FIX B: a mark split across a line break cannot evade a \\s+ rule", () => {
+ // "Acme Corp\nseal" — no single line matches, but the whitespace-normalised text does.
+ const findings = insigniaFindings("Shows the Acme Corp\nseal proudly.\n", MARKS);
+ expect(findings).toHaveLength(1);
+ expect(findings[0]?.id).toBe("banned-mark-1");
+ // Attributed to the line where the match starts.
+ expect(findings[0]?.line).toBe(1);
+ // Single-line matches are not double-reported by the cross-line pass.
+ expect(insigniaFindings("the Acme Corp seal\n", MARKS)).toHaveLength(1);
+});
+
+test("FIX C: a symlink anywhere in a scanned tree is rejected, not silently skipped", () => {
+ const linkRoot = join(ROOT, "linked");
+ mkdirSync(linkRoot, { recursive: true });
+ writeFileSync(join(ROOT, "outside.txt"), VIOLATIONS); // outside the scanned tree
+ symlinkSync(join(ROOT, "outside.txt"), join(linkRoot, "innocent-name.txt"));
+ expect(() => scanInsigniaTree([linkRoot], { ...MARKS, rootDir: ROOT })).toThrow(
+ /refuses symlink/,
+ );
+});
+
+test("insigniaPathFindings checks path, normalised path, and uppercased normalised path", () => {
+ expect(insigniaPathFindings("public/xyz-findings.svg", MARKS).map((f) => f.id)).toContain(
+ "banned-mark-2",
+ );
+ expect(insigniaPathFindings("public/alpaca.svg", MARKS)).toEqual([]);
+});
+
+test("no built-in roster: empty options yield no rules and no findings", () => {
+ expect(insigniaRules({})).toEqual([]);
+ expect(insigniaFindings("the Acme Corp seal, XYZ, anything at all")).toEqual([]);
+});
+
+test("pattern casing heuristic: lowercase source is case-insensitive, uppercase exact", () => {
+ // Lowercase pattern source → case-insensitive.
+ expect(insigniaFindings("ACME CORP SEAL sheet", MARKS)).toHaveLength(1);
+ // Uppercase pattern source → case-sensitive (prose "xyz" must not match).
+ expect(insigniaFindings("xyz jour", MARKS)).toEqual([]);
+ expect(insigniaFindings("Submit to XYZ today", MARKS).map((f) => f.id)).toEqual([
+ "banned-mark-2",
+ ]);
+});
+
+test("FIX F: a symlinked scan ROOT is rejected, not silently followed", () => {
+ const realRoot = join(ROOT, "real-root");
+ mkdirSync(realRoot, { recursive: true });
+ writeFileSync(join(realRoot, "violations.txt"), VIOLATIONS);
+ const linkedRoot = join(ROOT, "linked-root");
+ symlinkSync(realRoot, linkedRoot);
+ expect(() => scanInsigniaTree([linkedRoot], { ...MARKS, rootDir: ROOT })).toThrow(
+ /refuses symlink/,
+ );
+ // The real directory itself still scans normally.
+ expect(scanInsigniaTree([realRoot], { ...MARKS, rootDir: ROOT }).length).toBeGreaterThan(0);
+});
diff --git a/packages/solid-showcase-kit/test/interstitial.test.tsx b/packages/solid-showcase-kit/test/interstitial.test.tsx
new file mode 100644
index 000000000..021698499
--- /dev/null
+++ b/packages/solid-showcase-kit/test/interstitial.test.tsx
@@ -0,0 +1,111 @@
+// AUTHORED-BY Claude Fable 5
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, expect, test } from "vitest";
+import {
+ ConsentInterstitial,
+ copyText,
+ INTERSTITIAL_CONTINUE_LABEL,
+ INTERSTITIAL_HEADING,
+ ShowcaseTrustProvider,
+} from "../src/index.js";
+import { referencePack as pack } from "./support/fixtures.js";
+
+const APP_ID = "test-app";
+
+function clearConsentCookie() {
+ // biome-ignore lint/suspicious/noDocumentCookie: jsdom has no Cookie Store API; test-only cleanup.
+ document.cookie = `${pack.consentCookiePrefix}${APP_ID}=; max-age=0; path=/`;
+}
+
+beforeEach(clearConsentCookie);
+afterEach(() => {
+ cleanup();
+ clearConsentCookie();
+});
+
+function renderWithPack(ui: ReactNode) {
+ return render({ui} );
+}
+
+test("first visit: renders the pack copy, blocks scroll, and takes focus", async () => {
+ renderWithPack( );
+ const dialog = screen.getByRole("dialog", { name: INTERSTITIAL_HEADING });
+ expect(dialog.getAttribute("aria-modal")).toBe("true");
+ for (const paragraph of pack.interstitialParagraphs("Globex Telecom")) {
+ expect(dialog.textContent).toContain(copyText(paragraph));
+ }
+ expect(screen.getByRole("link", { name: "Learn more about the project" })).toBeDefined();
+ expect(document.body.style.overflow).toBe("hidden");
+ await waitFor(() => expect(document.activeElement).toBe(dialog));
+});
+
+test("affirmative continue persists the branding-prefixed cookie and dismisses", () => {
+ renderWithPack( );
+ fireEvent.click(screen.getByRole("button", { name: INTERSTITIAL_CONTINUE_LABEL }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(document.cookie).toContain(`ex-demo-consent-${APP_ID}=1`);
+ expect(document.body.style.overflow).toBe("");
+});
+
+test("returning visit: consent cookie suppresses the interstitial", async () => {
+ // biome-ignore lint/suspicious/noDocumentCookie: jsdom has no Cookie Store API; test-only setup.
+ document.cookie = `${pack.consentCookiePrefix}${APP_ID}=1; path=/`;
+ renderWithPack( );
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+});
+
+test("Escape does not dismiss it — continuing must be affirmative", () => {
+ renderWithPack( );
+ fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
+ expect(screen.queryByRole("dialog")).not.toBeNull();
+});
+
+test("Tab is trapped inside the dialog", () => {
+ renderWithPack( );
+ const dialog = screen.getByRole("dialog");
+ const continueButton = screen.getByRole("button", { name: INTERSTITIAL_CONTINUE_LABEL });
+ const learnMoreLink = screen.getByRole("link", { name: "Learn more about the project" });
+ learnMoreLink.focus();
+ fireEvent.keyDown(dialog, { key: "Tab" });
+ expect(document.activeElement).toBe(continueButton);
+ fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true });
+ expect(document.activeElement).toBe(learnMoreLink);
+});
+
+test("own variant: collective role negation, no self-negation of the convener", () => {
+ renderWithPack(
+ ,
+ );
+ const dialog = screen.getByRole("dialog", { name: INTERSTITIAL_HEADING });
+ for (const paragraph of pack.interstitialParagraphs("Example Demo Collective", "own")) {
+ expect(dialog.textContent).toContain(copyText(paragraph));
+ }
+ expect(dialog.textContent).not.toContain("not built by Example Demo Collective");
+});
+
+test("FIX E: reuse with a NEW appId re-opens — per-app acknowledgement is enforced", async () => {
+ const { rerender } = renderWithPack(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: INTERSTITIAL_CONTINUE_LABEL }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ // Same component instance, different app with NO stored consent → must show again.
+ rerender(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeNull());
+ // And switching back to the consented app closes it again.
+ rerender(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ // biome-ignore lint/suspicious/noDocumentCookie: jsdom has no Cookie Store API; test-only cleanup.
+ document.cookie = `${pack.consentCookiePrefix}other-app=; max-age=0; path=/`;
+});
diff --git a/packages/solid-showcase-kit/test/safety-invariants.test.tsx b/packages/solid-showcase-kit/test/safety-invariants.test.tsx
new file mode 100644
index 000000000..9697a353c
--- /dev/null
+++ b/packages/solid-showcase-kit/test/safety-invariants.test.tsx
@@ -0,0 +1,115 @@
+// AUTHORED-BY Claude Fable 5
+// @vitest-environment jsdom
+
+// The kit's non-negotiables: NO prop or configuration path may drop the safety copy,
+// hide the banner, remove the demo-field hint, or break the interstitial's
+// four-paragraph structure. These tests exercise the hostile-config paths.
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, test } from "vitest";
+import {
+ AppShell,
+ ConceptDemoBanner,
+ copyText,
+ createDisclaimerPack,
+ DEMO_FIELD_HINT,
+ DemoLockedField,
+ IllustrativeFigure,
+ ShowcaseTrustProvider,
+} from "../src/index.js";
+import { walletTheme } from "./support/fixtures.js";
+
+afterEach(cleanup);
+
+/** A branding config trying to configure AWAY as much as the schema allows. */
+const hostilePack = createDisclaimerPack({
+ convener: "Anyone",
+ description: "show a thing",
+ domainNegations: [],
+ illustrativeTag: " ",
+});
+
+test("fixed safety copy renders even with zero domain negations", () => {
+ expect(hostilePack.bannerFullText({ organization: "Acme" })).toBe(
+ "Concept demo — this is not Acme. A research prototype by the Anyone. All data is simulated.",
+ );
+ expect(hostilePack.bannerCompactText({ organization: "Acme" })).toBe(
+ "Concept demo — not Acme. Simulated data.",
+ );
+ expect(hostilePack.footerLegalLine({ organization: "Acme" })).toBe(
+ "Concept demonstration · Anyone · Not affiliated with, sponsored by, or endorsed by Acme · All data simulated · Do not enter real personal information",
+ );
+});
+
+test("interstitial keeps its four-paragraph structure for every variant and config", () => {
+ for (const variant of ["modelled", "own"] as const) {
+ const paragraphs = hostilePack.interstitialParagraphs("Acme", variant);
+ expect(paragraphs).toHaveLength(4);
+ expect(copyText(paragraphs[2] ?? [])).toBe("Everything here is simulated.");
+ expect(copyText(paragraphs[3] ?? [])).toBe(
+ "Do not enter real personal or financial information. Use the demo personas provided.",
+ );
+ }
+});
+
+test("a blank illustrativeTag override falls back to the default qualifier", () => {
+ expect(hostilePack.illustrativeTag).toBe("illustrative figure, not an offer");
+});
+
+test("the pack is frozen — safety copy cannot be patched out after creation", () => {
+ expect(Object.isFrozen(hostilePack)).toBe(true);
+});
+
+test("AppShell always renders the adjacent banner and the footer legal line", () => {
+ // AppShellProps has no prop that hides either surface; this pins the minimal render.
+ const { container } = render(
+
+
+ content
+
+ ,
+ );
+ expect(container.querySelector("[data-concept-demo-banner]")?.textContent).toContain(
+ "Concept demo — this is not Globex Telecom.",
+ );
+ expect(container.querySelector("[data-demo-footer]")?.textContent).toContain(
+ "Do not enter real personal information",
+ );
+});
+
+test("trust components fail closed without a pack: they throw rather than render bare", () => {
+ expect(() => render( )).toThrow(/ShowcaseTrustProvider/);
+});
+
+test("IllustrativeFigure renders its qualifier even with no provider at all", () => {
+ const { container } = render(6.875% );
+ expect(container.querySelector("[data-illustrative-figure]")?.textContent).toBe(
+ "6.875% — illustrative figure, not an offer",
+ );
+});
+
+test("DemoLockedField's hint prop is supplemental — the warning cannot be replaced", () => {
+ render( );
+ const input = screen.getByLabelText("City");
+ const hintId = input.getAttribute("aria-describedby");
+ expect(document.getElementById(hintId as string)?.textContent).toBe(
+ `${DEMO_FIELD_HINT} Use persona Jordan Demo.`,
+ );
+});
+
+test("FIX A: interstitial paragraphs are fresh deep-frozen copies — safety copy cannot be mutated away", () => {
+ const first = hostilePack.interstitialParagraphs("Acme");
+ const segment = first[3]?.[0];
+ expect(Object.isFrozen(first)).toBe(true);
+ expect(Object.isFrozen(first[3])).toBe(true);
+ expect(Object.isFrozen(segment)).toBe(true);
+ expect(() => {
+ (segment as { text: string }).text = "tampered";
+ }).toThrow(TypeError);
+ // A later call returns intact copy via distinct objects.
+ const second = hostilePack.interstitialParagraphs("Acme");
+ expect(copyText(second[3] ?? [])).toBe(
+ "Do not enter real personal or financial information. Use the demo personas provided.",
+ );
+ expect(second[3]?.[0]).not.toBe(segment);
+});
diff --git a/packages/solid-showcase-kit/test/support/fixtures.ts b/packages/solid-showcase-kit/test/support/fixtures.ts
new file mode 100644
index 000000000..d6654306b
--- /dev/null
+++ b/packages/solid-showcase-kit/test/support/fixtures.ts
@@ -0,0 +1,46 @@
+// AUTHORED-BY Claude Fable 5
+/**
+ * Shared test fixture: a fully FICTIONAL branding config exercising every knob the pack
+ * parameterises (convener, purpose clause, multi-entry negations, cookie prefix). The
+ * golden vectors in disclaimers.test.ts pin the strings THIS config produces, proving the
+ * `createDisclaimerPack` parameterisation end to end.
+ *
+ * NOTE: a real consumer's exact-string proof (its own convener/negations asserted as
+ * literal golden vectors) deliberately lives in THAT consumer's repository, alongside its
+ * real BrandingConfig — the kit's own suite stays generic.
+ */
+import { type BrandingConfig, createDisclaimerPack, themeFromSpec } from "../../src/index.js";
+
+export const referenceBranding: BrandingConfig = {
+ consentCookiePrefix: "ex-demo-consent-",
+ convener: "Example Demo Collective",
+ description: "show how a vehicle-hire journey could work on Solid personal data stores",
+ domainNegations: [
+ "Nothing here is an offer of hire or insurance.",
+ "Rates, fees, hire decisions, documents, and personas are fictitious and illustrative.",
+ "Nothing on this site is an offer or solicitation of hire, insurance, or of any product or service.",
+ ],
+};
+
+export const referencePack = createDisclaimerPack(referenceBranding);
+
+/** A branded app's theme spec (palette data is consumer data, not kit data). */
+export const walletTheme = themeFromSpec(
+ {
+ accent: "oklch(0.5 0.19 20)",
+ hue: 270,
+ primary: "oklch(0.22 0.012 270)",
+ role: "consumer data custodian",
+ },
+ "Globex Telecom",
+);
+
+export const tourTheme = themeFromSpec(
+ {
+ accent: "oklch(0.55 0.1 250)",
+ hue: 260,
+ primary: "oklch(0.279 0.041 260)",
+ role: "convener",
+ },
+ "Example Demo Collective",
+);
diff --git a/packages/solid-showcase-kit/test/themes.test.ts b/packages/solid-showcase-kit/test/themes.test.ts
new file mode 100644
index 000000000..1f9dea524
--- /dev/null
+++ b/packages/solid-showcase-kit/test/themes.test.ts
@@ -0,0 +1,62 @@
+// AUTHORED-BY Claude Fable 5
+import { expect, test } from "vitest";
+import { paletteTokens, themeCssProperties, themeFromSpec } from "../src/index.js";
+import { walletTheme } from "./support/fixtures.js";
+
+const TOKEN_NAMES = [
+ "background",
+ "foreground",
+ "card",
+ "card-foreground",
+ "muted",
+ "muted-foreground",
+ "border",
+ "primary",
+ "primary-foreground",
+ "accent",
+ "accent-foreground",
+] as const;
+
+test("paletteTokens derives a full token set from the hue anchor", () => {
+ const tokens = paletteTokens({
+ accent: "oklch(0.5 0.19 20)",
+ hue: 270,
+ primary: "oklch(0.22 0.012 270)",
+ });
+ for (const token of TOKEN_NAMES) {
+ // Palette-inspired original oklch values only — never brand-guideline colours.
+ expect(tokens[token], token).toMatch(/^oklch\(/);
+ }
+ expect(tokens.background).toBe("oklch(0.985 0.004 270)");
+ expect(tokens.primary).toBe("oklch(0.22 0.012 270)");
+ // Foregrounds default to the light foreground unless overridden.
+ expect(tokens["accent-foreground"]).toBe("oklch(0.985 0.003 250)");
+ expect(
+ paletteTokens({
+ accent: "a",
+ accentForeground: "oklch(0.24 0.02 250)",
+ hue: 240,
+ primary: "p",
+ })["accent-foreground"],
+ ).toBe("oklch(0.24 0.02 250)");
+});
+
+test("themeFromSpec builds a role-first OrgTheme from a validated spec", () => {
+ expect(walletTheme.modelledOn).toBe("Globex Telecom");
+ expect(walletTheme.role).toBe("consumer data custodian");
+ expect(walletTheme.tokens.primary).toBe("oklch(0.22 0.012 270)");
+ expect(() =>
+ themeFromSpec(
+ // @ts-expect-error deliberately malformed spec — role is required
+ { accent: "a", hue: 1, primary: "p" },
+ "Anyone",
+ ),
+ ).toThrow();
+});
+
+test("themeCssProperties emits CSS custom properties for every token", () => {
+ const style = themeCssProperties(walletTheme);
+ for (const token of TOKEN_NAMES) {
+ expect(style[`--${token}`]).toBe(walletTheme.tokens[token]);
+ }
+});
diff --git a/packages/solid-showcase-kit/tsconfig.build.json b/packages/solid-showcase-kit/tsconfig.build.json
new file mode 100644
index 000000000..44291d8d4
--- /dev/null
+++ b/packages/solid-showcase-kit/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "declaration": true,
+ "declarationMap": false,
+ "sourceMap": false,
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"]
+ },
+ "include": ["src"],
+ "exclude": ["test", "**/*.test.ts", "**/*.test.tsx"]
+}
diff --git a/packages/solid-showcase-kit/tsconfig.json b/packages/solid-showcase-kit/tsconfig.json
new file mode 100644
index 000000000..46e4fa98a
--- /dev/null
+++ b/packages/solid-showcase-kit/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "noImplicitOverride": true,
+ "types": ["node"]
+ },
+ "include": ["src", "test"]
+}
diff --git a/packages/solid-showcase-kit/vitest.config.ts b/packages/solid-showcase-kit/vitest.config.ts
new file mode 100644
index 000000000..02d01d910
--- /dev/null
+++ b/packages/solid-showcase-kit/vitest.config.ts
@@ -0,0 +1,13 @@
+// AUTHORED-BY Claude Fable 5
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+// Default environment is node (branding/insignia suites); component suites opt into
+// jsdom per-file via `// @vitest-environment jsdom`.
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: "node",
+ include: ["test/**/*.test.{ts,tsx}"],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 87c74a04e..8d9d23843 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3554,6 +3554,52 @@ importers:
specifier: ^4.0.0
version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
+ packages/solid-showcase-kit:
+ dependencies:
+ '@jeswr/app-shell':
+ specifier: workspace:*
+ version: link:../app-shell
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@biomejs/biome':
+ specifier: ^2.5.1
+ version: 2.5.3
+ '@testing-library/dom':
+ specifier: ^10.4.0
+ version: 10.4.1
+ '@testing-library/react':
+ specifier: ^16.3.0
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@types/node':
+ specifier: ^25.9.2
+ version: 25.9.5
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.2.3(@types/react@19.2.17)
+ '@vitejs/plugin-react':
+ specifier: ^5.2.0
+ version: 5.2.0(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0))
+ jsdom:
+ specifier: ^26.0.0
+ version: 26.1.0
+ react:
+ specifier: ^19.2.0
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.7(react@19.2.7)
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ vitest:
+ specifier: ^4.1.9
+ version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0))
+
packages/solid-task-model:
dependencies:
'@jeswr/fetch-rdf':
@@ -21077,6 +21123,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vitejs/plugin-react@5.2.0(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0))':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+ '@rolldown/pluginutils': 1.0.0-rc.3
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.18.0
+ vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)
+ transitivePeerDependencies:
+ - supports-color
+
'@vitejs/plugin-react@6.0.2(vite@8.1.4(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
@@ -22485,8 +22543,8 @@ snapshots:
'@typescript-eslint/parser': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0))
@@ -22502,8 +22560,8 @@ snapshots:
'@next/eslint-plugin-next': 16.2.10
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0))
@@ -22545,7 +22603,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -22556,11 +22614,11 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -22571,18 +22629,18 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
+ eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
+ eslint-module-utils@2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
@@ -22596,7 +22654,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -22607,7 +22665,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
- eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
+ eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -27242,6 +27300,35 @@ snapshots:
transitivePeerDependencies:
- msw
+ vitest@4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)):
+ dependencies:
+ '@vitest/expect': 4.1.10
+ '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0))
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/runner': 4.1.10
+ '@vitest/snapshot': 4.1.10
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ es-module-lexer: 2.3.0
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ obug: 2.1.3
+ pathe: 2.0.3
+ picomatch: 4.0.5
+ std-env: 4.2.0
+ tinybench: 2.9.0
+ tinyexec: 1.2.4
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 25.9.5
+ '@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
+ jsdom: 26.1.0
+ transitivePeerDependencies:
+ - msw
+
vitest@4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)):
dependencies:
'@vitest/expect': 4.1.10
diff --git a/suite.json b/suite.json
index 27e77a936..73e4994da 100644
--- a/suite.json
+++ b/suite.json
@@ -53,6 +53,7 @@
"@jeswr/solid-pod-guard",
"@jeswr/solid-session-restore",
"@jeswr/solid-seed",
+ "@jeswr/solid-showcase-kit",
"@jeswr/synthetic-rdf",
"@jeswr/solid-task-model",
"@jeswr/solid-vc",