diff --git a/packages/solid-showcase/LICENSE b/packages/solid-showcase/LICENSE new file mode 100644 index 000000000..2f396fc37 --- /dev/null +++ b/packages/solid-showcase/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/README.md b/packages/solid-showcase/README.md new file mode 100644 index 000000000..357f46e60 --- /dev/null +++ b/packages/solid-showcase/README.md @@ -0,0 +1,89 @@ + + +# @jeswr/solid-showcase + +The JSON-driven walkthrough site: a generic framework for taking people through a +multistakeholder interaction flow for data held in Solid pods. One validated **walkthrough +document** drives the whole site — landing page, guided chapters, ecosystem map, launcher +dock, compliance lens, and the deploy wiring — so a new use case is authored as data, not +as code. + +The package is deliberately **domain-generic**: nothing in it names any industry or use +case (a unit test enforces this). Domain knowledge — organisations, rules, personas, +copy — enters only through the document a consumer supplies at runtime. + +## The document + +```ts +import { parseWalkthrough, editorialFindings } from "@jeswr/solid-showcase"; +import walkthroughJson from "./content/walkthrough.json"; + +const walkthrough = parseWalkthrough(walkthroughJson); // throws with EVERY issue, named +console.assert(editorialFindings(walkthrough).length === 0); +``` + +`parseWalkthrough` validates shape (zod) plus the cross-reference rules JSON-Schema cannot +express: every try-live/launcher/role app resolves in the single service registry, registry +keys equal their entry slugs, exactly one centre role matches `registry.center`, chapter +scenes are contiguous 1..N, editorial budgets hold (lead/step word counts, minimum steps, +required "underneath" panels), compliance checks resolve to their chapters with matching +scenes, and the demo persona self-identifies as fictional/simulated. + +The zod schemas are the single source of truth: the TypeScript types are inferred from +them, and the JSON-Schema artifact at `@jeswr/solid-showcase/schema/walkthrough.v1.json` +is generated from the same schemas (`pnpm run generate:schema`), with a test pinning the +artifact in sync. Branding and theme contracts are composed from +`@jeswr/solid-showcase-kit` (`branding: brandingConfigSchema`, `theme: themeSpecSchema`) — +the kit owns those; this package owns the document shapes. The edge is acyclic: showcase +depends on kit, never the reverse. + +## The four page renderers + +```tsx +import { + ShowcaseLayout, ShowcaseLanding, ShowcaseChapterPage, ShowcaseCompliancePage, +} from "@jeswr/solid-showcase"; + +// app/layout.tsx → {children} +// app/page.tsx → +// app/chapters/[slug]/ → +// app/compliance/ → +``` + +Building blocks are exported too: `ChapterPlayer` (keyboard-navigable stepper), +`TryLiveButton` and `Launcher` (honest degradation — an undeployed zone stays visible as a +disabled placeholder link, never hidden and never navigable), `EcosystemMap`, +`DemoIdentityCard` (copy-to-clipboard persona card), `StatusDot`, and +`useServiceStatuses` (same-origin health probes with timeouts and periodic refresh). + +## Deploy helpers — `@jeswr/solid-showcase/next` + +```ts +import { + zoneRewrites, envMatrix, appVercelJson, healthRoute, showcaseMetadata, +} from "@jeswr/solid-showcase/next"; + +// next.config.ts (shell): multi-zone rewrites from every registry app with a zoneEnv +const nextConfig = { rewrites: () => zoneRewrites(walkthrough) }; + +// app/api/health/route.ts (each app) +export const { GET } = healthRoute("vault"); + +// Shell layout metadata: concept-demo title suffix + noindex/nofollow +export const metadata = showcaseMetadata(walkthrough); +``` + +`envMatrix(doc)` returns the full per-project env-var matrix: one zone-URL var per zone +app (set on the shell project; read at build time), and +`{envPrefix}_TRUST_FORWARDED_HEADERS=1` on every app that declares `podRoutes` — behind a +proxy, authenticated pod routes must compute their bound request URL from the public host. +`appVercelJson(app, doc)` emits the per-app `vercel.json` (framework pin, workspace turbo +build, turbo-ignore). + +## Install + +```sh +npm install @jeswr/solid-showcase react +``` + +MIT © the @jeswr Solid suite. See `SKILL.md` for agent-facing guidance. diff --git a/packages/solid-showcase/SKILL.md b/packages/solid-showcase/SKILL.md new file mode 100644 index 000000000..3fd940795 --- /dev/null +++ b/packages/solid-showcase/SKILL.md @@ -0,0 +1,88 @@ +--- +name: solid-showcase +description: Use when building or modifying a JSON-driven walkthrough site with @jeswr/solid-showcase — authoring or validating walkthrough documents, rendering the four showcase pages, the walkthrough UI components (stepper, try-live, launcher, ecosystem map, persona card), the walkthrough.v1.json schema artifact, or the deploy helpers under ./next (zone rewrites, env matrix, vercel.json, health routes, metadata). +--- + + +# Work with `@jeswr/solid-showcase` + +One validated walkthrough document drives the whole site. Author the document, validate it +with `parseWalkthrough`, render it with the four page components, and wire deploys with the +`./next` helpers. Never fork document content into components — the single registry is the +point. + +## The invariants `parseWalkthrough` enforces + +All are hard errors, all reported AT ONCE, each with a stable `code` and a message naming +the chapter/step/role/check: + +| code | rule | +|---|---| +| `registry-key-mismatch` | every `registry.apps` record key equals its entry's `slug` | +| `unknown-try-live-app` / `unknown-role-app` / `unknown-launcher-app` | every reference resolves in `registry.apps` | +| `duplicate-launcher-app` | `launcherOrder` has no duplicates | +| `duplicate-role-slug` / `center-role` | role slugs unique; exactly one `center: true` role matching `registry.center` | +| `unknown-role-scene` | `roles[].scene` names an existing chapter scene | +| `chapter-scene-order` / `duplicate-chapter-slug` | scenes contiguous 1..N in array order; slugs unique | +| `lead-budget` / `step-budget` / `min-steps` | editorial word/step budgets (defaults 40/65/2) | +| `underneath-required` / `underneath-length` | `underneathRequired` ⇒ non-empty points, each ≥ `minUnderneathChars` (default 20) | +| `unknown-compliance-chapter` / `compliance-scene-mismatch` | checks resolve to chapters with matching scenes | +| `editorial-floor` | overrides only TIGHTEN the schema floors (`minSteps` ≥ 2, `minUnderneathChars` ≥ 20) | +| `persona-honesty` | `persona.descriptor` must contain "fictional" or "simulated" | + +`editorialFindings(doc)` returns the budget findings without throwing (CI gate: +`expect(editorialFindings(doc)).toEqual([])`). `walkthroughWarnings(doc)` carries the +non-fatal role-first naming advisory (`modelledOn` must not appear inside `appName`). + +## Schema rules + +- Schema-first: plain `z.object`s, types inferred. `branding` and `theme` are COMPOSED + from `@jeswr/solid-showcase-kit` (`brandingConfigSchema`, `themeSpecSchema`) — never + redeclare them here; kit owns those contracts, and kit never imports from showcase + (acyclic edge). +- The artifact `schema/walkthrough.v1.json` is GENERATED: after any schema change run + `pnpm run build && pnpm run generate:schema` and commit the artifact — + `test/schema-sync.test.ts` fails on drift. +- `registry.apps[].podRoutes` (optional, additive) declares an app's authenticated pod + API routes; it exists so `envMatrix` can require the forwarded-headers trust var on + exactly those projects. + +## Rendering rules + +- `ShowcaseLayout` is client-side ("use client"): it builds the disclaimer pack via + `documentDisclaimerPack(doc)` (cookie prefix defaults to `{deploy.slug}-demo-consent-`), + resolves the shell theme from the registry entry at path "/" (neutral fallback + otherwise), and renders kit AppShell variant "own" + Launcher + ConsentInterstitial. +- Landing/chapter/compliance renderers are server-safe; they pass only serializable + document data into the client building blocks. +- Chapter routes live at `/chapters/{slug}`, the lens at `/compliance`. Guard unknown + slugs with `chapterBySlug(doc, slug)` before rendering `ShowcaseChapterPage` — it + throws on unknown slugs. `ShowcaseCompliancePage` throws when `doc.compliance` is + absent; only route to it when configured. +- Honest degradation is a contract, not a style: try-live and launcher entries for + undeployed zones stay VISIBLE as href-less placeholder links with `aria-disabled` and + an explanation. Never hide them, never leave a navigable href to a dead zone. + +## Deploy helpers (`./next`) + +- `zoneRewrites(doc, { env?, fallbackSuffix? })` — two rules per zone app (`prefix`, + `prefix/:path+`); unset env vars fall back to `https://{slug}.invalid` so they can never + route anywhere real. Zone URLs are read at BUILD time — changing one needs a shell + redeploy, and the build env allowlist must include the zone vars. +- `envMatrix(doc)` — zone-URL vars target the shell project; the + `{envPrefix}_TRUST_FORWARDED_HEADERS=1` spec targets exactly the `podRoutes` apps. +- `appVercelJson(app, doc)` — pair with a project-level `rootDirectory` of `apps/{slug}` + and CLEARED project-level build overrides (they silently win over `vercel.json`). +- `healthRoute(service)` → `export const { GET } = healthRoute("{slug}")`; the payload is + honestly `simulated: true`. `showcaseMetadata(doc)` is always noindex/nofollow. + +## Domain-generic gate (the whole point) + +This package is a GENERIC multistakeholder walkthrough framework. No use-case or domain +string may enter `src/`, tests, docs, or shipped artifacts — `test/domain-generic.test.ts` +greps the whole package for a banned-term roster and must stay empty. Real walkthrough +documents (and their golden tests) live in consumer repositories; the fixture here is a +fictional trail-expedition flow. Never "improve" the fixture with real-world content. + +Verify API usage against the published dist, run the workspace gate after changes, and +never weaken the malformed-document suite or the grep gate. diff --git a/packages/solid-showcase/package.json b/packages/solid-showcase/package.json new file mode 100644 index 000000000..822331929 --- /dev/null +++ b/packages/solid-showcase/package.json @@ -0,0 +1,61 @@ +{ + "name": "@jeswr/solid-showcase", + "version": "0.1.0", + "description": "JSON-driven walkthrough site for Solid concept demos — a zod-validated walkthrough document schema, four page renderers and the walkthrough UI components, plus document-consuming deploy helpers under ./next.", + "author": "Claude Fable 5 (@jeswr solid-sdk agent)", + "license": "MIT", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./next": { + "types": "./dist/next/index.d.ts", + "import": "./dist/next/index.js" + }, + "./schema/walkthrough.v1.json": "./schema/walkthrough.v1.json" + }, + "files": [ + "dist", + "src", + "schema", + "README.md", + "SKILL.md", + "LICENSE" + ], + "engines": { + "node": ">=20.12" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "generate:schema": "node scripts/generate-schema.mjs", + "lint": "biome check src test scripts", + "lint:fix": "biome check --write src test scripts", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "peerDependencies": { + "react": ">=18" + }, + "dependencies": { + "@jeswr/app-shell": "workspace:*", + "@jeswr/solid-showcase-kit": "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/schema/walkthrough.v1.json b/packages/solid-showcase/schema/walkthrough.v1.json new file mode 100644 index 000000000..004e047a7 --- /dev/null +++ b/packages/solid-showcase/schema/walkthrough.v1.json @@ -0,0 +1,569 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "walkthrough.v1.json", + "title": "WalkthroughDocument", + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 1 + }, + "site": { + "type": "object", + "properties": { + "appName": { + "type": "string", + "minLength": 1 + }, + "organization": { + "type": "string", + "minLength": 1 + }, + "heroTitle": { + "type": "string" + }, + "heroLead": { + "type": "string" + }, + "heroParagraph": { + "type": "string" + }, + "startCtaLabel": { + "type": "string" + }, + "exploreCtaLabel": { + "type": "string" + } + }, + "required": [ + "appName", + "organization", + "heroTitle", + "heroLead", + "heroParagraph" + ], + "additionalProperties": false + }, + "branding": { + "type": "object", + "properties": { + "convener": { + "type": "string", + "minLength": 1 + }, + "domainNegations": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "minLength": 1 + }, + "aboutHref": { + "type": "string" + }, + "consentCookiePrefix": { + "type": "string", + "pattern": "^[a-z0-9-]+-$" + }, + "illustrativeTag": { + "type": "string" + }, + "bannedMarks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "pattern", + "reason" + ], + "additionalProperties": false + } + } + }, + "required": [ + "convener", + "domainNegations", + "description" + ], + "additionalProperties": false + }, + "persona": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "descriptor": { + "type": "string" + }, + "fields": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "copyable": { + "type": "boolean" + }, + "note": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + } + }, + "footnote": { + "type": "string" + } + }, + "required": [ + "name", + "descriptor", + "fields" + ], + "additionalProperties": false + }, + "anchors": { + "maxItems": 6, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri", + "pattern": "^https:\\/\\/.*" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "value", + "label", + "detail", + "source" + ], + "additionalProperties": false + } + }, + "registry": { + "type": "object", + "properties": { + "apps": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "appName": { + "type": "string" + }, + "modelledOn": { + "type": "string" + }, + "path": { + "type": "string", + "pattern": "^\\/" + }, + "zoneEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "healthPath": { + "type": "string", + "pattern": "^\\/" + }, + "theme": { + "type": "object", + "properties": { + "hue": { + "type": "number" + }, + "primary": { + "type": "string" + }, + "accent": { + "type": "string" + }, + "primaryForeground": { + "type": "string" + }, + "accentForeground": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "required": [ + "hue", + "primary", + "accent", + "role" + ], + "additionalProperties": false + }, + "honesty": { + "type": "object", + "properties": { + "real": { + "type": "array", + "items": { + "type": "string" + } + }, + "simulated": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "real", + "simulated" + ], + "additionalProperties": false + }, + "podRoutes": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\/" + } + } + }, + "required": [ + "slug", + "appName", + "modelledOn", + "path", + "healthPath" + ], + "additionalProperties": false + } + }, + "launcherOrder": { + "type": "array", + "items": { + "type": "string" + } + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "roleNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "role": { + "type": "string" + }, + "modelledOn": { + "type": "string" + }, + "membership": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "scene": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "apps": { + "type": "array", + "items": { + "type": "string" + } + }, + "center": { + "type": "boolean" + } + }, + "required": [ + "slug", + "role", + "modelledOn", + "membership", + "summary", + "apps" + ], + "additionalProperties": false + } + }, + "center": { + "type": "string" + } + }, + "required": [ + "apps", + "launcherOrder", + "roles", + "center" + ], + "additionalProperties": false + }, + "chapters": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "scene": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + }, + "anchor": { + "type": "string", + "minLength": 10 + }, + "lead": { + "type": "string" + }, + "steps": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1 + }, + "body": { + "type": "string" + }, + "tryLive": { + "type": "object", + "properties": { + "app": { + "type": "string" + }, + "label": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "app", + "label" + ], + "additionalProperties": false + } + }, + "required": [ + "title", + "body", + "tryLive" + ], + "additionalProperties": false + } + }, + "underneath": { + "type": "array", + "items": { + "type": "string", + "minLength": 20 + } + }, + "underneathRequired": { + "type": "boolean" + } + }, + "required": [ + "slug", + "scene", + "title", + "anchor", + "lead", + "steps" + ], + "additionalProperties": false + } + }, + "editorial": { + "type": "object", + "properties": { + "maxLeadWords": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxStepWords": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minSteps": { + "type": "integer", + "minimum": 2, + "maximum": 9007199254740991 + }, + "minUnderneathChars": { + "type": "integer", + "minimum": 20, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "compliance": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "nonAffiliation": { + "type": "string", + "minLength": 20 + }, + "checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "rule": { + "type": "string" + }, + "citation": { + "type": "string" + }, + "citationUrl": { + "type": "string", + "format": "uri", + "pattern": "^https:\\/\\/.*" + }, + "scene": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "chapterSlug": { + "type": "string" + }, + "observe": { + "type": "string" + } + }, + "required": [ + "id", + "rule", + "citation", + "citationUrl", + "scene", + "chapterSlug", + "observe" + ], + "additionalProperties": false + } + } + }, + "required": [ + "title", + "nonAffiliation", + "checks" + ], + "additionalProperties": false + }, + "deploy": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "envPrefix": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + } + }, + "required": [ + "slug", + "envPrefix" + ], + "additionalProperties": false + } + }, + "required": [ + "version", + "site", + "branding", + "persona", + "anchors", + "registry", + "chapters", + "deploy" + ], + "additionalProperties": false +} diff --git a/packages/solid-showcase/scripts/generate-schema.mjs b/packages/solid-showcase/scripts/generate-schema.mjs new file mode 100644 index 000000000..4a1606d66 --- /dev/null +++ b/packages/solid-showcase/scripts/generate-schema.mjs @@ -0,0 +1,12 @@ +// AUTHORED-BY Claude Fable 5 +// Regenerate the shipped JSON-Schema artifact from the zod schema. Run AFTER a build +// (the script imports the built output): `pnpm run build && pnpm run generate:schema`. +// A unit test (test/schema-sync.test.ts) asserts the committed artifact matches. +import { writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { walkthroughJsonSchema } from "../dist/schema.js"; + +const target = resolve(dirname(fileURLToPath(import.meta.url)), "../schema/walkthrough.v1.json"); +await writeFile(target, `${JSON.stringify(walkthroughJsonSchema(), null, 2)}\n`); +console.log(`wrote ${target}`); diff --git a/packages/solid-showcase/src/components/chapter-player.tsx b/packages/solid-showcase/src/components/chapter-player.tsx new file mode 100644 index 000000000..48ef6be65 --- /dev/null +++ b/packages/solid-showcase/src/components/chapter-player.tsx @@ -0,0 +1,112 @@ +// AUTHORED-BY Claude Fable 5 +"use client"; + +import { cn } from "@jeswr/app-shell"; +import { type KeyboardEvent as ReactKeyboardEvent, useState } from "react"; +import type { ServiceRegistry, WalkthroughChapter } from "../schema.js"; +import { TryLiveButton } from "./try-live.js"; + +export interface ChapterPlayerProps { + chapter: WalkthroughChapter; + registry: ServiceRegistry; +} + +/** + * Chapter player: a linear, keyboard-navigable stepper over one chapter's steps + * (step-dot buttons, previous/next controls, and Arrow-key navigation on the stepper), + * with the step's try-this-live deep link and the chapter's "what just happened + * underneath" panel. + */ +export function ChapterPlayer({ chapter, registry }: ChapterPlayerProps) { + const [stepIndex, setStepIndex] = useState(0); + const step = chapter.steps[stepIndex] ?? chapter.steps[0]; + const isFirst = stepIndex === 0; + const isLast = stepIndex === chapter.steps.length - 1; + + function goPrevious() { + setStepIndex((index) => Math.max(0, index - 1)); + } + function goNext() { + setStepIndex((index) => Math.min(chapter.steps.length - 1, index + 1)); + } + function onStepperKeyDown(event: ReactKeyboardEvent) { + if (event.key === "ArrowLeft") { + event.preventDefault(); + goPrevious(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + goNext(); + } + } + + if (step === undefined) return null; + + return ( +
+ + +
+

+ Step {stepIndex + 1} of {chapter.steps.length} +

+

+ {step.title} +

+

{step.body}

+
+ +
+
+ +
+ + +
+ + {chapter.underneath !== undefined && chapter.underneath.length > 0 && ( +
+ + What just happened underneath + +
    + {chapter.underneath.map((point) => ( +
  • {point}
  • + ))} +
+
+ )} +
+ ); +} diff --git a/packages/solid-showcase/src/components/demo-identity-card.tsx b/packages/solid-showcase/src/components/demo-identity-card.tsx new file mode 100644 index 000000000..89f56b857 --- /dev/null +++ b/packages/solid-showcase/src/components/demo-identity-card.tsx @@ -0,0 +1,69 @@ +// AUTHORED-BY Claude Fable 5 +"use client"; + +import { cn } from "@jeswr/app-shell"; +import { useState } from "react"; +import type { DemoPersonaCard, PersonaField } from "../schema.js"; + +function CopyRow({ label, value, copyable, note }: PersonaField) { + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard unavailable (permissions/insecure context): the value stays selectable. + } + } + + return ( +
+
+

{label}

+

{value}

+ {note !== undefined &&

{note}

} +
+ {copyable !== false && ( + + )} +
+ ); +} + +export interface DemoIdentityCardProps { + persona: DemoPersonaCard; + className?: string | undefined; +} + +/** + * Copy-ready demo identity: the scripted persona's values, one click from the + * presenter's clipboard. The descriptor (validated to self-identify as + * fictional/simulated) and the optional footnote render verbatim. + */ +export function DemoIdentityCard({ persona, className }: DemoIdentityCardProps) { + return ( +
+

Demo identity — {persona.name}

+

{persona.descriptor}

+
+ {persona.fields.map((field) => ( + + ))} +
+ {persona.footnote !== undefined && ( +

{persona.footnote}

+ )} +
+ ); +} diff --git a/packages/solid-showcase/src/components/ecosystem-map.tsx b/packages/solid-showcase/src/components/ecosystem-map.tsx new file mode 100644 index 000000000..9839cde74 --- /dev/null +++ b/packages/solid-showcase/src/components/ecosystem-map.tsx @@ -0,0 +1,150 @@ +// AUTHORED-BY Claude Fable 5 +"use client"; + +import { cn } from "@jeswr/app-shell"; +import { useState } from "react"; +import { centerRole, registeredApp, surroundingRoles } from "../document.js"; +import type { EcosystemRole, ServiceRegistry } from "../schema.js"; +import { StatusDot } from "./status-dot.js"; +import { TryLiveButton } from "./try-live.js"; +import { type ServiceStatus, useServiceStatuses } from "./use-service-status.js"; + +export interface EcosystemMapProps { + registry: ServiceRegistry; +} + +function roleHealthPaths(registry: ServiceRegistry, role: EcosystemRole): string[] { + return role.apps.map((slug) => registeredApp(registry, slug).healthPath); +} + +function roleStatus( + registry: ServiceRegistry, + role: EcosystemRole, + statuses: Record, +): ServiceStatus | undefined { + const paths = roleHealthPaths(registry, role); + if (paths.length === 0) return undefined; + const resolved = paths.map((path) => statuses[path] ?? "checking"); + if (resolved.includes("live")) return "live"; + if (resolved.every((status) => status === "not-deployed")) return "not-deployed"; + return "checking"; +} + +/** + * Interactive ecosystem map: the data subject's own vault at the centre, the value-chain + * roles around it. Click or focus a node to read its seat; roles with a demo app expose a + * try-this-live launch with a live-status dot; mapped-but-unbuilt seats say so. Driven + * entirely by the single service registry. + */ +export function EcosystemMap({ registry }: EcosystemMapProps) { + const center = centerRole(registry); + const roles = surroundingRoles(registry); + const [selectedSlug, setSelectedSlug] = useState(center.slug); + const allPaths = [center, ...roles].flatMap((role) => roleHealthPaths(registry, role)); + const statuses = useServiceStatuses([...new Set(allPaths)]); + const selected = [center, ...roles].find((role) => role.slug === selectedSlug) ?? center; + const selectedStatus = roleStatus(registry, selected, statuses); + + return ( +
+
+ + {roles.map((role) => { + const status = roleStatus(registry, role, statuses); + const isSelected = selectedSlug === role.slug; + return ( + + ); + })} +
+ +
+
+

+ {selected.roleNumber !== undefined && ( + Role {selected.roleNumber} · + )} + {selected.role} +

+

{selected.membership}

+
+

modelled on {selected.modelledOn}

+

{selected.summary}

+ {selected.apps.length > 0 ? ( +
+ {selected.apps.map((slug) => ( + + ))} +
+ ) : ( +

+ This seat is mapped for a later phase; it has no application in this demo. +

+ )} + {selectedStatus === "not-deployed" && ( +

+ Not deployed in this environment — the seat and its story remain part of the map. +

+ )} +
+
+ ); +} diff --git a/packages/solid-showcase/src/components/launcher.tsx b/packages/solid-showcase/src/components/launcher.tsx new file mode 100644 index 000000000..2a3181396 --- /dev/null +++ b/packages/solid-showcase/src/components/launcher.tsx @@ -0,0 +1,82 @@ +// AUTHORED-BY Claude Fable 5 +"use client"; + +import { cn } from "@jeswr/app-shell"; +import { useId, useState } from "react"; +import { launcherApps } from "../document.js"; +import type { ServiceRegistry } from "../schema.js"; +import { StatusDot } from "./status-dot.js"; +import { useServiceStatuses } from "./use-service-status.js"; + +export interface LauncherProps { + registry: ServiceRegistry; +} + +/** + * Persistent launcher dock: every app in the suite with a live status dot, driven by the + * single service registry (`registry.launcherOrder`). Rendered on every page from the + * layout. Undeployed zones stay listed — honest degradation, not omission. + */ +export function Launcher({ registry }: LauncherProps) { + const [open, setOpen] = useState(false); + const panelId = useId(); + const apps = launcherApps(registry); + const statuses = useServiceStatuses(apps.map((app) => app.healthPath)); + + return ( +
+ {open && ( +
+

Apps in this demo

+
    + {apps.map((app) => { + const status = statuses[app.healthPath] ?? "checking"; + const live = status === "live"; + return ( +
  • + {/* Same accessible-disabled pattern as TryLiveButton: a zone that is + not live renders as a placeholder link (no href, no handler, + aria-disabled) so nothing navigates into a failing zone. */} + + + + {app.appName} + + + {app.modelledOn} + + + + +
  • + ); + })} +
+

+ Zones marked “Not deployed” are not wired in this environment. +

+
+ )} + +
+ ); +} diff --git a/packages/solid-showcase/src/components/status-dot.tsx b/packages/solid-showcase/src/components/status-dot.tsx new file mode 100644 index 000000000..23fe08545 --- /dev/null +++ b/packages/solid-showcase/src/components/status-dot.tsx @@ -0,0 +1,28 @@ +// AUTHORED-BY Claude Fable 5 +import { cn } from "@jeswr/app-shell"; +import { type ServiceStatus, STATUS_LABELS } from "./use-service-status.js"; + +const DOT_CLASSES: Record = { + checking: "bg-muted-foreground/50", + live: "bg-emerald-500", + "not-deployed": "bg-amber-500", +}; + +/** Status dot + label; degradation stays visible and honest (never hidden). */ +export function StatusDot({ + status, + className, +}: { + status: ServiceStatus; + className?: string | undefined; +}) { + return ( + + + ); +} diff --git a/packages/solid-showcase/src/components/try-live.tsx b/packages/solid-showcase/src/components/try-live.tsx new file mode 100644 index 000000000..b771fccbc --- /dev/null +++ b/packages/solid-showcase/src/components/try-live.tsx @@ -0,0 +1,64 @@ +// AUTHORED-BY Claude Fable 5 +"use client"; + +import { cn } from "@jeswr/app-shell"; +import { registeredApp } from "../document.js"; +import type { ServiceRegistry } from "../schema.js"; +import { type ServiceStatus, useServiceStatuses } from "./use-service-status.js"; + +export interface TryLiveButtonProps { + registry: ServiceRegistry; + /** Key of `registry.apps`. */ + app: string; + label: string; + className?: string | undefined; +} + +/** + * "Try this live" deep link. When the target zone is not deployed the control stays + * VISIBLE but disabled — a placeholder link (no href, no handler) with aria-disabled and + * an explanation — so the story remains honest about what is wired in this environment + * and nothing can navigate into a failing zone (middle-click included). + */ +export function TryLiveButton({ registry, app, label, className }: TryLiveButtonProps) { + const target = registeredApp(registry, app); + const statuses = useServiceStatuses([target.healthPath]); + const status: ServiceStatus = statuses[target.healthPath] ?? "checking"; + + if (status === "live") { + return ( + + {label} + + + ); + } + + return ( + + {/* biome-ignore lint/a11y/useValidAnchor: an href-less anchor is the HTML-spec + placeholder-link form for a disabled link — keeping href + preventDefault would + still navigate on middle-click / open-in-new-tab. */} + + {label} + + + {status === "checking" + ? "Checking whether this app is deployed…" + : `${target.appName} is not deployed in this environment — the step still reads honestly without it.`} + + + ); +} diff --git a/packages/solid-showcase/src/components/use-service-status.ts b/packages/solid-showcase/src/components/use-service-status.ts new file mode 100644 index 000000000..6031cc53c --- /dev/null +++ b/packages/solid-showcase/src/components/use-service-status.ts @@ -0,0 +1,102 @@ +// AUTHORED-BY Claude Fable 5 +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Live-status probes: client-side fetches to each zone's same-origin `/api/health` route + * (proxied through the shell's multi-zone rewrites), with periodic refresh and honest + * degradation — an unreachable zone is reported as "not deployed", never hidden. + */ +export type ServiceStatus = "checking" | "live" | "not-deployed"; + +export const REFRESH_INTERVAL_MS = 30_000; +/** Per-probe budget: a stalled zone must not hold the others at "checking". */ +export const PROBE_TIMEOUT_MS = 5_000; + +async function probe(healthPath: string, signal: AbortSignal): Promise { + try { + const response = await fetch(healthPath, { cache: "no-store", signal }); + if (!response.ok) return "not-deployed"; + const payload: unknown = await response.json(); + const ok = + typeof payload === "object" && payload !== null && (payload as { ok?: unknown }).ok === true; + return ok ? "live" : "not-deployed"; + } catch { + return "not-deployed"; + } +} + +/** Poll the given health paths; keys of the result are the health paths themselves. */ +export function useServiceStatuses(healthPaths: string[]): Record { + const [statuses, setStatuses] = useState>({}); + // Deterministic serialization keeps the effect key stable for equal arrays without + // embedding delimiter characters in the source (paths never contain quotes). + const pathsKey = JSON.stringify(healthPaths); + + useEffect(() => { + const paths = JSON.parse(pathsKey) as string[]; + let cancelled = false; + const inFlight = new Set(); + const controllers = new Set(); + + async function refreshPath(path: string) { + // Per-endpoint guard, held until the underlying fetch ACTUALLY settles: at most + // one outstanding request per endpoint, ever. A zone whose fetch ignores its abort + // signal must not accumulate a new zombie request on every interval, and one + // endpoint's pending probe never gates any other. + if (inFlight.has(path)) return; + inFlight.add(path); + const controller = new AbortController(); + controllers.add(controller); + let timer: ReturnType | undefined; + try { + const settled = probe(path, controller.signal); + // The timeout SETTLES the race as well as aborting the fetch, so the endpoint's + // status is published within budget regardless of the zone. + const status = await Promise.race([ + settled, + new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve("not-deployed"); + }, PROBE_TIMEOUT_MS); + }), + ]); + if (!cancelled) { + setStatuses((previous) => + previous[path] === status ? previous : { ...previous, [path]: status }, + ); + } + // A late result is discarded — "live" requires answering within budget — but the + // guard stays held until the request is truly finished; polling for this + // endpoint resumes on the first interval after it settles. + await settled; + } finally { + clearTimeout(timer); + controllers.delete(controller); + inFlight.delete(path); + } + } + + function refreshAll() { + for (const path of paths) void refreshPath(path); + } + + refreshAll(); + const interval = setInterval(refreshAll, REFRESH_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(interval); + for (const controller of controllers) controller.abort(); + }; + }, [pathsKey]); + + return statuses; +} + +export const STATUS_LABELS: Record = { + checking: "Checking…", + live: "Live", + "not-deployed": "Not deployed", +}; diff --git a/packages/solid-showcase/src/document.ts b/packages/solid-showcase/src/document.ts new file mode 100644 index 000000000..a2b92de5c --- /dev/null +++ b/packages/solid-showcase/src/document.ts @@ -0,0 +1,97 @@ +// AUTHORED-BY Claude Fable 5 +/** + * Read-side helpers over a validated walkthrough document: registry lookups, the + * document-derived disclaimer pack, and the shell theme resolution the page renderers + * share. + */ +import { + createDisclaimerPack, + type DisclaimerPack, + type OrgTheme, + themeFromSpec, +} from "@jeswr/solid-showcase-kit"; +import type { + EcosystemRole, + RegisteredApp, + ServiceRegistry, + WalkthroughChapter, + WalkthroughDocument, +} from "./schema.js"; + +/** Look up a chapter by slug; `undefined` for unknown slugs (route to a 404). */ +export function chapterBySlug( + doc: WalkthroughDocument, + slug: string, +): WalkthroughChapter | undefined { + return doc.chapters.find((chapter) => chapter.slug === slug); +} + +/** A registered app by slug; throws for slugs a validated document cannot contain. */ +export function registeredApp(registry: ServiceRegistry, slug: string): RegisteredApp { + const app = registry.apps[slug]; + if (app === undefined) { + throw new Error( + `No app "${slug}" in registry.apps — validate the document with parseWalkthrough first.`, + ); + } + return app; +} + +/** The launcher dock apps, in `launcherOrder`. */ +export function launcherApps(registry: ServiceRegistry): RegisteredApp[] { + return registry.launcherOrder.map((slug) => registeredApp(registry, slug)); +} + +/** The centre role (the data-subject's own vault/pod seat). */ +export function centerRole(registry: ServiceRegistry): EcosystemRole { + const role = registry.roles.find((entry) => entry.center === true); + if (role === undefined) { + throw new Error( + "No centre role in registry.roles — validate the document with parseWalkthrough first.", + ); + } + return role; +} + +/** The non-centre roles, in document order. */ +export function surroundingRoles(registry: ServiceRegistry): EcosystemRole[] { + return registry.roles.filter((entry) => entry.center !== true); +} + +/** + * The shell's own registry entry: the app served at "/" with no zone rewrite — + * the walkthrough site itself. `undefined` when the document does not register it. + */ +export function shellApp(registry: ServiceRegistry): RegisteredApp | undefined { + return Object.values(registry.apps).find((app) => app.zoneEnv === undefined && app.path === "/"); +} + +/** + * The disclaimer pack for a document. When `branding.consentCookiePrefix` is absent it + * defaults to `${deploy.slug}-demo-consent-` so every app in the walkthrough shares one + * cookie namespace. + */ +export function documentDisclaimerPack(doc: WalkthroughDocument): DisclaimerPack { + return createDisclaimerPack({ + ...doc.branding, + consentCookiePrefix: doc.branding.consentCookiePrefix ?? `${doc.deploy.slug}-demo-consent-`, + }); +} + +/** Neutral fallback palette for a shell entry that declares no theme of its own. */ +const SHELL_FALLBACK_THEME = { + accent: "oklch(0.72 0.1 250)", + hue: 250, + primary: "oklch(0.45 0.06 250)", + role: "guided walkthrough", +} as const; + +/** + * The org theme the shell frame renders with: the shell registry entry's theme when + * declared, else a neutral fallback. `modelledOn` is the convening organisation. + */ +export function shellTheme(doc: WalkthroughDocument): OrgTheme { + const shell = shellApp(doc.registry); + const spec = shell?.theme ?? { ...SHELL_FALLBACK_THEME }; + return themeFromSpec(spec, shell?.modelledOn ?? doc.site.organization); +} diff --git a/packages/solid-showcase/src/index.ts b/packages/solid-showcase/src/index.ts new file mode 100644 index 000000000..d0c34dcf5 --- /dev/null +++ b/packages/solid-showcase/src/index.ts @@ -0,0 +1,93 @@ +// AUTHORED-BY Claude Fable 5 +/** + * @jeswr/solid-showcase — the JSON-driven walkthrough site. One validated walkthrough + * document drives the whole site: schema + validation, four page renderers, and the + * walkthrough UI building blocks. Browser-safe; the deploy helpers that consume the + * document live under `@jeswr/solid-showcase/next`. + */ + +// Building blocks +export { ChapterPlayer, type ChapterPlayerProps } from "./components/chapter-player.js"; +export { + DemoIdentityCard, + type DemoIdentityCardProps, +} from "./components/demo-identity-card.js"; +export { EcosystemMap, type EcosystemMapProps } from "./components/ecosystem-map.js"; +export { Launcher, type LauncherProps } from "./components/launcher.js"; +export { StatusDot } from "./components/status-dot.js"; +export { TryLiveButton, type TryLiveButtonProps } from "./components/try-live.js"; +export { + PROBE_TIMEOUT_MS, + REFRESH_INTERVAL_MS, + type ServiceStatus, + STATUS_LABELS, + useServiceStatuses, +} from "./components/use-service-status.js"; +// Document read helpers +export { + centerRole, + chapterBySlug, + documentDisclaimerPack, + launcherApps, + registeredApp, + shellApp, + shellTheme, + surroundingRoles, +} from "./document.js"; +export { + ShowcaseChapterPage, + type ShowcaseChapterPageProps, +} from "./pages/showcase-chapter-page.js"; +export { + ShowcaseCompliancePage, + type ShowcaseCompliancePageProps, +} from "./pages/showcase-compliance-page.js"; +export { ShowcaseLanding, type ShowcaseLandingProps } from "./pages/showcase-landing.js"; +// Page-level renderers (the whole walkthrough as four components) +export { ShowcaseLayout, type ShowcaseLayoutProps } from "./pages/showcase-layout.js"; +// Schema + inferred document types (branding/theme contracts are composed from +// @jeswr/solid-showcase-kit — import those types from the kit). +export { + type ChapterStep, + type ComplianceCheck, + type ComplianceLens, + chapterStepSchema, + complianceCheckSchema, + complianceLensSchema, + type DemoPersonaCard, + type DeployConfig, + demoPersonaCardSchema, + deployConfigSchema, + type EcosystemRole, + type EditorialLimits, + ecosystemRoleSchema, + editorialLimitsSchema, + type PersonaField, + personaFieldSchema, + type QuantifiedAnchor, + quantifiedAnchorSchema, + type RegisteredApp, + registeredAppSchema, + type ServiceRegistry, + type SiteIdentity, + serviceRegistrySchema, + siteIdentitySchema, + type WalkthroughChapter, + type WalkthroughDocument, + walkthroughChapterSchema, + walkthroughDocumentSchema, + walkthroughJsonSchema, +} from "./schema.js"; +// Validation +export { + countWords, + EDITORIAL_DEFAULTS, + type EditorialFinding, + editorialFindings, + parseWalkthrough, + type ResolvedEditorialLimits, + resolveEditorial, + type WalkthroughIssue, + WalkthroughValidationError, + walkthroughWarnings, +} from "./validate.js"; diff --git a/packages/solid-showcase/src/next/index.ts b/packages/solid-showcase/src/next/index.ts new file mode 100644 index 000000000..8907f68e6 --- /dev/null +++ b/packages/solid-showcase/src/next/index.ts @@ -0,0 +1,170 @@ +// AUTHORED-BY Claude Fable 5 +/** + * `@jeswr/solid-showcase/next` — everything that consumes the walkthrough document for + * DEPLOY wiring: multi-zone rewrites, the per-project env-var matrix, per-app + * `vercel.json`, the zone health route, and shell metadata. Document-shaped types live in + * this package, so these helpers do too (the kit stays document-agnostic). + */ +import type { DemoMetadata } from "@jeswr/solid-showcase-kit"; +import { documentDisclaimerPack, shellApp } from "../document.js"; +import type { RegisteredApp, WalkthroughDocument } from "../schema.js"; + +/** One Next.js rewrite rule (structurally `next`'s Rewrite — no dependency on next). */ +export interface ZoneRewrite { + source: string; + destination: string; +} + +export interface ZoneRewritesOptions { + /** + * Suffix of the guaranteed-unresolvable fallback host used when a zone's env var is + * unset (`https://{slug}{fallbackSuffix}`). Default ".invalid" — the reserved TLD — + * so a missing env var can never route traffic anywhere real. + */ + fallbackSuffix?: string | undefined; + /** Env source, injectable for tests. Default `process.env`. */ + env?: Record | undefined; +} + +/** + * The shell's multi-zone rewrite table, derived from every registry app that declares a + * `zoneEnv`. Each zone gets two rules: the bare prefix and `prefix/:path+`, both pointing + * at the zone URL read from the env var (trailing slashes stripped). Async so it drops + * straight into `next.config.ts`: + * + * ```ts + * const nextConfig: NextConfig = { rewrites: () => zoneRewrites(walkthrough) }; + * ``` + */ +export async function zoneRewrites( + doc: WalkthroughDocument, + options?: ZoneRewritesOptions, +): Promise { + const suffix = options?.fallbackSuffix ?? ".invalid"; + const env = options?.env ?? process.env; + return Object.values(doc.registry.apps) + .filter((app) => app.zoneEnv !== undefined) + .flatMap((app) => { + const fallback = `https://${app.slug}${suffix}`; + const zoneUrl = (env[app.zoneEnv as string] ?? fallback).replace(/\/+$/, ""); + return [ + { destination: `${zoneUrl}${app.path}`, source: app.path }, + { destination: `${zoneUrl}${app.path}/:path+`, source: `${app.path}/:path+` }, + ]; + }); +} + +/** One env var a deploy of this walkthrough needs, and which app projects need it. */ +export interface EnvVarSpec { + name: string; + /** Registry slugs of the app projects the var must be set on. */ + apps: string[]; + /** Fixed value where one is known (e.g. "1"); otherwise deploy-specific. */ + value?: string | undefined; + purpose: string; + /** Read at build time — changing it requires a redeploy of the consuming app. */ + buildTime: boolean; +} + +/** + * The full env-var matrix for a deploy of this document: + * + * - one zone-URL var per `zoneEnv` app, set on the SHELL project (rewrites read them at + * build time); + * - `{envPrefix}_TRUST_FORWARDED_HEADERS=1` on every app that declares `podRoutes` — + * behind a proxy, authenticated pod routes must compute their bound request URL from + * the forwarded public host or every call fails closed. + */ +export function envMatrix(doc: WalkthroughDocument): EnvVarSpec[] { + const shell = shellApp(doc.registry); + const shellApps = shell === undefined ? [] : [shell.slug]; + const specs: EnvVarSpec[] = []; + + for (const app of Object.values(doc.registry.apps)) { + if (app.zoneEnv === undefined) continue; + specs.push({ + apps: shellApps, + buildTime: true, + name: app.zoneEnv, + purpose: `Production URL of ${app.appName} ("${app.slug}"), no trailing slash — read at build time by the shell's zone rewrites.`, + value: undefined, + }); + } + + const podRouteApps = Object.values(doc.registry.apps) + .filter((app) => (app.podRoutes?.length ?? 0) > 0) + .map((app) => app.slug); + if (podRouteApps.length > 0) { + specs.push({ + apps: podRouteApps, + buildTime: false, + name: `${doc.deploy.envPrefix}_TRUST_FORWARDED_HEADERS`, + purpose: + "Trust proxy-forwarded host headers on authenticated pod routes; without it the proof-bound request URL is computed from the internal host and every authenticated call is rejected.", + value: "1", + }); + } + + return specs; +} + +export interface AppVercelJsonOptions { + /** + * Workspace package name the turbo build filters on. Default + * `@{deploy.slug}/app-{app.slug}`. + */ + packageName?: string | undefined; +} + +/** + * The per-app `vercel.json` for a turbo monorepo deploy: framework pin, workspace-rooted + * turbo build, and `turbo-ignore` so a push rebuilds only affected apps. Pair it with a + * project-level `rootDirectory` of `apps/{slug}` and CLEARED project-level build + * overrides (a project-level buildCommand silently wins over this file). + */ +export function appVercelJson( + app: RegisteredApp, + doc: WalkthroughDocument, + options?: AppVercelJsonOptions, +): Record { + const packageName = options?.packageName ?? `@${doc.deploy.slug}/app-${app.slug}`; + return { + // biome-ignore lint/style/useNamingConvention: fixed vercel.json wire field + $schema: "https://openapi.vercel.sh/vercel.json", + buildCommand: `pnpm --dir ../.. exec turbo run build --filter=${packageName}`, + framework: "nextjs", + ignoreCommand: "npx turbo-ignore --fallback=HEAD^1", + }; +} + +/** + * A zone health probe for the live-status dock: same-origin via the shell's multi-zone + * rewrites, honest `simulated: true` payload. Spread it from a route file: + * + * ```ts + * // app/api/health/route.ts + * export const { GET } = healthRoute("vault"); + * ``` + */ +// biome-ignore lint/style/useNamingConvention: GET is the Next route-handler export name +export function healthRoute(service: string): { GET(): Response } { + return { + // biome-ignore lint/style/useNamingConvention: GET is the Next route-handler export name + GET(): Response { + return Response.json({ ok: true, service, simulated: true }); + }, + }; +} + +/** + * Metadata for the shell's root layout (variant "own" — the walkthrough publishes under + * the convener's own branding): concept-demo title suffix, non-affiliation description, + * and noindex/nofollow. Structurally compatible with Next `Metadata`. + */ +export function showcaseMetadata(doc: WalkthroughDocument): DemoMetadata { + return documentDisclaimerPack(doc).demoMetadata({ + appName: doc.site.appName, + organization: doc.site.organization, + variant: "own", + }); +} diff --git a/packages/solid-showcase/src/pages/showcase-chapter-page.tsx b/packages/solid-showcase/src/pages/showcase-chapter-page.tsx new file mode 100644 index 000000000..743a6f4a9 --- /dev/null +++ b/packages/solid-showcase/src/pages/showcase-chapter-page.tsx @@ -0,0 +1,111 @@ +// AUTHORED-BY Claude Fable 5 +import { ChapterPlayer } from "../components/chapter-player.js"; +import { chapterBySlug } from "../document.js"; +import type { WalkthroughDocument } from "../schema.js"; + +export interface ShowcaseChapterPageProps { + document: WalkthroughDocument; + slug: string; +} + +/** + * One chapter: scene navigation pills, the chapter header (scene number, title, lead, + * anchor), the keyboard-navigable chapter player, and previous/next navigation. Guard the + * slug with {@link chapterBySlug} in your route (e.g. Next `notFound()`) — an unknown + * slug here throws. + */ +export function ShowcaseChapterPage({ document: doc, slug }: ShowcaseChapterPageProps) { + const chapter = chapterBySlug(doc, slug); + if (chapter === undefined) { + throw new Error(`Unknown chapter slug "${slug}" — guard routes with chapterBySlug().`); + } + const { chapters, compliance } = doc; + const index = chapters.findIndex((entry) => entry.slug === chapter.slug); + const previous = index > 0 ? chapters[index - 1] : undefined; + const next = index < chapters.length - 1 ? chapters[index + 1] : undefined; + + return ( +
+ + +
+

+ Scene {chapter.scene} of {chapters.length} +

+

+ {chapter.title} +

+

{chapter.lead}

+

+ Anchor: {chapter.anchor} +

+
+ +
+ +
+ + +
+ ); +} diff --git a/packages/solid-showcase/src/pages/showcase-compliance-page.tsx b/packages/solid-showcase/src/pages/showcase-compliance-page.tsx new file mode 100644 index 000000000..83d610633 --- /dev/null +++ b/packages/solid-showcase/src/pages/showcase-compliance-page.tsx @@ -0,0 +1,85 @@ +// AUTHORED-BY Claude Fable 5 +import type { WalkthroughDocument } from "../schema.js"; + +export interface ShowcaseCompliancePageProps { + document: WalkthroughDocument; +} + +/** + * The compliance/reviewer lens, deliberately UNBRANDED — neutral grayscale styling, no + * authority name in the chrome, no seal-like imagery — with the document's mandatory + * non-affiliation statement rendered prominently. Content is the journey's public-rule + * beats rendered as a reviewer-facing checklist. Only route here when + * `document.compliance` is configured — rendering without it throws. + */ +export function ShowcaseCompliancePage({ document: doc }: ShowcaseCompliancePageProps) { + const lens = doc.compliance; + if (lens === undefined) { + throw new Error( + "This walkthrough document configures no compliance lens — do not route to the compliance page.", + ); + } + + return ( +
+
+

+ Reviewer lens — illustrative +

+

+ {lens.title} +

+

+ {lens.nonAffiliation} +

+

+ The demo journey dramatizes public rules. This view renders those beats the way a reviewer + would read them: the rule, its public source, and what the subject-held record makes + checkable. +

+
+ +
    + {lens.checks.map((check, index) => ( +
  1. +
    + +
    +

    {check.rule}

    +

    + + {check.citation} + +

    +

    {check.observe}

    +

    + + Dramatized in scene {check.scene} → + +

    +
    +
    +
  2. + ))} +
+ +

+ All rules referenced are public. {lens.nonAffiliation} +

+
+ ); +} diff --git a/packages/solid-showcase/src/pages/showcase-landing.tsx b/packages/solid-showcase/src/pages/showcase-landing.tsx new file mode 100644 index 000000000..f85b65be4 --- /dev/null +++ b/packages/solid-showcase/src/pages/showcase-landing.tsx @@ -0,0 +1,153 @@ +// AUTHORED-BY Claude Fable 5 +import { StatCard } from "@jeswr/solid-showcase-kit"; +import { DemoIdentityCard } from "../components/demo-identity-card.js"; +import { EcosystemMap } from "../components/ecosystem-map.js"; +import type { WalkthroughDocument } from "../schema.js"; + +export interface ShowcaseLandingProps { + document: WalkthroughDocument; +} + +/** + * The landing page, entirely document-driven: hero, the quantified-anchor stat row with + * public sources, the interactive ecosystem map, the chapter cards (plus the compliance + * lens card when configured), and the copy-ready demo identity. + */ +export function ShowcaseLanding({ document: doc }: ShowcaseLandingProps) { + const { site, anchors, chapters, compliance, persona } = doc; + const firstChapter = chapters[0]; + + return ( +
+
+

+ {site.organization} +

+

+ {site.heroTitle} +

+

{site.heroLead}

+

{site.heroParagraph}

+ +
+ + {anchors.length > 0 && ( +
+

+ Why now — the numbers +

+
+ {anchors.map((anchor) => ( + + ))} +
+ +
+ )} + +
+

+ The ecosystem +

+

+ Every application maps to a seat a real party holds in the journey. Click a seat to see + who holds it, what the demo shows in its colours, and — where an app exists — launch it + live. +

+
+ +
+
+ +
+

+ The journey — {chapters.length} scene{chapters.length === 1 ? "" : "s"} +

+

+ One persona — {persona.name}, a fictional identity — carries the whole journey. Each scene + names the public rule or industry fact it dramatizes. +

+
    + {chapters.map((chapter) => ( +
  1. + +

    Scene {chapter.scene}

    +

    {chapter.title}

    +

    {chapter.lead}

    +
    +
  2. + ))} + {compliance !== undefined && ( +
  3. + +

    Reviewer lens

    +

    {compliance.title}

    +

    {compliance.nonAffiliation}

    +
    +
  4. + )} +
+
+ +
+

+ The demo identity +

+

+ Values a presenter can copy in seconds — every one of them simulated. +

+
+ +
+
+
+ ); +} diff --git a/packages/solid-showcase/src/pages/showcase-layout.tsx b/packages/solid-showcase/src/pages/showcase-layout.tsx new file mode 100644 index 000000000..f3bff38e0 --- /dev/null +++ b/packages/solid-showcase/src/pages/showcase-layout.tsx @@ -0,0 +1,57 @@ +// AUTHORED-BY Claude Fable 5 +"use client"; + +import { AppShell, ConsentInterstitial, ShowcaseTrustProvider } from "@jeswr/solid-showcase-kit"; +import type { ReactNode } from "react"; +import { Launcher } from "../components/launcher.js"; +import { documentDisclaimerPack, shellApp, shellTheme } from "../document.js"; +import type { WalkthroughDocument } from "../schema.js"; + +export interface ShowcaseLayoutProps { + document: WalkthroughDocument; + children: ReactNode; +} + +/** + * The walkthrough shell frame, entirely document-driven: trust provider + AppShell + * (variant "own" — the shell publishes under the convener's own branding), section nav, + * the launcher dock, and the per-app consent interstitial. Render it inside your root + * layout's ``. + */ +export function ShowcaseLayout({ document: doc, children }: ShowcaseLayoutProps) { + const pack = documentDisclaimerPack(doc); + const theme = shellTheme(doc); + const shell = shellApp(doc.registry); + + return ( + + + + Ecosystem + + + Chapters + + {doc.compliance !== undefined && ( + + {doc.compliance.title} + + )} + + } + variant="own" + > + {children} + + + + + ); +} diff --git a/packages/solid-showcase/src/schema.ts b/packages/solid-showcase/src/schema.ts new file mode 100644 index 000000000..97f16b4e4 --- /dev/null +++ b/packages/solid-showcase/src/schema.ts @@ -0,0 +1,248 @@ +// AUTHORED-BY Claude Fable 5 +/** + * The walkthrough document schema — the single JSON document that drives a whole + * walkthrough site (landing, chapters, ecosystem map, launcher, compliance lens, deploy + * helpers). + * + * Schema-first: every schema is a plain `z.object` with NO type annotation and the + * TypeScript contracts are INFERRED from them. The shipped JSON-Schema artifact + * (`schema/walkthrough.v1.json`) is generated from {@link walkthroughDocumentSchema} via + * {@link walkthroughJsonSchema}, so neither the types nor the artifact can drift from the + * runtime validator. + * + * Branding and theming contracts are COMPOSED from `@jeswr/solid-showcase-kit` — the kit + * owns them (`branding: brandingConfigSchema`, `theme: themeSpecSchema`); this package + * owns only the document-shaped types. The dependency edge is acyclic: showcase depends + * on kit, never the reverse. + * + * This package is domain-generic by design: nothing in the schema (or anywhere in this + * package) names a use case — domain knowledge enters only through the values of the + * document a consumer supplies. + */ +import { brandingConfigSchema, themeSpecSchema } from "@jeswr/solid-showcase-kit"; +import { z } from "zod"; + +const LOWER_SLUG = /^[a-z0-9-]+$/; +const ENV_VAR_NAME = /^[A-Z][A-Z0-9_]*$/; +const ABSOLUTE_PATH = /^\//; + +/** Site-wide identity + hero copy for the walkthrough shell. */ +export const siteIdentitySchema = z.object({ + /** e.g. "Open Trails Walkthrough" */ + appName: z.string().min(1), + /** Publishing organisation (the convener) — drives the "own" disclaimer variant. */ + organization: z.string().min(1), + heroTitle: z.string(), + /** Two-sided value headline. */ + heroLead: z.string(), + heroParagraph: z.string(), + startCtaLabel: z.string().optional(), + exploreCtaLabel: z.string().optional(), +}); + +export const personaFieldSchema = z.object({ + label: z.string(), + value: z.string(), + /** Renders a copy-to-clipboard action; defaults to true. */ + copyable: z.boolean().optional(), + note: z.string().optional(), +}); + +/** Copy-ready demo identity; the descriptor MUST self-identify as fictional/simulated. */ +export const demoPersonaCardSchema = z.object({ + name: z.string(), + descriptor: z.string(), + fields: z.array(personaFieldSchema).min(1), + /** Rendered under the card, e.g. the scripted thresholds the values are pinned to. */ + footnote: z.string().optional(), +}); + +/** Landing stat-row anchor; the source URL must be public and dereferenceable. */ +export const quantifiedAnchorSchema = z.object({ + id: z.string(), + value: z.string(), + label: z.string(), + detail: z.string(), + source: z.object({ + name: z.string(), + url: z.url().startsWith("https://"), + }), +}); + +/** A registered demo surface. Zone apps resolve through the shell's multi-zone rewrites. */ +export const registeredAppSchema = z.object({ + slug: z.string().regex(LOWER_SLUG), + appName: z.string(), + /** Role-first honest branding: "modelled on X". Never "by X". */ + modelledOn: z.string(), + /** In-shell path (zone rewrite prefix, or a shell-local route). */ + path: z.string().regex(ABSOLUTE_PATH), + /** Env var the shell rewrite reads; absent = shell-local surface. */ + zoneEnv: z.string().regex(ENV_VAR_NAME).optional(), + healthPath: z.string().regex(ABSOLUTE_PATH), + /** Palette-inspired theme, never brand-guideline values. */ + theme: themeSpecSchema.optional(), + /** HonestyPanel content — single-sourced here so apps and the shell agree. */ + honesty: z + .object({ + real: z.array(z.string()), + simulated: z.array(z.string()), + }) + .optional(), + /** + * Authenticated pod API routes this app serves (app-relative paths). Declaring any + * makes the deploy env matrix require `{envPrefix}_TRUST_FORWARDED_HEADERS` on the + * app's project — behind a proxy the proof's bound URL must be computed from the + * public host, or every authenticated call fails. + */ + podRoutes: z.array(z.string().regex(ABSOLUTE_PATH)).optional(), +}); + +/** A seat in the ecosystem map. Empty `apps` = mapped seat with no app (shown honestly). */ +export const ecosystemRoleSchema = z.object({ + slug: z.string(), + /** Value-chain number; absent for the centre node. */ + roleNumber: z.int().min(1).optional(), + role: z.string(), + modelledOn: z.string(), + /** Honest recruitment framing, e.g. "External approach". */ + membership: z.string(), + summary: z.string(), + scene: z.int().min(1).optional(), + /** Keys of registry.apps. */ + apps: z.array(z.string()), + center: z.boolean().optional(), +}); + +/** The ONE source for map/launcher/try-live/zones. */ +export const serviceRegistrySchema = z.object({ + /** Keyed by slug. The shell itself registers too (zoneEnv absent). */ + apps: z.record(z.string(), registeredAppSchema), + /** Launcher dock order; every entry must be a key of `apps`. */ + launcherOrder: z.array(z.string()), + roles: z.array(ecosystemRoleSchema), + /** Slug of the centre role (the data-subject's own vault/pod). */ + center: z.string(), +}); + +export const chapterStepSchema = z.object({ + title: z.string().min(1), + body: z.string(), + /** `app` must be a registry key. */ + tryLive: z.object({ + app: z.string(), + label: z.string().min(1), + }), +}); + +export const walkthroughChapterSchema = z.object({ + slug: z.string().regex(LOWER_SLUG), + /** 1-based, contiguous, in array order. */ + scene: z.int().min(1), + title: z.string(), + /** The regulation/industry anchor the scene dramatizes. */ + anchor: z.string().min(10), + lead: z.string(), + steps: z.array(chapterStepSchema).min(2), + /** Plain-English "what just happened underneath". */ + underneath: z.array(z.string().min(20)).optional(), + /** Enforce presence of `underneath` (the protocol/proof beats). Default false. */ + underneathRequired: z.boolean().optional(), +}); + +/** + * Editorial overrides may only TIGHTEN the schema floors, never loosen them: the + * generated JSON-Schema pins the absolute minimums (steps minItems 2, underneath + * minLength 20), and zod rejects minSteps < 2 / minUnderneathChars < 20 — so no valid + * override can conflict with the schema. + */ +export const editorialLimitsSchema = z.object({ + maxLeadWords: z.int().min(1).optional(), + maxStepWords: z.int().min(1).optional(), + minSteps: z.int().min(2).optional(), + minUnderneathChars: z.int().min(20).optional(), +}); + +export const complianceCheckSchema = z.object({ + id: z.string(), + /** The public rule, plain English. */ + rule: z.string(), + citation: z.string(), + citationUrl: z.url().startsWith("https://"), + scene: z.int(), + /** Must resolve to a chapter whose scene equals `scene`. */ + chapterSlug: z.string(), + /** What the lens checks in the demo journey. */ + observe: z.string(), +}); + +/** The regulator/reviewer lens, rendered deliberately unbranded. */ +export const complianceLensSchema = z.object({ + title: z.string(), + /** Mandatory non-affiliation statement rendered in the lens chrome. */ + nonAffiliation: z.string().min(20), + checks: z.array(complianceCheckSchema), +}); + +export const deployConfigSchema = z.object({ + /** Use-case slug — drives cookie prefix + generated names. */ + slug: z.string().regex(LOWER_SLUG), + /** Env-var prefix — drives `{envPrefix}_TRUST_FORWARDED_HEADERS` etc. */ + envPrefix: z.string().regex(ENV_VAR_NAME), +}); + +/** The whole walkthrough site, as data. Version is a literal — breaking changes bump it. */ +export const walkthroughDocumentSchema = z.object({ + version: z.literal(1), + site: siteIdentitySchema, + branding: brandingConfigSchema, + persona: demoPersonaCardSchema, + /** 0–6; landing stat row. */ + anchors: z.array(quantifiedAnchorSchema).max(6), + registry: serviceRegistrySchema, + /** ≥1, scene numbers contiguous from 1. */ + chapters: z.array(walkthroughChapterSchema).min(1), + /** Defaults applied when absent. */ + editorial: editorialLimitsSchema.optional(), + compliance: complianceLensSchema.optional(), + deploy: deployConfigSchema, +}); + +export type SiteIdentity = z.infer; +export type PersonaField = z.infer; +export type DemoPersonaCard = z.infer; +export type QuantifiedAnchor = z.infer; +export type RegisteredApp = z.infer; +export type EcosystemRole = z.infer; +export type ServiceRegistry = z.infer; +export type ChapterStep = z.infer; +export type WalkthroughChapter = z.infer; +export type EditorialLimits = z.infer; +export type ComplianceCheck = z.infer; +export type ComplianceLens = z.infer; +export type DeployConfig = z.infer; +export type WalkthroughDocument = z.infer; + +/** + * The generated JSON-Schema artifact, exactly as shipped at + * `@jeswr/solid-showcase/schema/walkthrough.v1.json`. `scripts/generate-schema.mjs` + * writes this to disk; a unit test asserts the committed artifact stays byte-identical + * to this output. + * + * `$id` is the bare artifact name: a hosted, dereferenceable IRI is deferred until one + * actually resolves (no minted IRIs). + */ +export function walkthroughJsonSchema(): Record { + const generated = z.toJSONSchema(walkthroughDocumentSchema, { + target: "draft-2020-12", + }) as Record; + const { $schema, ...rest } = generated; + return { + // biome-ignore lint/style/useNamingConvention: fixed JSON-Schema wire field + $schema, + // biome-ignore lint/style/useNamingConvention: fixed JSON-Schema wire field + $id: "walkthrough.v1.json", + title: "WalkthroughDocument", + ...rest, + }; +} diff --git a/packages/solid-showcase/src/validate.ts b/packages/solid-showcase/src/validate.ts new file mode 100644 index 000000000..ed632d55d --- /dev/null +++ b/packages/solid-showcase/src/validate.ts @@ -0,0 +1,365 @@ +// AUTHORED-BY Claude Fable 5 +/** + * Document validation beyond the type shapes: registry resolution, chapter contiguity, + * editorial budgets, compliance cross-references, honesty floors. Every issue names the + * chapter/step/role/check it comes from, and `parseWalkthrough` reports ALL issues at + * once rather than failing on the first. + */ +import type { ZodError } from "zod"; +import { + type EditorialLimits, + type WalkthroughChapter, + type WalkthroughDocument, + walkthroughDocumentSchema, +} from "./schema.js"; + +/** One validation failure, with a stable machine code and a human message. */ +export interface WalkthroughIssue { + /** Stable rule identifier, e.g. "unknown-try-live-app". */ + code: string; + /** Dotted location, e.g. `chapters[pack-the-vault].steps[1].tryLive.app`. */ + path: string; + message: string; +} + +/** Thrown by {@link parseWalkthrough}; carries every issue found. */ +export class WalkthroughValidationError extends Error { + readonly issues: readonly WalkthroughIssue[]; + + constructor(issues: readonly WalkthroughIssue[]) { + super( + `Invalid walkthrough document (${issues.length} issue${issues.length === 1 ? "" : "s"}):\n${issues + .map((issue) => `- [${issue.code}] ${issue.path}: ${issue.message}`) + .join("\n")}`, + ); + this.name = "WalkthroughValidationError"; + this.issues = issues; + } +} + +/** Editorial limits with the defaults applied. */ +export interface ResolvedEditorialLimits { + maxLeadWords: number; + maxStepWords: number; + minSteps: number; + minUnderneathChars: number; +} + +export const EDITORIAL_DEFAULTS: ResolvedEditorialLimits = Object.freeze({ + maxLeadWords: 40, + maxStepWords: 65, + minSteps: 2, + minUnderneathChars: 20, +}); + +/** Apply the editorial defaults to a document's (possibly absent) overrides. */ +export function resolveEditorial(editorial?: EditorialLimits): ResolvedEditorialLimits { + return { + maxLeadWords: editorial?.maxLeadWords ?? EDITORIAL_DEFAULTS.maxLeadWords, + maxStepWords: editorial?.maxStepWords ?? EDITORIAL_DEFAULTS.maxStepWords, + minSteps: editorial?.minSteps ?? EDITORIAL_DEFAULTS.minSteps, + minUnderneathChars: editorial?.minUnderneathChars ?? EDITORIAL_DEFAULTS.minUnderneathChars, + }; +} + +export function countWords(text: string): number { + return text.split(/\s+/u).filter((word) => word.length > 0).length; +} + +/** One editorial budget violation. */ +export interface EditorialFinding { + chapterSlug: string; + scene: number; + rule: "lead-budget" | "step-budget" | "min-steps" | "underneath-required" | "underneath-length"; + /** Step title, for step-level findings. */ + stepTitle?: string; + limit: number; + actual: number; + message: string; +} + +/** + * Machine-enforced editorial gates: a chapter cannot ship a bloated lead or step, too few + * steps, or a missing/thin "underneath" panel where one is required. Empty result = + * publishable. + */ +export function editorialFindings(doc: WalkthroughDocument): EditorialFinding[] { + const limits = resolveEditorial(doc.editorial); + const findings: EditorialFinding[] = []; + + for (const chapter of doc.chapters) { + const name = `chapter "${chapter.slug}" (scene ${chapter.scene})`; + const leadWords = countWords(chapter.lead); + if (leadWords > limits.maxLeadWords) { + findings.push({ + actual: leadWords, + chapterSlug: chapter.slug, + limit: limits.maxLeadWords, + message: `${name}: lead is ${leadWords} words (max ${limits.maxLeadWords})`, + rule: "lead-budget", + scene: chapter.scene, + }); + } + if (chapter.steps.length < limits.minSteps) { + findings.push({ + actual: chapter.steps.length, + chapterSlug: chapter.slug, + limit: limits.minSteps, + message: `${name}: has ${chapter.steps.length} step(s) (min ${limits.minSteps})`, + rule: "min-steps", + scene: chapter.scene, + }); + } + for (const step of chapter.steps) { + const bodyWords = countWords(step.body); + if (bodyWords > limits.maxStepWords) { + findings.push({ + actual: bodyWords, + chapterSlug: chapter.slug, + limit: limits.maxStepWords, + message: `${name}, step "${step.title}": body is ${bodyWords} words (max ${limits.maxStepWords})`, + rule: "step-budget", + scene: chapter.scene, + stepTitle: step.title, + }); + } + } + if (chapter.underneathRequired === true) { + const underneath = chapter.underneath ?? []; + if (underneath.length === 0) { + findings.push({ + actual: 0, + chapterSlug: chapter.slug, + limit: 1, + message: `${name}: underneathRequired is set but "underneath" is missing or empty`, + rule: "underneath-required", + scene: chapter.scene, + }); + } + for (const point of underneath) { + if (point.length < limits.minUnderneathChars) { + findings.push({ + actual: point.length, + chapterSlug: chapter.slug, + limit: limits.minUnderneathChars, + message: `${name}: underneath point "${point}" is ${point.length} chars (min ${limits.minUnderneathChars})`, + rule: "underneath-length", + scene: chapter.scene, + }); + } + } + } + } + + return findings; +} + +function chapterName(chapter: WalkthroughChapter): string { + return `chapter "${chapter.slug}" (scene ${chapter.scene})`; +} + +function crossValidationIssues(doc: WalkthroughDocument): WalkthroughIssue[] { + const issues: WalkthroughIssue[] = []; + const { registry, chapters, compliance, persona } = doc; + const appKeys = new Set(Object.keys(registry.apps)); + + // Every registry.apps record key equals its entry's slug. + for (const [key, app] of Object.entries(registry.apps)) { + if (key !== app.slug) { + issues.push({ + code: "registry-key-mismatch", + message: `registry.apps["${key}"] declares slug "${app.slug}" — record keys must equal their entry's slug`, + path: `registry.apps[${key}].slug`, + }); + } + } + + // Every launcherOrder entry resolves; no duplicates. + const seenLauncher = new Set(); + registry.launcherOrder.forEach((slug, index) => { + if (!appKeys.has(slug)) { + issues.push({ + code: "unknown-launcher-app", + message: `launcherOrder[${index}] "${slug}" is not a key of registry.apps`, + path: `registry.launcherOrder[${index}]`, + }); + } + if (seenLauncher.has(slug)) { + issues.push({ + code: "duplicate-launcher-app", + message: `launcherOrder[${index}] "${slug}" appears more than once`, + path: `registry.launcherOrder[${index}]`, + }); + } + seenLauncher.add(slug); + }); + + // Role slugs unique; every roles[].apps[] resolves; exactly one centre role matching + // registry.center; every roles[].scene names an existing chapter scene. + const sceneNumbers = new Set(chapters.map((chapter) => chapter.scene)); + const seenRoleSlugs = new Set(); + const centerRoles: string[] = []; + registry.roles.forEach((role, index) => { + if (seenRoleSlugs.has(role.slug)) { + issues.push({ + code: "duplicate-role-slug", + message: `role "${role.slug}" (roles[${index}]) duplicates an earlier role slug`, + path: `registry.roles[${index}].slug`, + }); + } + seenRoleSlugs.add(role.slug); + if (role.center === true) centerRoles.push(role.slug); + for (const appSlug of role.apps) { + if (!appKeys.has(appSlug)) { + issues.push({ + code: "unknown-role-app", + message: `role "${role.slug}" lists app "${appSlug}", which is not a key of registry.apps`, + path: `registry.roles[${index}].apps`, + }); + } + } + if (role.scene !== undefined && !sceneNumbers.has(role.scene)) { + issues.push({ + code: "unknown-role-scene", + message: `role "${role.slug}" names scene ${role.scene}, but no chapter has that scene`, + path: `registry.roles[${index}].scene`, + }); + } + }); + if (centerRoles.length !== 1 || centerRoles[0] !== registry.center) { + issues.push({ + code: "center-role", + message: + centerRoles.length === 0 + ? `no role has center: true (registry.center is "${registry.center}")` + : centerRoles.length > 1 + ? `multiple roles claim center: true (${centerRoles.map((slug) => `"${slug}"`).join(", ")})` + : `the centre role is "${centerRoles[0]}" but registry.center is "${registry.center}"`, + path: "registry.center", + }); + } + + // Chapter scene numbers contiguous 1..N in array order; slugs unique. + const seenChapterSlugs = new Set(); + chapters.forEach((chapter, index) => { + if (chapter.scene !== index + 1) { + issues.push({ + code: "chapter-scene-order", + message: `${chapterName(chapter)} is at position ${index} — scenes must be contiguous 1..N in array order (expected scene ${index + 1})`, + path: `chapters[${index}].scene`, + }); + } + if (seenChapterSlugs.has(chapter.slug)) { + issues.push({ + code: "duplicate-chapter-slug", + message: `${chapterName(chapter)} duplicates an earlier chapter slug`, + path: `chapters[${index}].slug`, + }); + } + seenChapterSlugs.add(chapter.slug); + + // Every steps[].tryLive.app resolves in registry.apps. + chapter.steps.forEach((step, stepIndex) => { + if (!appKeys.has(step.tryLive.app)) { + issues.push({ + code: "unknown-try-live-app", + message: `${chapterName(chapter)}, step ${stepIndex + 1} "${step.title}": tryLive.app "${step.tryLive.app}" is not a key of registry.apps`, + path: `chapters[${index}].steps[${stepIndex}].tryLive.app`, + }); + } + }); + }); + + // compliance.checks[].chapterSlug resolves AND the check's scene equals that chapter's. + if (compliance !== undefined) { + const chaptersBySlug = new Map(chapters.map((chapter) => [chapter.slug, chapter])); + compliance.checks.forEach((check, index) => { + const chapter = chaptersBySlug.get(check.chapterSlug); + if (chapter === undefined) { + issues.push({ + code: "unknown-compliance-chapter", + message: `compliance check "${check.id}" references chapter "${check.chapterSlug}", which does not exist`, + path: `compliance.checks[${index}].chapterSlug`, + }); + } else if (chapter.scene !== check.scene) { + issues.push({ + code: "compliance-scene-mismatch", + message: `compliance check "${check.id}" says scene ${check.scene}, but ${chapterName(chapter)} has scene ${chapter.scene}`, + path: `compliance.checks[${index}].scene`, + }); + } + }); + } + + // Honesty floor: the persona must self-identify as fictional/simulated. + const descriptor = persona.descriptor.toLowerCase(); + if (!(descriptor.includes("fictional") || descriptor.includes("simulated"))) { + issues.push({ + code: "persona-honesty", + message: `persona.descriptor must contain "fictional" or "simulated" — got "${persona.descriptor}"`, + path: "persona.descriptor", + }); + } + + return issues; +} + +function zodErrorToIssues(error: ZodError): WalkthroughIssue[] { + return error.issues.map((issue) => { + const path = issue.path.map(String).join("."); + return { + // Editorial overrides below the schema floors get their own stable code — the + // floors (minSteps ≥ 2, minUnderneathChars ≥ 20) are pinned in the schema itself. + code: issue.path[0] === "editorial" ? "editorial-floor" : "schema", + message: issue.message, + path, + }; + }); +} + +/** + * Validate an untrusted JSON value as a walkthrough document. Enforces the full rule + * table: shape (zod), registry resolution, launcher/role/centre integrity, chapter + * contiguity, editorial budgets, compliance cross-references, and the persona honesty + * floor. Throws {@link WalkthroughValidationError} carrying EVERY issue, each with a + * stable code and a chapter/step-naming message. + */ +export function parseWalkthrough(json: unknown): WalkthroughDocument { + const parsed = walkthroughDocumentSchema.safeParse(json); + if (!parsed.success) { + throw new WalkthroughValidationError(zodErrorToIssues(parsed.error)); + } + const doc = parsed.data; + const issues = crossValidationIssues(doc); + for (const finding of editorialFindings(doc)) { + issues.push({ + code: finding.rule, + message: finding.message, + path: + finding.stepTitle === undefined + ? `chapters[${finding.chapterSlug}]` + : `chapters[${finding.chapterSlug}].steps["${finding.stepTitle}"]`, + }); + } + if (issues.length > 0) throw new WalkthroughValidationError(issues); + return doc; +} + +/** + * Non-fatal advisories: currently the role-first naming rule — an app's display name + * must not embed the organisation it is modelled on ("modelled on X", never "X app"). + */ +export function walkthroughWarnings(doc: WalkthroughDocument): WalkthroughIssue[] { + const warnings: WalkthroughIssue[] = []; + for (const [key, app] of Object.entries(doc.registry.apps)) { + const modelledOn = app.modelledOn.trim().toLowerCase(); + if (modelledOn.length > 0 && app.appName.toLowerCase().includes(modelledOn)) { + warnings.push({ + code: "role-first-app-name", + message: `app "${key}": appName "${app.appName}" contains modelledOn "${app.modelledOn}" — name apps role-first, never after the modelled organisation`, + path: `registry.apps[${key}].appName`, + }); + } + } + return warnings; +} diff --git a/packages/solid-showcase/test/components.test.tsx b/packages/solid-showcase/test/components.test.tsx new file mode 100644 index 000000000..f2af31dca --- /dev/null +++ b/packages/solid-showcase/test/components.test.tsx @@ -0,0 +1,209 @@ +// AUTHORED-BY Claude Fable 5 +// @vitest-environment jsdom +/** + * Walkthrough UI building blocks: keyboard stepper, try-live honest degradation, + * launcher placeholder-link pattern, ecosystem-map selection, persona-card copy actions. + */ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ChapterPlayer } from "../src/components/chapter-player.js"; +import { DemoIdentityCard } from "../src/components/demo-identity-card.js"; +import { EcosystemMap } from "../src/components/ecosystem-map.js"; +import { Launcher } from "../src/components/launcher.js"; +import { TryLiveButton } from "../src/components/try-live.js"; +import { exampleWalkthrough } from "./support/example-document.js"; + +const registry = exampleWalkthrough.registry; +const firstChapter = exampleWalkthrough.chapters[0]; +if (firstChapter === undefined) throw new Error("fixture drift"); + +function stubFetch(handler: (path: string) => Promise) { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => handler(String(input))), + ); +} + +const live = () => Promise.resolve(Response.json({ ok: true, service: "x", simulated: true })); +const down = () => Promise.reject(new Error("unreachable")); +const forever = () => new Promise(() => {}); + +beforeEach(() => { + stubFetch(down); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("ChapterPlayer — keyboard stepper", () => { + test("advances with next/previous controls and step dots", () => { + const { container } = render(); + expect(container.querySelector("[data-chapter-player='pack-the-vault']")).not.toBeNull(); + expect(screen.getByText("Step 1 of 2")).toBeTruthy(); + expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Open the vault"); + + fireEvent.click(screen.getByRole("button", { name: "Next step →" })); + expect(screen.getByText("Step 2 of 2")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Next step →" })).toHaveProperty("disabled", true); + + fireEvent.click(screen.getByRole("button", { name: "← Previous step" })); + expect(screen.getByText("Step 1 of 2")).toBeTruthy(); + + const dot2 = screen.getByRole("button", { name: "Step 2: Grant the permit desk access" }); + fireEvent.click(dot2); + expect(dot2.getAttribute("aria-current")).toBe("step"); + }); + + test("arrow keys drive the stepper", () => { + render(); + const stepper = screen.getByRole("navigation", { name: "Steps in this chapter" }); + fireEvent.keyDown(stepper, { key: "ArrowRight" }); + expect(screen.getByText("Step 2 of 2")).toBeTruthy(); + fireEvent.keyDown(stepper, { key: "ArrowLeft" }); + expect(screen.getByText("Step 1 of 2")).toBeTruthy(); + }); + + test("renders the underneath panel when present", () => { + const { container } = render(); + const panel = container.querySelector("[data-underneath-panel]"); + expect(panel?.textContent).toContain("What just happened underneath"); + expect(panel?.textContent).toContain("signed receipt"); + }); +}); + +describe("TryLiveButton — honest degradation", () => { + test("live zone renders a real deep link", async () => { + stubFetch(live); + const { container } = render( + , + ); + await waitFor(() => { + const anchor = container.querySelector("a[data-try-live='vault']"); + expect(anchor?.getAttribute("href")).toBe("/vault"); + }); + expect(container.querySelector("[data-try-live-disabled]")).toBeNull(); + }); + + test("while checking, the control is a visible placeholder link", () => { + stubFetch(forever); + const { container } = render( + , + ); + const anchor = container.querySelector("a[data-try-live='vault']"); + expect(anchor?.getAttribute("href")).toBeNull(); + expect(anchor?.getAttribute("aria-disabled")).toBe("true"); + expect(container.textContent).toContain("Checking whether this app is deployed…"); + }); + + test("an undeployed zone stays visible, disabled, and explained — never navigable", async () => { + stubFetch(down); + const { container } = render( + , + ); + await waitFor(() => { + expect(container.textContent).toContain( + "Traveller Vault is not deployed in this environment", + ); + }); + const anchor = container.querySelector("a[data-try-live='vault']"); + expect(anchor?.getAttribute("href")).toBeNull(); + expect(anchor?.getAttribute("aria-disabled")).toBe("true"); + expect(anchor?.hasAttribute("data-try-live-disabled")).toBe(true); + }); +}); + +describe("Launcher — placeholder-link pattern", () => { + test("lists every launcherOrder app; undeployed entries are placeholder links", async () => { + stubFetch((path) => (path === "/api/health" ? live() : down())); + const { container } = render(); + fireEvent.click(screen.getByRole("button", { name: "Apps" })); + + const dock = container.querySelector("[data-launcher]"); + expect(dock).not.toBeNull(); + const entries = [...(dock?.querySelectorAll("[data-launcher-app]") ?? [])]; + expect(entries.map((entry) => entry.getAttribute("data-launcher-app"))).toEqual([ + "atlas", + "vault", + "permits", + "outfitter", + "advisory", + ]); + + // atlas + advisory probe /api/health (live); zone apps are down → placeholder links. + await waitFor(() => { + const atlas = dock?.querySelector("[data-launcher-app='atlas']"); + expect(atlas?.getAttribute("href")).toBe("/"); + }); + const vault = dock?.querySelector("[data-launcher-app='vault']"); + expect(vault?.getAttribute("href")).toBeNull(); + expect(vault?.getAttribute("aria-disabled")).toBe("true"); + expect(dock?.textContent).toContain( + "Zones marked “Not deployed” are not wired in this environment.", + ); + }); +}); + +describe("EcosystemMap — selection", () => { + test("centre is selected by default; clicking a seat updates the detail panel", () => { + const { container } = render(); + const centre = container.querySelector("[data-map-center]"); + expect(centre?.getAttribute("aria-pressed")).toBe("true"); + const detail = container.querySelector("[data-map-detail]"); + expect(detail?.textContent).toContain("Traveller"); + expect(detail?.textContent).toContain("modelled on Cairn Cooperative"); + + const permitNode = container.querySelector("[data-map-node='permit-authority']"); + if (permitNode === null) throw new Error("missing map node"); + fireEvent.click(permitNode); + expect(permitNode.getAttribute("aria-pressed")).toBe("true"); + expect(centre?.getAttribute("aria-pressed")).toBe("false"); + expect(detail?.textContent).toContain("Role 1 · Permit authority"); + expect(detail?.textContent).toContain("Issues day permits"); + expect(detail?.querySelector("[data-try-live='permits']")).not.toBeNull(); + }); + + test("a mapped-but-unbuilt seat says so honestly", () => { + const { container } = render(); + const stewards = container.querySelector("[data-map-node='stewards']"); + if (stewards === null) throw new Error("missing map node"); + expect(stewards.textContent).toContain("Mapped seat — no app in this demo"); + fireEvent.click(stewards); + const detail = container.querySelector("[data-map-detail]"); + expect(detail?.querySelector("[data-map-no-app]")?.textContent).toContain( + "no application in this demo", + ); + }); +}); + +describe("DemoIdentityCard — copy actions", () => { + test("copies a field value and confirms; non-copyable fields render no button", async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const { container } = render(); + expect(container.querySelector("[data-demo-identity-card]")).not.toBeNull(); + expect(container.textContent).toContain("Fictional persona — every value is simulated."); + expect(container.textContent).toContain( + "Values are pinned to the walkthrough's scripted checks.", + ); + + const buttons = screen.getAllByRole("button", { name: "Copy" }); + // Three fields, one of which (Emergency contact) is copyable: false. + expect(buttons).toHaveLength(2); + const first = buttons[0]; + if (first === undefined) throw new Error("missing copy button"); + fireEvent.click(first); + expect(writeText).toHaveBeenCalledWith("Rowan Vale"); + await waitFor(() => { + expect(first.textContent).toBe("Copied"); + }); + + const contactRow = within(container as HTMLElement).getByText("Emergency contact"); + expect(contactRow.parentElement?.parentElement?.querySelector("button")).toBeNull(); + }); +}); diff --git a/packages/solid-showcase/test/domain-generic.test.ts b/packages/solid-showcase/test/domain-generic.test.ts new file mode 100644 index 000000000..c1517800b --- /dev/null +++ b/packages/solid-showcase/test/domain-generic.test.ts @@ -0,0 +1,65 @@ +// AUTHORED-BY Claude Fable 5 +/** + * Domain-generic grep gate: this package is a GENERIC framework for multistakeholder + * pod-data walkthroughs — no use case may leak into its source, tests, docs, or shipped + * artifacts. Domain content enters ONLY via the walkthrough document at runtime. + * + * The banned terms are assembled from fragments so this gate can scan every file in the + * package — including itself — without matching its own source. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const SKIP_DIRS = new Set(["node_modules", "dist", ".turbo", ".next"]); +const TEXT_EXTENSIONS = /\.(?:ts|tsx|mts|mjs|js|jsx|json|md|txt)$/u; + +function walk(dir: string, files: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stats = statSync(full); + if (stats.isDirectory()) { + if (!SKIP_DIRS.has(entry)) walk(full, files); + } else if (TEXT_EXTENSIONS.test(entry)) { + files.push(full); + } + } + return files; +} + +// Assembled at runtime; no fragment pair sits adjacent in this file's own source. +const bannedFragments: [string, string][] = [ + ["mort", "gage"], + ["\\bED", "MA\\b"], + ["len", "der"], + ["ba", "nk"], + ["fin", "ance"], + ["cre", "dit"], + ["lo", "an"], + ["fre", "ddie"], + ["equi", "fax"], + ["veri", "zon"], + ["under", "writ"], + ["decision-", "00"], + ["borrow", "er"], +]; +const banned = new RegExp(bannedFragments.map(([a, b]) => `${a}${b}`).join("|"), "iu"); + +test("no use-case/domain term appears anywhere in the package", () => { + const files = walk(packageRoot); + expect(files.length).toBeGreaterThan(10); + const hits: string[] = []; + for (const file of files) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, index) => { + const match = banned.exec(line); + if (match !== null) { + hits.push(`${relative(packageRoot, file)}:${index + 1} → "${match[0]}" in: ${line.trim()}`); + } + }); + } + expect(hits, `domain terms leaked into the generic package:\n${hits.join("\n")}`).toEqual([]); +}); diff --git a/packages/solid-showcase/test/next-helpers.test.ts b/packages/solid-showcase/test/next-helpers.test.ts new file mode 100644 index 000000000..77e658a7a --- /dev/null +++ b/packages/solid-showcase/test/next-helpers.test.ts @@ -0,0 +1,125 @@ +// AUTHORED-BY Claude Fable 5 +/** Deploy helpers under `@jeswr/solid-showcase/next`, driven only by the document. */ +import { expect, test } from "vitest"; +import { registeredApp } from "../src/index.js"; +import { + appVercelJson, + envMatrix, + healthRoute, + showcaseMetadata, + zoneRewrites, +} from "../src/next/index.js"; +import { exampleWalkthrough } from "./support/example-document.js"; + +test("zoneRewrites with env UNSET falls back to unresolvable .invalid hosts", async () => { + const rewrites = await zoneRewrites(exampleWalkthrough, { env: {} }); + // Three zone apps (vault, permits, outfitter) × two rules each; shell-local apps + // (atlas, advisory) produce no rewrites. + expect(rewrites).toHaveLength(6); + expect(rewrites).toContainEqual({ + destination: "https://vault.invalid/vault", + source: "/vault", + }); + expect(rewrites).toContainEqual({ + destination: "https://vault.invalid/vault/:path+", + source: "/vault/:path+", + }); + expect(rewrites).toContainEqual({ + destination: "https://permits.invalid/permits", + source: "/permits", + }); + expect(rewrites).toContainEqual({ + destination: "https://outfitter.invalid/outfitter/:path+", + source: "/outfitter/:path+", + }); +}); + +test("zoneRewrites with env SET routes both rules to the zone URL, trailing slash stripped", async () => { + const rewrites = await zoneRewrites(exampleWalkthrough, { + env: { + TRAILS_OUTFITTER_ZONE_URL: "https://trails-outfitter.example.app", + TRAILS_PERMITS_ZONE_URL: "https://trails-permits.example.app", + TRAILS_VAULT_ZONE_URL: "https://trails-vault.example.app/", + }, + }); + expect(rewrites).toContainEqual({ + destination: "https://trails-vault.example.app/vault", + source: "/vault", + }); + expect(rewrites).toContainEqual({ + destination: "https://trails-vault.example.app/vault/:path+", + source: "/vault/:path+", + }); + expect(rewrites).toContainEqual({ + destination: "https://trails-permits.example.app/permits/:path+", + source: "/permits/:path+", + }); +}); + +test("zoneRewrites honours a custom fallback suffix", async () => { + const rewrites = await zoneRewrites(exampleWalkthrough, { env: {}, fallbackSuffix: ".test" }); + expect(rewrites).toContainEqual({ + destination: "https://vault.test/vault", + source: "/vault", + }); +}); + +test("envMatrix lists every zone URL var on the shell project", () => { + const matrix = envMatrix(exampleWalkthrough); + const zoneVars = matrix.filter((spec) => spec.name.endsWith("_ZONE_URL")); + expect(zoneVars.map((spec) => spec.name).sort()).toEqual([ + "TRAILS_OUTFITTER_ZONE_URL", + "TRAILS_PERMITS_ZONE_URL", + "TRAILS_VAULT_ZONE_URL", + ]); + for (const spec of zoneVars) { + expect(spec.apps).toEqual(["atlas"]); + expect(spec.buildTime).toBe(true); + } +}); + +test("envMatrix includes *_TRUST_FORWARDED_HEADERS for exactly the pod-route apps", () => { + const matrix = envMatrix(exampleWalkthrough); + const trust = matrix.find((spec) => spec.name === "TRAILS_TRUST_FORWARDED_HEADERS"); + expect(trust).toBeDefined(); + expect(trust?.value).toBe("1"); + // vault and permits declare podRoutes; outfitter/atlas/advisory do not. + expect(trust?.apps.sort()).toEqual(["permits", "vault"]); +}); + +test("envMatrix omits the trust-headers var when no app declares pod routes", () => { + const doc = structuredClone(exampleWalkthrough); + for (const app of Object.values(doc.registry.apps)) { + app.podRoutes = undefined; + } + expect(envMatrix(doc).some((spec) => spec.name.endsWith("_TRUST_FORWARDED_HEADERS"))).toBe(false); +}); + +test("appVercelJson pins the framework, turbo build filter, and turbo-ignore", () => { + const vault = registeredApp(exampleWalkthrough.registry, "vault"); + expect(appVercelJson(vault, exampleWalkthrough)).toEqual({ + // biome-ignore lint/style/useNamingConvention: fixed vercel.json wire field + $schema: "https://openapi.vercel.sh/vercel.json", + buildCommand: "pnpm --dir ../.. exec turbo run build --filter=@trails/app-vault", + framework: "nextjs", + ignoreCommand: "npx turbo-ignore --fallback=HEAD^1", + }); + expect( + appVercelJson(vault, exampleWalkthrough, { packageName: "@acme/vault" }).buildCommand, + ).toContain("--filter=@acme/vault"); +}); + +test("healthRoute answers ok + service + simulated:true", async () => { + const { GET } = healthRoute("atlas"); + const response = GET(); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, service: "atlas", simulated: true }); +}); + +test("showcaseMetadata is the 'own'-variant demo metadata with noindex", () => { + const metadata = showcaseMetadata(exampleWalkthrough); + expect(metadata.title).toContain("Open Trails Walkthrough"); + expect(metadata.title).toContain("Concept Demo"); + expect(metadata.robots).toEqual({ follow: false, index: false }); + expect(metadata.description).toContain("Meridian Trails Collective"); +}); diff --git a/packages/solid-showcase/test/pages.test.tsx b/packages/solid-showcase/test/pages.test.tsx new file mode 100644 index 000000000..0c65483db --- /dev/null +++ b/packages/solid-showcase/test/pages.test.tsx @@ -0,0 +1,117 @@ +// AUTHORED-BY Claude Fable 5 +// @vitest-environment jsdom +/** The generic example document renders end-to-end through all four page renderers. */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { + ShowcaseChapterPage, + ShowcaseCompliancePage, + ShowcaseLanding, + ShowcaseLayout, +} from "../src/index.js"; +import { exampleWalkthrough } from "./support/example-document.js"; + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("unreachable"))), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +test("ShowcaseLayout renders the shell frame, banner, launcher, and interstitial", () => { + const { container, baseElement } = render( + +

page body

+
, + ); + expect(container.querySelector("[data-app-shell]")).not.toBeNull(); + // The concept-demo banner and footer legal line are unremovable kit surfaces. + expect(container.textContent).toContain("Concept demo"); + expect(container.querySelector("[data-demo-footer]")).not.toBeNull(); + expect(screen.getByText("page body")).toBeTruthy(); + expect(container.querySelector("[data-launcher]")).not.toBeNull(); + // First visit (no consent cookie): the interstitial is open. + expect(baseElement.querySelector("[data-consent-interstitial]")).not.toBeNull(); + // The compliance lens is linked from the header nav under its document title. + expect(screen.getAllByRole("link", { name: "Steward Review" }).length).toBeGreaterThan(0); +}); + +test("ShowcaseLanding renders hero, anchors with sources, map, chapter cards, persona", () => { + const { container } = render(); + expect(screen.getByRole("heading", { level: 1 }).textContent).toBe( + "One journey, many parties, one vault", + ); + expect(container.textContent).toContain("Meridian Trails Collective"); + // Anchors + their public sources. + expect(container.querySelectorAll("[data-stat-card]")).toHaveLength(2); + expect(container.querySelector("[data-anchor-sources] a")?.getAttribute("href")).toBe( + "https://example.org/research/journeys", + ); + // Ecosystem map and chapter cards. + expect(container.querySelector("[data-ecosystem-map]")).not.toBeNull(); + expect(container.querySelectorAll("[data-chapter-card]")).toHaveLength(4); // 3 chapters + lens card + expect( + container.querySelector("[data-chapter-card='pack-the-vault']")?.getAttribute("href"), + ).toBe("/chapters/pack-the-vault"); + expect(container.querySelector("[data-cta-start-tour]")?.textContent).toBe( + "Start the walkthrough", + ); + // Persona card. + expect(container.querySelector("[data-demo-identity-card]")).not.toBeNull(); +}); + +test("ShowcaseChapterPage renders the chapter player and neighbour navigation", () => { + const { container } = render( + , + ); + expect(container.querySelector("[data-chapter-player='prove-the-permit']")).not.toBeNull(); + expect(container.textContent).toContain("Scene 2 of 3"); + expect(container.textContent).toContain("Anchor:"); + expect(container.querySelector("[data-previous-chapter]")?.getAttribute("href")).toBe( + "/chapters/pack-the-vault", + ); + expect(container.querySelector("[data-next-chapter]")?.getAttribute("href")).toBe( + "/chapters/share-the-route", + ); +}); + +test("the last chapter hands off to the compliance lens when configured", () => { + const { container } = render( + , + ); + expect(container.querySelector("[data-next-chapter]")?.getAttribute("href")).toBe("/compliance"); +}); + +test("ShowcaseChapterPage throws on an unknown slug (routes must guard with chapterBySlug)", () => { + expect(() => + render(), + ).toThrowError(/Unknown chapter slug "nope"/); +}); + +test("ShowcaseCompliancePage renders the unbranded checklist with non-affiliation", () => { + const { container } = render(); + expect(container.querySelector("[data-compliance-view]")).not.toBeNull(); + expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Steward Review"); + const statement = + "An illustrative checklist over public trail rules; not affiliated with or endorsed by any park or range authority."; + expect(container.textContent).toContain(statement); + expect(container.querySelectorAll("[data-compliance-check]")).toHaveLength(2); + const check = container.querySelector("[data-compliance-check='day-permit']"); + expect(check?.querySelector("a")?.getAttribute("href")).toBe( + "https://example.org/rules/day-permits", + ); + expect(check?.textContent).toContain("Dramatized in scene 1"); +}); + +test("ShowcaseCompliancePage refuses to render without a configured lens", () => { + const doc = structuredClone(exampleWalkthrough); + doc.compliance = undefined; + expect(() => render()).toThrowError( + /configures no compliance lens/, + ); +}); diff --git a/packages/solid-showcase/test/parse-walkthrough.test.ts b/packages/solid-showcase/test/parse-walkthrough.test.ts new file mode 100644 index 000000000..786dcb3c7 --- /dev/null +++ b/packages/solid-showcase/test/parse-walkthrough.test.ts @@ -0,0 +1,239 @@ +// AUTHORED-BY Claude Fable 5 +/** + * Malformed-document suite: one DISTINCT rejection per validation invariant, each + * asserting the stable issue code AND that the message names the offending + * chapter/step/role/check. + */ +import { describe, expect, test } from "vitest"; +import type { WalkthroughDocument } from "../src/index.js"; +import { + editorialFindings, + parseWalkthrough, + WalkthroughValidationError, + walkthroughWarnings, +} from "../src/index.js"; +import { cloneExample, exampleWalkthrough } from "./support/example-document.js"; + +function issuesOf(doc: unknown) { + try { + parseWalkthrough(doc); + } catch (error) { + if (error instanceof WalkthroughValidationError) return error.issues; + throw error; + } + throw new Error("expected parseWalkthrough to reject the document"); +} + +function expectIssue(doc: unknown, code: string, messagePart: string | RegExp) { + const issues = issuesOf(doc); + const matching = issues.filter((issue) => issue.code === code); + expect( + matching, + `expected an issue with code "${code}" in:\n${JSON.stringify(issues, null, 2)}`, + ).not.toHaveLength(0); + const [issue] = matching; + if (typeof messagePart === "string") { + expect(issue?.message).toContain(messagePart); + } else { + expect(issue?.message).toMatch(messagePart); + } +} + +test("the example document parses, returns the document, and has no editorial findings", () => { + const doc = parseWalkthrough(structuredClone(exampleWalkthrough) as unknown); + expect(doc.site.appName).toBe(exampleWalkthrough.site.appName); + expect(editorialFindings(doc)).toEqual([]); + expect(walkthroughWarnings(doc)).toEqual([]); +}); + +describe("each invariant gets a distinct rejection", () => { + test("unknown try-live app", () => { + const doc = cloneExample(); + const step = doc.chapters[0]?.steps[1]; + if (step === undefined) throw new Error("fixture drift"); + step.tryLive.app = "no-such-app"; + expectIssue(doc, "unknown-try-live-app", 'chapter "pack-the-vault"'); + expectIssue(doc, "unknown-try-live-app", '"no-such-app"'); + }); + + test("roles[].apps[] unresolved", () => { + const doc = cloneExample(); + doc.registry.roles[1]?.apps.push("ghost-app"); + expectIssue(doc, "unknown-role-app", 'role "permit-authority"'); + }); + + test("launcherOrder entry unresolved", () => { + const doc = cloneExample(); + doc.registry.launcherOrder.push("phantom"); + expectIssue(doc, "unknown-launcher-app", '"phantom"'); + }); + + test("launcherOrder entry duplicated", () => { + const doc = cloneExample(); + doc.registry.launcherOrder.push("vault"); + expectIssue(doc, "duplicate-launcher-app", '"vault"'); + }); + + test("registry record key ≠ entry slug", () => { + const doc = cloneExample(); + const vault = doc.registry.apps.vault; + if (vault === undefined) throw new Error("fixture drift"); + vault.slug = "vault-renamed"; + expectIssue(doc, "registry-key-mismatch", 'registry.apps["vault"]'); + }); + + test("duplicate role slug", () => { + const doc = cloneExample(); + const stewards = doc.registry.roles[4]; + if (stewards === undefined) throw new Error("fixture drift"); + stewards.slug = "outfitting"; + expectIssue(doc, "duplicate-role-slug", '"outfitting"'); + }); + + test("no centre role / centre mismatch", () => { + const noCentre = cloneExample(); + const traveller = noCentre.registry.roles[0]; + if (traveller === undefined) throw new Error("fixture drift"); + traveller.center = undefined; + expectIssue(noCentre, "center-role", "no role has center: true"); + + const mismatch = cloneExample(); + mismatch.registry.center = "stewards"; + expectIssue(mismatch, "center-role", '"stewards"'); + }); + + test("roles[].scene with no matching chapter", () => { + const doc = cloneExample(); + const role = doc.registry.roles[2]; + if (role === undefined) throw new Error("fixture drift"); + role.scene = 9; + expectIssue(doc, "unknown-role-scene", 'role "outfitting" names scene 9'); + }); + + test("non-contiguous chapter scenes", () => { + const doc = cloneExample(); + const last = doc.chapters[2]; + if (last === undefined) throw new Error("fixture drift"); + last.scene = 5; + expectIssue(doc, "chapter-scene-order", 'chapter "share-the-route"'); + expectIssue(doc, "chapter-scene-order", "expected scene 3"); + }); + + test("lead budget overrun", () => { + const doc = cloneExample(); + const chapter = doc.chapters[1]; + if (chapter === undefined) throw new Error("fixture drift"); + chapter.lead = Array.from({ length: 41 }, (_, index) => `word${index}`).join(" "); + expectIssue(doc, "lead-budget", 'chapter "prove-the-permit"'); + expectIssue(doc, "lead-budget", "41 words (max 40)"); + }); + + test("step budget overrun", () => { + const doc = cloneExample(); + const step = doc.chapters[1]?.steps[0]; + if (step === undefined) throw new Error("fixture drift"); + step.body = Array.from({ length: 66 }, (_, index) => `word${index}`).join(" "); + expectIssue(doc, "step-budget", 'step "Issue the day permit"'); + }); + + test("minSteps (tightened override) violation", () => { + const doc = cloneExample(); + doc.editorial = { minSteps: 3 }; + expectIssue(doc, "min-steps", "min 3"); + }); + + test("required underneath missing", () => { + const doc = cloneExample(); + const chapter = doc.chapters[0]; + if (chapter === undefined) throw new Error("fixture drift"); + chapter.underneath = undefined; + expectIssue(doc, "underneath-required", 'chapter "pack-the-vault"'); + }); + + test("required underneath point too short (tightened override)", () => { + const doc = cloneExample(); + doc.editorial = { minUnderneathChars: 200 }; + expectIssue(doc, "underneath-length", 'chapter "pack-the-vault"'); + expectIssue(doc, "underneath-length", "min 200"); + }); + + test("compliance chapterSlug unresolved", () => { + const doc = cloneExample(); + const check = doc.compliance?.checks[0]; + if (check === undefined) throw new Error("fixture drift"); + check.chapterSlug = "missing-chapter"; + expectIssue(doc, "unknown-compliance-chapter", 'check "day-permit"'); + }); + + test("compliance scene ≠ referenced chapter's scene", () => { + const doc = cloneExample(); + const check = doc.compliance?.checks[1]; + if (check === undefined) throw new Error("fixture drift"); + check.scene = 1; + expectIssue(doc, "compliance-scene-mismatch", 'check "route-notice"'); + expectIssue(doc, "compliance-scene-mismatch", "scene 3"); + }); + + test("editorial override below a schema floor", () => { + const belowSteps = cloneExample(); + belowSteps.editorial = { minSteps: 1 }; + const issues = issuesOf(belowSteps); + const floor = issues.find((issue) => issue.code === "editorial-floor"); + expect(floor?.path).toBe("editorial.minSteps"); + + const belowChars = cloneExample(); + belowChars.editorial = { minUnderneathChars: 10 }; + expect(issuesOf(belowChars).find((issue) => issue.code === "editorial-floor")?.path).toBe( + "editorial.minUnderneathChars", + ); + }); + + test('persona descriptor missing "fictional"/"simulated"', () => { + const doc = cloneExample(); + doc.persona.descriptor = "A traveller from the north ranges."; + expectIssue(doc, "persona-honesty", "persona.descriptor"); + }); +}); + +test("all issues are reported at once, not first-failure-only", () => { + const doc = cloneExample(); + doc.registry.launcherOrder.push("phantom", "vault"); + doc.persona.descriptor = "A traveller."; + const codes = new Set(issuesOf(doc).map((issue) => issue.code)); + expect(codes).toEqual( + new Set(["unknown-launcher-app", "duplicate-launcher-app", "persona-honesty"]), + ); +}); + +test("editorialFindings reports budgets without throwing", () => { + const doc = cloneExample(); + const chapter = doc.chapters[0]; + if (chapter === undefined) throw new Error("fixture drift"); + chapter.lead = Array.from({ length: 50 }, () => "word").join(" "); + const findings = editorialFindings(doc as WalkthroughDocument); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + actual: 50, + chapterSlug: "pack-the-vault", + limit: 40, + rule: "lead-budget", + scene: 1, + }); +}); + +test("role-first naming violations surface as warnings, not errors", () => { + const doc = cloneExample(); + const vault = doc.registry.apps.vault; + if (vault === undefined) throw new Error("fixture drift"); + vault.appName = "Cairn Cooperative Vault"; + const parsed = parseWalkthrough(doc as unknown); + const warnings = walkthroughWarnings(parsed); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.code).toBe("role-first-app-name"); + expect(warnings[0]?.message).toContain("Cairn Cooperative"); +}); + +test("non-object input is rejected with schema issues", () => { + const issues = issuesOf("not a document"); + expect(issues[0]?.code).toBe("schema"); +}); diff --git a/packages/solid-showcase/test/schema-sync.test.ts b/packages/solid-showcase/test/schema-sync.test.ts new file mode 100644 index 000000000..6f7525fa8 --- /dev/null +++ b/packages/solid-showcase/test/schema-sync.test.ts @@ -0,0 +1,74 @@ +// AUTHORED-BY Claude Fable 5 +/** + * The shipped JSON-Schema artifact is GENERATED from the zod schema — this suite pins + * that it can never drift from the runtime validator (regenerate with + * `pnpm run build && pnpm run generate:schema`), and that the editorial floors are + * carried in the artifact itself. + */ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { walkthroughJsonSchema } from "../src/index.js"; + +const artifactPath = resolve( + dirname(fileURLToPath(import.meta.url)), + "../schema/walkthrough.v1.json", +); + +function readArtifact(): Record { + return JSON.parse(readFileSync(artifactPath, "utf8")) as Record; +} + +test("schema/walkthrough.v1.json is byte-in-sync with the zod schema", () => { + expect(readArtifact()).toEqual(walkthroughJsonSchema()); + // Byte-level too (stable serialization): the artifact is the pretty-printed output. + expect(readFileSync(artifactPath, "utf8")).toBe( + `${JSON.stringify(walkthroughJsonSchema(), null, 2)}\n`, + ); +}); + +test("the artifact identifies itself and requires the document core", () => { + const artifact = readArtifact(); + expect(artifact.$id).toBe("walkthrough.v1.json"); + expect(artifact.$schema).toContain("2020-12"); + expect(artifact.required).toEqual( + expect.arrayContaining([ + "version", + "site", + "branding", + "persona", + "registry", + "chapters", + "deploy", + ]), + ); +}); + +test("editorial minima carry the absolute floors (minimum 2 / minimum 20)", () => { + const artifact = readArtifact(); + const properties = artifact.properties as Record< + string, + { properties?: Record> } + >; + const editorial = properties.editorial?.properties; + expect(editorial?.minSteps?.minimum).toBe(2); + expect(editorial?.minUnderneathChars?.minimum).toBe(20); + + // The chapter shape pins the same floors structurally. + const chapters = properties.chapters as unknown as { + items: { properties: Record> }; + }; + expect(chapters.items.properties.steps?.minItems).toBe(2); + const underneath = chapters.items.properties.underneath as { items?: { minLength?: number } }; + expect(underneath.items?.minLength).toBe(20); +}); + +test("branding is composed from the kit's schema (kit owns the contract)", () => { + const artifact = readArtifact(); + const properties = artifact.properties as Record>; + const branding = properties.branding as { required?: string[] }; + expect(branding.required).toEqual( + expect.arrayContaining(["convener", "domainNegations", "description"]), + ); +}); diff --git a/packages/solid-showcase/test/support/example-document.ts b/packages/solid-showcase/test/support/example-document.ts new file mode 100644 index 000000000..c699a944d --- /dev/null +++ b/packages/solid-showcase/test/support/example-document.ts @@ -0,0 +1,288 @@ +// AUTHORED-BY Claude Fable 5 +/** + * A wholly FICTIONAL, domain-generic walkthrough document: a multi-party trail-expedition + * journey over personal data stores. Every organisation, rule, and value here is invented + * for the test fixture — this package never embeds any real use case's content; real + * walkthroughs live with their consumers. + */ +import type { WalkthroughDocument } from "../../src/schema.js"; + +export const exampleWalkthrough: WalkthroughDocument = { + anchors: [ + { + detail: + "The same traveller facts are re-collected by every desk in a typical multi-party expedition, per the fictional journeys dataset.", + id: "copies", + label: "times the same records are re-collected per journey", + source: { name: "Example journeys dataset", url: "https://example.org/research/journeys" }, + value: "9×", + }, + { + detail: "Median wait from first request to a complete permit file, same fictional dataset.", + id: "days", + label: "days to assemble one permit file today", + source: { name: "Example permit-office survey", url: "https://example.org/research/permits" }, + value: "12 days", + }, + ], + branding: { + aboutHref: "/", + convener: "Meridian Trails Collective", + description: "show how a multi-party expedition journey could run on personal data stores", + domainNegations: ["Nothing here is an offer of guided travel or insurance."], + }, + chapters: [ + { + anchor: + "Ridgeway public rulebook §4.2 (fictional): one day permit per traveller per range day", + lead: "Rowan gathers the records the journey needs — once — into a vault only Rowan controls.", + scene: 1, + slug: "pack-the-vault", + steps: [ + { + body: "Open the vault and see the traveller records already in place: club membership, route history, and the fictional persona's contact card.", + title: "Open the vault", + tryLive: { app: "vault", label: "Open the Traveller Vault" }, + }, + { + body: "Grant the permit desk scoped, revocable access to exactly the two records it needs. The grant is receipted in the vault.", + title: "Grant the permit desk access", + tryLive: { app: "permits", label: "Visit the Permit Desk" }, + }, + ], + title: "Pack the vault", + underneath: [ + "The vault wrote an access grant to the traveller's own data store, scoped to two resources.", + "A signed receipt of the grant landed beside the records it covers, so revocation has an audit trail.", + ], + underneathRequired: true, + }, + { + anchor: "Ridgeway public rulebook §5.1 (fictional): gear lists are advisory, never retained", + lead: "The permit desk reads what it was granted, issues the day permit, and the outfitter works from the same single source.", + scene: 2, + slug: "prove-the-permit", + steps: [ + { + body: "The permit desk reads the granted records straight from the vault — no copies — and issues a day permit back into it.", + title: "Issue the day permit", + tryLive: { app: "permits", label: "Watch the permit issue" }, + }, + { + body: "The outfitter checks the same permit record from the vault to size the gear list. Nothing is retained after the visit.", + title: "Fit the gear list", + tryLive: { app: "outfitter", label: "Open the Gear Locker" }, + }, + ], + title: "Prove the permit", + }, + { + anchor: + "Meridian route-notice convention (fictional): shared routes carry a public advisory link", + lead: "Rowan shares the final route once, and every party sees the same advisory-linked copy.", + scene: 3, + slug: "share-the-route", + steps: [ + { + body: "The route advisory reads the shared route and attaches the public conditions notice for the range day.", + title: "Attach the advisory", + tryLive: { app: "advisory", label: "Open the Route Advisory" }, + }, + { + body: "Back in the vault, Rowan reviews who saw what, then revokes the permit desk's grant now that the journey is booked.", + title: "Review and revoke", + tryLive: { app: "vault", label: "Review access in the vault" }, + }, + ], + title: "Share the route", + }, + ], + compliance: { + checks: [ + { + chapterSlug: "pack-the-vault", + citation: "Ridgeway public rulebook §4.2 (fictional)", + citationUrl: "https://example.org/rules/day-permits", + id: "day-permit", + observe: + "The permit grant and its receipt sit in the traveller's vault before the range day starts.", + rule: "A day permit must be issued before the range day starts", + scene: 1, + }, + { + chapterSlug: "share-the-route", + citation: "Meridian route-notice convention §2 (fictional)", + citationUrl: "https://example.org/rules/route-notices", + id: "route-notice", + observe: + "The shared route record carries the advisory link, and the vault's access log shows exactly who read it.", + rule: "A shared route must carry the public conditions notice", + scene: 3, + }, + ], + nonAffiliation: + "An illustrative checklist over public trail rules; not affiliated with or endorsed by any park or range authority.", + title: "Steward Review", + }, + deploy: { + envPrefix: "TRAILS", + slug: "trails", + }, + editorial: {}, + persona: { + descriptor: "Fictional persona — every value is simulated.", + fields: [ + { label: "Name", value: "Rowan Vale" }, + { + label: "Club membership id", + note: "Issued by the fictional collective", + value: "MTC-2481", + }, + { copyable: false, label: "Emergency contact", value: "Sana Vale (fictional)" }, + ], + footnote: "Values are pinned to the walkthrough's scripted checks.", + name: "Rowan Vale", + }, + registry: { + apps: { + advisory: { + appName: "Route Advisory", + healthPath: "/api/health", + modelledOn: "Unbranded — no authority affiliation", + path: "/advisory", + slug: "advisory", + }, + atlas: { + appName: "Trail Atlas", + healthPath: "/api/health", + modelledOn: "Meridian Trails Collective", + path: "/", + slug: "atlas", + theme: { + accent: "oklch(0.72 0.12 95)", + hue: 150, + primary: "oklch(0.42 0.09 150)", + role: "walkthrough shell", + }, + }, + outfitter: { + appName: "Gear Locker", + healthPath: "/outfitter/api/health", + modelledOn: "Bluffside Outfitters", + path: "/outfitter", + slug: "outfitter", + theme: { + accent: "oklch(0.7 0.11 60)", + hue: 30, + primary: "oklch(0.45 0.1 30)", + role: "gear outfitting", + }, + zoneEnv: "TRAILS_OUTFITTER_ZONE_URL", + }, + permits: { + appName: "Permit Desk", + healthPath: "/permits/api/health", + honesty: { + real: ["Reads granted records from a real personal data store"], + simulated: ["Permit decisions are scripted for the demo"], + }, + modelledOn: "Ridgeway Range Authority", + path: "/permits", + podRoutes: ["/api/pod/permits"], + slug: "permits", + zoneEnv: "TRAILS_PERMITS_ZONE_URL", + }, + vault: { + appName: "Traveller Vault", + healthPath: "/vault/api/health", + honesty: { + real: ["Reads and writes against a real personal data store"], + simulated: ["Sign-in is simulated in visitor mode"], + }, + modelledOn: "Cairn Cooperative", + path: "/vault", + podRoutes: ["/api/pod/grants", "/api/pod/receipts"], + slug: "vault", + theme: { + accent: "oklch(0.72 0.1 200)", + hue: 230, + primary: "oklch(0.42 0.08 230)", + role: "personal data custodian", + }, + zoneEnv: "TRAILS_VAULT_ZONE_URL", + }, + }, + center: "traveller", + launcherOrder: ["atlas", "vault", "permits", "outfitter", "advisory"], + roles: [ + { + apps: ["vault"], + center: true, + membership: "The data subject — the centre of the ecosystem", + modelledOn: "Cairn Cooperative", + role: "Traveller", + slug: "traveller", + summary: + "Holds every record of the journey in a vault only the traveller controls, and grants each party scoped, revocable, receipted access.", + }, + { + apps: ["permits"], + membership: "Fictional collective member", + modelledOn: "Ridgeway Range Authority", + role: "Permit authority", + roleNumber: 1, + scene: 1, + slug: "permit-authority", + summary: + "Issues day permits against records it reads — never copies — from the traveller's vault.", + }, + { + apps: ["outfitter"], + membership: "Fictional collective member", + modelledOn: "Bluffside Outfitters", + role: "Gear outfitting", + roleNumber: 2, + scene: 2, + slug: "outfitting", + summary: + "Sizes the gear list from the same permit record every other party sees, retaining nothing.", + }, + { + apps: ["advisory"], + membership: "External approach", + modelledOn: "Unbranded — no authority affiliation", + role: "Route advisories", + roleNumber: 3, + scene: 3, + slug: "advisories", + summary: "Attaches the public conditions notice to any route shared for a range day.", + }, + { + apps: [], + membership: "Mapped for a later phase", + modelledOn: "Larkfell Stewards", + role: "Trail stewards", + roleNumber: 4, + slug: "stewards", + summary: + "Maintains the ranges the journey crosses; mapped on the ecosystem so the seat is honest, with no app in this demo.", + }, + ], + }, + site: { + appName: "Open Trails Walkthrough", + exploreCtaLabel: "Explore the ecosystem", + heroLead: "Travellers hold their own records. Every party gets scoped, revocable access.", + heroParagraph: + "A multi-party expedition today means the same facts about the traveller collected, checked, copied, and re-checked by every desk on the route. This walkthrough rebuilds that journey on personal data stores: records live in a vault the traveller controls, checks arrive once as reusable records, and each application is modelled on a fictional organisation's seat in the journey.", + heroTitle: "One journey, many parties, one vault", + organization: "Meridian Trails Collective", + startCtaLabel: "Start the walkthrough", + }, + version: 1, +}; + +/** A structured clone for mutation-based malformed-document tests. */ +export function cloneExample(): WalkthroughDocument { + return structuredClone(exampleWalkthrough); +} diff --git a/packages/solid-showcase/tsconfig.build.json b/packages/solid-showcase/tsconfig.build.json new file mode 100644 index 000000000..44291d8d4 --- /dev/null +++ b/packages/solid-showcase/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/tsconfig.json b/packages/solid-showcase/tsconfig.json new file mode 100644 index 000000000..46e4fa98a --- /dev/null +++ b/packages/solid-showcase/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/vitest.config.ts b/packages/solid-showcase/vitest.config.ts new file mode 100644 index 000000000..02d01d910 --- /dev/null +++ b/packages/solid-showcase/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 8d9d23843..1d92854b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3554,6 +3554,55 @@ 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: + dependencies: + '@jeswr/app-shell': + specifier: workspace:* + version: link:../app-shell + '@jeswr/solid-showcase-kit': + specifier: workspace:* + version: link:../solid-showcase-kit + 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-showcase-kit: dependencies: '@jeswr/app-shell': diff --git a/suite.json b/suite.json index 73e4994da..d0e4beaa8 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", "@jeswr/solid-showcase-kit", "@jeswr/synthetic-rdf", "@jeswr/solid-task-model",