From 49971a8130cc3a38387869344ec6f25b9423bc63 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Thu, 3 Sep 2026 22:08:55 +0200 Subject: [PATCH 01/14] docs: plan readonly-view v2 launch --- .../2026-09-03-legal-deployment-release.md | 365 ++++++++++++++++++ ...6-09-03-legal-deployment-release-design.md | 69 ++++ 2 files changed, 434 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-03-legal-deployment-release.md create mode 100644 docs/superpowers/specs/2026-09-03-legal-deployment-release-design.md diff --git a/docs/superpowers/plans/2026-09-03-legal-deployment-release.md b/docs/superpowers/plans/2026-09-03-legal-deployment-release.md new file mode 100644 index 0000000..837316f --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-legal-deployment-release.md @@ -0,0 +1,365 @@ +# ReadonlyView Legal, Deployment, and Stable Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add bilingual legal pages, production Vercel deployment, reference-grade GitHub automation, and publish `@nipe-solutions/readonly-view@2.0.0` as a verified stable release. + +**Architecture:** Keep the Vite site framework-free and add four static legal entry points sharing the existing design system. Use Vercel Git integration for preview/production deployments and the guarded GitHub release workflow for npm OIDC publication after all local and hosted checks pass. + +**Tech Stack:** HTML, CSS, TypeScript, Vite 8, Playwright, GitHub Actions, Vercel, GoDaddy DNS, npm trusted publishing. + +**Spec:** `docs/superpowers/specs/2026-09-03-legal-deployment-release-design.md` + +## Global Constraints + +- Publish exactly `@nipe-solutions/readonly-view@2.0.0` publicly under `latest`. +- Use `https://readonly-view.nipesolutions.com` as the production documentation origin. +- Preserve zero runtime dependencies and the framework-free Vite website. +- Reuse the approved NIPE Solutions e.U. legal operator data and bilingual reference text. +- Do not publish until the release commit is merged to `main`, CI is green, and the exact tarball is verified. +- Do not deprecate or otherwise mutate the old `immuview` npm package. + +--- + +### Task 1: Bilingual legal routes and production website metadata + +**Files:** +- Create: `website/privacy/index.html` +- Create: `website/impressum/index.html` +- Create: `website/de/datenschutz/index.html` +- Create: `website/de/impressum/index.html` +- Create: `website/legal.css` +- Create: `website/public/robots.txt` +- Create: `website/public/sitemap.xml` +- Create: `website/public/favicon.svg` +- Create: `website/public/favicon-32x32.png` +- Create: `website/public/apple-touch-icon.png` +- Create: `website/public/site.webmanifest` +- Modify: `website/index.html` +- Modify: `website/styles.css` +- Modify: `vite.website.config.ts` +- Modify: `scripts/verify-website.mjs` +- Modify: `test/browser/website.spec.ts` +- Modify: `test/integration/docs-examples.test.ts` + +**Interfaces:** +- Consumes: existing `.masthead`, `.brand`, footer, color tokens, and `npm run build:website`. +- Produces: four stable legal routes, language cross-links, canonical production URLs, and multi-page Vite output under `website/dist`. +- Produces: a crisp ReadonlyView tab-icon family derived from the existing `R` brand mark. + +- [ ] **Step 1: Add failing build-verification assertions** + +Extend `scripts/verify-website.mjs` to read all five built HTML files and assert their defining content: + +```js +const pages = new Map([ + ['privacy/index.html', ['Privacy Policy', 'Vercel Inc.', 'no non-essential cookies']], + ['impressum/index.html', ['Impressum', 'NIPE Solutions e.U.', 'ATU78464412']], + ['de/datenschutz/index.html', ['Datenschutzerklärung', 'Vercel Inc.', 'Datenschutzbehörde']], + ['de/impressum/index.html', ['Impressum', 'NIPE Solutions e.U.', 'FN 585066t']], +]); +``` + +Also assert that the main page links each route and no page contains `v2 alpha`. + +Assert that the home page contains examples for nested protection, owner-side live updates, arrays, Map, Set, Date, cycles, shared identity, and mutation rejection. + +Assert that all pages link `/favicon.svg`, `/favicon-32x32.png`, `/apple-touch-icon.png`, and `/site.webmanifest`, and that every referenced asset exists in `website/dist`. + +- [ ] **Step 2: Run the website check and confirm it fails** + +Run: `npm run test:website` + +Expected: failure because the legal build outputs do not exist. + +- [ ] **Step 3: Add the legal HTML and shared legal layout** + +Create semantic pages with one `h1`, a last-updated date, language switch, linked email/phone, company details, and the approved English/German privacy sections. Use a shared `.legal-shell`, `.legal-content`, and `.legal-meta` layout in `website/legal.css`; retain `:focus-visible` and a one-column mobile breakpoint. + +- [ ] **Step 4: Configure the Vite multi-page build** + +Change `vite.website.config.ts` to resolve these inputs: + +```ts +rollupOptions: { + input: { + index: resolve(import.meta.dirname, 'website/index.html'), + privacy: resolve(import.meta.dirname, 'website/privacy/index.html'), + impressum: resolve(import.meta.dirname, 'website/impressum/index.html'), + datenschutz: resolve(import.meta.dirname, 'website/de/datenschutz/index.html'), + 'de-impressum': resolve(import.meta.dirname, 'website/de/impressum/index.html'), + }, +}, +``` + +Use root-absolute asset and navigation URLs because production routes are nested. + +- [ ] **Step 5: Update the home page, examples, and crawl metadata** + +Create an original SVG favicon with a dark rounded-square core, white `R`, and blue membrane accent. Render deterministic 32×32 and 180×180 PNG variants from that SVG, add a minimal web manifest, and add favicon, Apple-touch, manifest, and theme-color tags to every page. + +Remove the hero release-status pill entirely. Move `v2` and `zero runtime dependencies` into the proof/metadata area, add canonical/OG production URLs, and add footer links to all legal pages. Add copyable examples for nested objects, owner-side live updates, arrays, Map, Set, Date, cycles, shared identity, and mutation rejection. Add `robots.txt` and a sitemap containing the five canonical URLs. + +Extend `test/integration/docs-examples.test.ts` with executable equivalents of the new examples, including: + +```ts +const shared = { id: 1 }; +const source = { + items: [shared], + map: new Map([['selected', shared]]), + set: new Set([shared]), +}; +const view = readonlyView(source); +expect(view.items[0]).toBe(view.map.get('selected')); +expect([...view.set][0]).toBe(view.items[0]); +expect(() => Reflect.set(view.items[0], 'id', 2)).toThrow( + DirectMutationError, +); +``` + +- [ ] **Step 6: Add browser route coverage** + +Add a Playwright test that opens each route and checks its heading, language link, footer, and absence of horizontal overflow: + +```ts +for (const [route, heading] of legalRoutes) { + await page.goto(route); + await expect(page.getByRole('heading', { level: 1, name: heading })).toBeVisible(); + expect(await page.evaluate(() => document.body.scrollWidth)).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth), + ); +} +``` + +- [ ] **Step 7: Verify and commit** + +Run: `npm run test:website && npm run test:browser -- --project=chromium` + +Expected: all website and Chromium tests pass. + +Commit: `feat(website): add bilingual legal pages` + +### Task 2: Vercel configuration and public metadata + +**Files:** +- Create: `vercel.json` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `README.md` +- Modify: `SECURITY.md` +- Modify: `docs/compatibility.md` +- Modify: `scripts/verify-release.mjs` + +**Interfaces:** +- Consumes: `npm run build:website` and `website/dist` from Task 1. +- Produces: repeatable Vercel builds and a single canonical documentation origin across metadata. + +- [ ] **Step 1: Add failing metadata verification** + +Extend `scripts/verify-release.mjs` with exact assertions: + +```js +assert(pkg.homepage === 'https://readonly-view.nipesolutions.com', 'Homepage is stale'); +const vercel = JSON.parse(await readFile(resolve(root, 'vercel.json'), 'utf8')); +assert(vercel.buildCommand === 'npm run build:website', 'Unexpected Vercel build'); +assert(vercel.outputDirectory === 'website/dist', 'Unexpected Vercel output'); +``` + +- [ ] **Step 2: Confirm the release verifier fails** + +Run: `node scripts/verify-release.mjs` + +Expected: failure because package homepage and `vercel.json` are not production-ready. + +- [ ] **Step 3: Add Vercel configuration** + +Create: + +```json +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "npm run build:website", + "framework": null, + "installCommand": "npm ci", + "outputDirectory": "website/dist" +} +``` + +- [ ] **Step 4: Update public URLs and package version** + +Run `npm version 2.0.0 --no-git-tag-version`, set `homepage` to the production domain, and update README, security, and compatibility links. Confirm both `package-lock.json` version fields equal `2.0.0`. + +- [ ] **Step 5: Verify and commit** + +Run: `npm run build:website && node scripts/verify-release.mjs` + +Expected: both commands pass for `@nipe-solutions/readonly-view@2.0.0`. + +Commit: `chore(release): prepare v2 production metadata` + +### Task 3: Reference-grade CI and guarded release workflow + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `docs/RELEASING.md` +- Modify: `CONTRIBUTING.md` + +**Interfaces:** +- Consumes: `npm run release:check`, Playwright projects, packed artifact output, npm channel verifier. +- Produces: dependency-ordered CI, failure diagnostics, and a stable-only guarded release path using GitHub environment `npm`. + +- [ ] **Step 1: Validate workflow syntax before edits** + +Run: `npx prettier --check .github/workflows/ci.yml .github/workflows/release.yml` + +Expected: pass, establishing the existing baseline. + +- [ ] **Step 2: Align the CI workflow** + +Use `actions/checkout@v7`, retain `actions/setup-node@v7` and `actions/upload-artifact@v7`, change primary quality to `npm run release:check`, make Node 22 and browser jobs depend on `quality`, and upload `playwright-report/` plus `test-results/` only on browser failure. Keep Chromium, Firefox, WebKit, packed artifact, Node 22, concurrency, and read-only permissions. + +- [ ] **Step 3: Tighten stable release validation** + +Use `actions/checkout@v7`, keep exact confirmation `publish 2.0.0 with latest`, require `main`, use environment `npm`, grant `id-token: write` only to publish, verify `2.0.0` is absent before publish, then verify registry propagation before the GitHub release. Preserve generic version inputs for future releases but enforce stable/latest through `verify-release-channel.mjs`. + +- [ ] **Step 4: Document hosted deployment and publication** + +Update `docs/RELEASING.md` and `CONTRIBUTING.md` with Vercel Git behavior, custom domain, npm environment/trusted-publisher tuple, exact dispatch inputs, post-publish checks, and rollback/non-republish guidance. + +- [ ] **Step 5: Verify and commit** + +Run: `npm run format:check && npm run lint && node scripts/verify-release-channel.mjs latest` + +Expected: all commands pass. + +Commit: `ci: align deployment and stable release gates` + +### Task 4: Finalize changelog and verify the release candidate + +**Files:** +- Modify: `CHANGELOG.md` +- Modify: `docs/release-readiness.md` + +**Interfaces:** +- Consumes: completed Tasks 1–3 and version `2.0.0`. +- Produces: an auditable stable changelog and exact local release evidence. + +- [ ] **Step 1: Promote release notes** + +Leave an empty `## Unreleased` section and move the complete v2 notes beneath `## 2.0.0 - 2026-09-03`, retaining Breaking Changes, Added, Fixed, Performance, and Documentation headings. + +- [ ] **Step 2: Run the practical local gate** + +Run: `npm ci && npm run release:check` + +Expected: formatting, lint, TypeScript, 65+ semantic tests, type tests, size, packed consumers, website verification, benchmark smoke, release metadata, and tarball creation all pass. + +- [ ] **Step 3: Run the browser matrix** + +Run: `npm run test:browser` + +Expected: Chromium, Firefox, and WebKit pass, except the repository's documented macOS 14 WebKit exclusion when applicable. + +- [ ] **Step 4: Inspect the exact package** + +Run: `npm pack --dry-run --json` and `tar -tzf .artifacts/nipe-solutions-readonly-view-2.0.0.tgz` + +Expected: only package metadata, README, LICENSE, changelog, and intended `dist` files; no tests, website source, benchmarks, source maps, or private modules. + +- [ ] **Step 5: Record measurements and commit** + +Update `docs/release-readiness.md` with exact test totals, tarball sizes, minified/gzip sizes, browser results, and the pending hosted steps. + +Commit: `docs: finalize readonly-view 2.0.0 release notes` + +### Task 5: Review, merge, and Vercel production deployment + +**Files:** +- External: GitHub pull request and CI +- External: Vercel project for `NIPE-Solutions/readonly-view` +- External: GoDaddy DNS for `nipesolutions.com` + +**Interfaces:** +- Consumes: reviewed release branch and `vercel.json`. +- Produces: green `main`, Vercel preview/production deployments, DNS verification, and valid HTTPS at the production origin. + +- [ ] **Step 1: Push and open a focused pull request** + +Push the release-preparation branch, open a PR against `main`, and include legal routes, workflow deltas, local gate evidence, package version, and explicit statement that npm is not yet published. + +- [ ] **Step 2: Review CI and preview deployment** + +Wait for quality, Node 22, Chromium, Firefox, WebKit, and Vercel preview checks. Open the preview and verify the home page plus all legal routes on desktop and mobile. + +- [ ] **Step 3: Merge and verify production CI** + +Merge only after all required checks pass, then wait for the `main` CI run to finish successfully. + +- [ ] **Step 4: Link the Vercel project** + +In the authenticated Vercel account, import `NIPE-Solutions/readonly-view`, select the appropriate NIPE Solutions team, confirm production branch `main`, and verify that Vercel reads the committed build and output settings. + +- [ ] **Step 5: Configure the custom domain** + +Add `readonly-view.nipesolutions.com` to the Vercel project, read the exact required DNS record, create that record in the authenticated GoDaddy account, and wait for Vercel to report a valid configuration and issued TLS certificate. + +- [ ] **Step 6: Verify production** + +Check status 200, canonical URL, title, home interaction, all four legal routes, sitemap, robots, and HTTPS using both browser inspection and HTTP requests. + +### Task 6: npm trusted publishing and irreversible stable release + +**Files:** +- External: GitHub environment `npm` +- External: npm organization/package trusted-publisher settings +- External: GitHub Actions Release run +- External: npm package and GitHub release + +**Interfaces:** +- Consumes: green merge commit on `main`, exact version `2.0.0`, npm organization access, production documentation. +- Produces: public `@nipe-solutions/readonly-view@2.0.0`, `latest` dist-tag, provenance, and GitHub `v2.0.0` release. + +- [ ] **Step 1: Configure GitHub release protection** + +Create the repository environment `npm`, restrict it to `main`, and add the authenticated owner as a required reviewer when the repository plan supports reviewers. Do not add a long-lived npm token. + +- [ ] **Step 2: Configure npm trusted publishing** + +In npm organization/package settings, configure repository `NIPE-Solutions/readonly-view`, workflow `release.yml`, environment `npm`. If npm cannot create the package through OIDC, perform only the minimum authenticated first-publication setup allowed by npm, then retain OIDC for future releases. + +- [ ] **Step 3: Reconfirm registry absence and commit identity** + +Run: `npm view @nipe-solutions/readonly-view@2.0.0 version` + +Expected before publication: npm `E404`. Confirm the release workflow SHA equals the reviewed green `main` SHA. + +- [ ] **Step 4: Dispatch the release** + +Run: + +```bash +gh workflow run release.yml --ref main \ + -f version=2.0.0 \ + -f channel=latest \ + -f 'confirmation=publish 2.0.0 with latest' +``` + +Approve the protected `npm` environment deployment when prompted and monitor every job through registry propagation and GitHub release creation. + +- [ ] **Step 5: Verify the public package** + +Run: + +```bash +npm view @nipe-solutions/readonly-view@2.0.0 name version dist-tags.latest repository homepage +npm exec --yes --package=@nipe-solutions/readonly-view@2.0.0 -- \ + node -e "import('@nipe-solutions/readonly-view').then(m => console.log(typeof m.readonlyView))" +gh release view v2.0.0 --repo NIPE-Solutions/readonly-view +``` + +Expected: exact name/version, `latest` equals `2.0.0`, production homepage, import prints `function`, provenance is visible in npm, and the GitHub release exists. + +- [ ] **Step 6: Record final release evidence** + +Update the release-readiness record or GitHub release notes with the production site, CI run, npm version, provenance, tarball integrity, and known limitations. Do not modify the old `immuview` package. diff --git a/docs/superpowers/specs/2026-09-03-legal-deployment-release-design.md b/docs/superpowers/specs/2026-09-03-legal-deployment-release-design.md new file mode 100644 index 0000000..4b93d68 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-legal-deployment-release-design.md @@ -0,0 +1,69 @@ +# ReadonlyView Legal, Deployment, and Stable Release Design + +**Status:** Approved on 2026-09-03 + +## Outcome + +ReadonlyView will expose complete bilingual legal pages, deploy its Vite documentation site through Vercel at `https://readonly-view.nipesolutions.com`, align repository automation with the NIPE Solutions reference repository, and publish `@nipe-solutions/readonly-view@2.0.0` under npm's `latest` tag. + +## Legal pages + +The site will publish four stable routes: + +- `/privacy/` +- `/impressum/` +- `/de/datenschutz/` +- `/de/impressum/` + +The text and operator data will be adapted from the NIPE Solutions React Spring Bottom Sheet v5 site. The operator is NIPE Solutions e.U., proprietor Nicholas Petrasek, Achtergasse 10, 1230 Wien, Austria. Contact, VAT, company-register, court, authority, chamber, and trade details remain identical to the approved reference pages. Privacy text will describe Vercel hosting, ordinary request logs, email/contact handling, no non-essential cookies or analytics, GDPR rights, and the Austrian data-protection authority. The pages must not claim legal guarantees beyond the source text. + +Legal pages use the established ReadonlyView visual system: shared masthead and footer, narrow readable measure, explicit language switching, keyboard-visible focus styles, and responsive layouts. The main footer links all English and German legal routes. + +## Website architecture + +The existing framework-free Vite website remains in place. Legal pages are static HTML entry points with shared CSS; Vite is configured as a multi-page build so route directories produce deployable `index.html` files. The site remains zero-JavaScript on legal routes and retains the current interactive module only on the documentation home page. + +The hero contains only the product proposition, install command, and interactive ownership demonstration. It must not use a release-status pill or the phrase `v2 alpha · zero runtime dependencies`. Version and zero-dependency facts belong in the quieter proof/metadata area. + +The documentation home includes concise, copyable code examples for nested objects, owner-side live updates, arrays, Map, Set, Date, circular references, shared identity, and rejected mutations. Examples use the real public API, remain short enough to scan, and are mirrored by automated documentation-example tests rather than presented as decorative code. + +Production metadata, canonical URLs, Open Graph URLs, sitemap, robots file, package homepage, and repository documentation use `https://readonly-view.nipesolutions.com`. + +The tab icon uses the established ReadonlyView brand mark rather than a generic shield or lock: a dark rounded-square `R` core with a blue membrane accent, simplified for legibility at 16×16. The site ships a scalable SVG favicon plus raster browser and Apple-touch variants, with theme colors and manifest metadata shared by every page. + +## Vercel and DNS + +The repository will include a `vercel.json` with `npm ci`, `npm run build:website`, and `website/dist` output. The GitHub repository will be connected to Vercel using Git integration: pull requests receive preview deployments and `main` is the production branch. The custom domain is `readonly-view.nipesolutions.com`; its exact Vercel-provided DNS record will be configured in GoDaddy and verified rather than guessed. + +## GitHub workflows + +Automation follows the reference repository's sequencing and supply-chain model without importing React-specific jobs: + +- current pinned major versions for checkout, Node setup, and artifact upload; +- Node 24 primary quality gate and retained Node 22 compatibility gate; +- browser jobs depend on quality and cover Chromium, Firefox, and WebKit; +- Playwright diagnostics upload only on failure; +- release runs only from `main`, requires exact version/channel/confirmation, verifies package and registry state, uses the protected `npm` environment and OIDC trusted publishing, verifies propagation, and creates a GitHub release; +- packed artifact verification, declaration checks, size budgets, website build, and benchmarks remain required. + +Vercel deployment uses Vercel Git integration, not duplicated GitHub Actions deployment secrets. + +## Stable npm publication + +The package name is `@nipe-solutions/readonly-view`, version `2.0.0`, visibility public, and distribution tag `latest`. Package metadata and the lockfile must agree. The changelog will contain a real `2.0.0` entry and a fresh empty `Unreleased` section. + +Before publication, the exact packed artifact must pass the full release check and browser matrix. npm trusted publishing will be configured for `NIPE-Solutions/readonly-view`, workflow `release.yml`, environment `npm`, if npm permits initial publication through OIDC. If the registry requires an authenticated first publication, the first release may use the already authenticated npm organization account, but it must still publish the verified artifact with provenance when supported. Publication is followed by registry import checks, provenance inspection, and a GitHub `v2.0.0` release. + +The old `immuview` package is outside this change and will be deprecated separately by the owner. + +## Release order + +1. Add and test legal pages and production metadata. +2. Align workflows and release documentation. +3. Run the full local quality, package, website, and browser gates. +4. Merge the reviewed release-preparation branch to `main` and wait for green CI. +5. Link and deploy Vercel, configure GoDaddy DNS, and verify HTTPS/routes. +6. Configure npm/GitHub trusted publishing and protection. +7. Dispatch the stable release and verify npm and GitHub release state. + +Public npm publication must not precede a green release commit on `main`. From d832879296234de180ff3cfded33d7b525562d60 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Thu, 3 Sep 2026 22:18:04 +0200 Subject: [PATCH 02/14] feat(website): add bilingual legal pages --- scripts/verify-website.mjs | 105 +++++++++++++- test/browser/website.spec.ts | 26 ++++ test/integration/docs-examples.test.ts | 61 +++++++++ vite.website.config.ts | 29 +++- website/de/datenschutz/index.html | 181 +++++++++++++++++++++++++ website/de/impressum/index.html | 140 +++++++++++++++++++ website/impressum/index.html | 151 +++++++++++++++++++++ website/index.html | 125 +++++++++++++++-- website/legal.css | 106 +++++++++++++++ website/privacy/index.html | 174 ++++++++++++++++++++++++ website/public/apple-touch-icon.png | Bin 0 -> 5214 bytes website/public/favicon-32x32.png | Bin 0 -> 995 bytes website/public/favicon.svg | 5 + website/public/robots.txt | 4 + website/public/site.webmanifest | 20 +++ website/public/sitemap.xml | 8 ++ website/styles.css | 41 ++++-- 17 files changed, 1145 insertions(+), 31 deletions(-) create mode 100644 website/de/datenschutz/index.html create mode 100644 website/de/impressum/index.html create mode 100644 website/impressum/index.html create mode 100644 website/legal.css create mode 100644 website/privacy/index.html create mode 100644 website/public/apple-touch-icon.png create mode 100644 website/public/favicon-32x32.png create mode 100644 website/public/favicon.svg create mode 100644 website/public/robots.txt create mode 100644 website/public/site.webmanifest create mode 100644 website/public/sitemap.xml diff --git a/scripts/verify-website.mjs b/scripts/verify-website.mjs index 23f55f1..7256750 100644 --- a/scripts/verify-website.mjs +++ b/scripts/verify-website.mjs @@ -1,9 +1,40 @@ import { access, readFile } from 'node:fs/promises'; -const html = await readFile( - new URL('../website/dist/index.html', import.meta.url), - 'utf8', -); +const distUrl = new URL('../website/dist/', import.meta.url); +const pages = new Map([ + [ + 'privacy/index.html', + ['Privacy Policy', 'Vercel Inc.', 'no non-essential cookies'], + ], + [ + 'impressum/index.html', + ['Impressum', 'NIPE Solutions e.U.', 'ATU78464412'], + ], + [ + 'de/datenschutz/index.html', + ['Datenschutzerklärung', 'Vercel Inc.', 'Datenschutzbehörde'], + ], + [ + 'de/impressum/index.html', + ['Impressum', 'NIPE Solutions e.U.', 'FN 585066t'], + ], +]); +const pageHtml = new Map([ + ['index.html', await readFile(new URL('index.html', distUrl), 'utf8')], +]); + +for (const [path, expectedContent] of pages) { + const html = await readFile(new URL(path, distUrl), 'utf8'); + pageHtml.set(path, html); + for (const content of expectedContent) { + if (!html.includes(content)) + throw new Error(`${path} is missing defining content: ${content}`); + } +} + +const home = pageHtml.get('index.html'); +if (home === undefined) throw new Error('Missing built home page'); + for (const heading of [ 'Ownership model', 'Supported types', @@ -12,10 +43,70 @@ for (const heading of [ 'Migration from ImmuView v1', 'Security and trust model', ]) { - if (!html.includes(heading)) + if (!home.includes(heading)) throw new Error('Missing website section: ' + heading); } -if (html.includes('react')) + +for (const route of pages.keys()) { + const publicRoute = `/${route.replace('index.html', '')}`; + if (!home.includes(`href="${publicRoute}"`)) + throw new Error('Home page does not link legal route: ' + publicRoute); +} + +for (const example of [ + 'Nested protection', + 'Owner-side live updates', + 'Arrays', + 'Map and Set', + 'Date', + 'Circular references', + 'Shared identity', + 'Mutation rejection', +]) { + if (!home.includes(example)) + throw new Error('Missing website example: ' + example); +} + +const iconAssets = [ + '/favicon.svg', + '/favicon-32x32.png', + '/apple-touch-icon.png', + '/site.webmanifest', +]; +for (const [path, html] of pageHtml) { + if (html.includes('v2 alpha')) + throw new Error(`${path} unexpectedly contains v2 alpha`); + for (const asset of iconAssets) { + if (!html.includes(asset)) + throw new Error(`${path} does not link ${asset}`); + } +} +for (const asset of iconAssets) { + await access(new URL(asset.slice(1), distUrl)); +} + +const origin = 'https://readonly-view.nipesolutions.com'; +const canonicalUrls = [ + `${origin}/`, + ...[...pages.keys()].map( + (path) => `${origin}/${path.replace('index.html', '')}`, + ), +]; +for (const url of canonicalUrls) { + if (![...pageHtml.values()].some((html) => html.includes(url))) + throw new Error('Missing canonical page URL: ' + url); +} + +const robots = await readFile(new URL('robots.txt', distUrl), 'utf8'); +if (!robots.includes(`${origin}/sitemap.xml`)) + throw new Error('robots.txt does not link the production sitemap'); +const sitemap = await readFile(new URL('sitemap.xml', distUrl), 'utf8'); +for (const url of canonicalUrls) { + if (!sitemap.includes(`${url}`)) + throw new Error('Sitemap is missing: ' + url); +} + +if (home.includes('react')) throw new Error('Website unexpectedly references React'); -await access(new URL('../website/dist/assets', import.meta.url)); +await access(new URL('assets', distUrl)); console.log('Website structure verified'); diff --git a/test/browser/website.spec.ts b/test/browser/website.spec.ts index 49ef3f3..7a3c865 100644 --- a/test/browser/website.spec.ts +++ b/test/browser/website.spec.ts @@ -27,3 +27,29 @@ test('documentation has no narrow-screen overflow', async ({ page }) => { })); expect(widths.body).toBeLessThanOrEqual(widths.viewport); }); + +const legalRoutes = [ + ['/website/privacy/', 'Privacy Policy', 'Deutsch'], + ['/website/impressum/', 'Impressum', 'Deutsch'], + ['/website/de/datenschutz/', 'Datenschutzerklärung', 'English'], + ['/website/de/impressum/', 'Impressum', 'English'], +] as const; + +for (const [route, heading, language] of legalRoutes) { + test(`${route} has legal navigation and no horizontal overflow`, async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(route); + await expect( + page.getByRole('heading', { level: 1, name: heading }), + ).toBeVisible(); + await expect(page.getByRole('link', { name: language })).toBeVisible(); + await expect(page.getByRole('contentinfo')).toBeVisible(); + expect( + await page.evaluate(() => document.body.scrollWidth), + ).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth), + ); + }); +} diff --git a/test/integration/docs-examples.test.ts b/test/integration/docs-examples.test.ts index 338a575..4333506 100644 --- a/test/integration/docs-examples.test.ts +++ b/test/integration/docs-examples.test.ts @@ -16,3 +16,64 @@ it('matches the primary ownership example', () => { source.user.roles.push('editor'); expect(view.user).toEqual({ name: 'Bob', roles: ['admin', 'editor'] }); }); + +it('keeps array, Map, Set, and Date reads live while rejecting writes', () => { + const source = { + items: [{ id: 1 }], + map: new Map([['selected', { id: 1 }]]), + set: new Set([{ id: 1 }]), + updatedAt: new Date('2026-09-03T00:00:00.000Z'), + }; + const view = readonlyView(source); + + source.items.push({ id: 2 }); + source.map.set('next', { id: 2 }); + source.set.add({ id: 2 }); + source.updatedAt.setUTCDate(4); + + expect(view.items.map((item) => item.id)).toEqual([1, 2]); + expect(view.map.get('next')?.id).toBe(2); + expect([...view.set].map((item) => item.id)).toEqual([1, 2]); + expect(view.updatedAt.toISOString()).toBe('2026-09-04T00:00:00.000Z'); + expect(() => (view.items as { id: number }[]).push({ id: 3 })).toThrow( + DirectMutationError, + ); + expect(() => (view.map as Map).clear()).toThrow( + DirectMutationError, + ); + expect(() => (view.set as Set<{ id: number }>).clear()).toThrow( + DirectMutationError, + ); + expect(() => (view.updatedAt as Date).setUTCDate(5)).toThrow( + DirectMutationError, + ); +}); + +it('preserves cycles in a readonly view', () => { + type Node = { label: string; self?: Node }; + const source: Node = { label: 'root' }; + source.self = source; + + const view = readonlyView(source); + + expect(view.self).toBe(view); + expect(() => Reflect.set(view.self!, 'label', 'changed')).toThrow( + DirectMutationError, + ); +}); + +it('preserves shared identity across objects and collections', () => { + const shared = { id: 1 }; + const source = { + items: [shared], + map: new Map([['selected', shared]]), + set: new Set([shared]), + }; + const view = readonlyView(source); + + expect(view.items[0]).toBe(view.map.get('selected')); + expect([...view.set][0]).toBe(view.items[0]); + expect(() => Reflect.set(view.items[0]!, 'id', 2)).toThrow( + DirectMutationError, + ); +}); diff --git a/vite.website.config.ts b/vite.website.config.ts index 725aa28..681adfc 100644 --- a/vite.website.config.ts +++ b/vite.website.config.ts @@ -1,7 +1,32 @@ import { defineConfig } from 'vite'; +import { resolve } from 'node:path'; export default defineConfig({ root: 'website', - base: './', - build: { outDir: 'dist', emptyOutDir: true }, + base: '/', + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: { + index: resolve(import.meta.dirname, 'website/index.html'), + privacy: resolve( + import.meta.dirname, + 'website/privacy/index.html', + ), + impressum: resolve( + import.meta.dirname, + 'website/impressum/index.html', + ), + datenschutz: resolve( + import.meta.dirname, + 'website/de/datenschutz/index.html', + ), + 'de-impressum': resolve( + import.meta.dirname, + 'website/de/impressum/index.html', + ), + }, + }, + }, }); diff --git a/website/de/datenschutz/index.html b/website/de/datenschutz/index.html new file mode 100644 index 0000000..4410f80 --- /dev/null +++ b/website/de/datenschutz/index.html @@ -0,0 +1,181 @@ + + + + + + + + + + + Datenschutzerklärung — ReadonlyView + + + + + + + + + + +
+ RReadonlyView + NIPE primitives / 01 + +
+
+ + +
+ + + diff --git a/website/de/impressum/index.html b/website/de/impressum/index.html new file mode 100644 index 0000000..6e46fe8 --- /dev/null +++ b/website/de/impressum/index.html @@ -0,0 +1,140 @@ + + + + + + + + + + + Impressum — ReadonlyView + + + + + + + + + + +
+ RReadonlyView + NIPE primitives / 01 + +
+
+ + +
+ + + diff --git a/website/impressum/index.html b/website/impressum/index.html new file mode 100644 index 0000000..13dd3a1 --- /dev/null +++ b/website/impressum/index.html @@ -0,0 +1,151 @@ + + + + + + + + + + + Impressum — ReadonlyView + + + + + + + + + + +
+ RReadonlyView + NIPE primitives / 01 + +
+
+ + +
+ + + diff --git a/website/index.html b/website/index.html index c549774..8a5c774 100644 --- a/website/index.html +++ b/website/index.html @@ -9,13 +9,35 @@ /> + - + + ReadonlyView — a live readonly membrane + + + + + @@ -34,9 +56,6 @@
-

- v2 alpha · zero runtime dependencies -

The source stays mutable.
The view does not.

A deeply readonly, lazy, live view over mutable @@ -88,8 +107,11 @@

The source stays mutable.
The view does not.

>Every supported exposure route is wrapped.
- Small1.9 kB minified + gzip baseline. + v2Zero runtime dependencies. 1.9 kB minified + gzip + baseline.
@@ -98,6 +120,7 @@

The source stays mutable.
The view does not.

diff --git a/website/legal.css b/website/legal.css new file mode 100644 index 0000000..3307db5 --- /dev/null +++ b/website/legal.css @@ -0,0 +1,106 @@ +.legal-shell { + width: min(1120px, calc(100% - 80px)); + margin: 0 auto; + padding: 88px 0 104px; +} +.legal-header { + max-width: 760px; + padding-bottom: 56px; + border-bottom: 1px solid var(--rule); +} +.legal-header h1 { + margin: 18px 0 24px; + font-size: clamp(48px, 7vw, 76px); +} +.legal-kicker { + margin: 0; + color: var(--blue); + font-size: 14px; + font-weight: 650; +} +.legal-intro { + margin: 0; + max-width: 65ch; + color: #4c5158; + font-size: 19px; + line-height: 1.65; +} +.legal-content { + display: grid; + grid-template-columns: minmax(0, 720px) minmax(220px, 1fr); + gap: 88px; + padding-top: 64px; +} +.legal-content article { + min-width: 0; +} +.legal-content section + section { + margin-top: 52px; + padding-top: 52px; + border-top: 1px solid var(--rule); +} +.legal-content h2 { + margin: 0 0 18px; + font-size: 27px; + letter-spacing: -0.025em; +} +.legal-content p, +.legal-content li { + max-width: 72ch; + color: #444950; + font-size: 17px; + line-height: 1.75; +} +.legal-content a, +.legal-meta a { + color: #173c91; + text-decoration: underline; + text-decoration-color: #9db7ff; + text-underline-offset: 3px; +} +.legal-meta { + align-self: start; + position: sticky; + top: 104px; + padding-left: 22px; + border-left: 3px solid var(--blue); + color: var(--muted); + font-size: 14px; + line-height: 1.65; +} +.legal-meta p { + margin: 0 0 20px; +} +.legal-meta strong { + color: var(--ink); +} +.language-switch { + display: inline-flex; + margin-top: 22px; +} + +@media (max-width: 720px) { + .legal-shell { + width: min(100% - 40px, 720px); + padding: 56px 0 72px; + } + .legal-header { + padding-bottom: 40px; + } + .legal-header h1 { + font-size: clamp(42px, 14vw, 60px); + } + .legal-content { + grid-template-columns: 1fr; + gap: 56px; + padding-top: 48px; + } + .legal-content section + section { + margin-top: 40px; + padding-top: 40px; + } + .legal-meta { + position: static; + order: -1; + } +} diff --git a/website/privacy/index.html b/website/privacy/index.html new file mode 100644 index 0000000..45e4af4 --- /dev/null +++ b/website/privacy/index.html @@ -0,0 +1,174 @@ + + + + + + + + + + + Privacy Policy — ReadonlyView + + + + + + + + + + +
+ RReadonlyView + NIPE primitives / 01 + +
+
+ + +
+ + + diff --git a/website/public/apple-touch-icon.png b/website/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c212ec10ff9124cd3f126744724154d27703a752 GIT binary patch literal 5214 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 Il2zD$0DBVirvLx| literal 0 HcmV?d00001 diff --git a/website/public/favicon-32x32.png b/website/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..570c11236994a96f8f5db48815bd106a85d403c5 GIT binary patch literal 995 zcmV<9104K`P)bf;mk6OCPtr3M99R%j74Tl92+x=Z`;cf-asgYz*_+$5D1L+-X0%p zFHF)=C`G@?%{}M*&iQ>m=lsr1F8s$5nF79Gu-Fy1!!k)waT$f`-ipJD?kz$n%4SKW zaM_Ay--2#74XYkAV!7kLO?KDLX9ZxIreC67mhc*&Z)GmxfpK%ahrH++7ZUVDUi+4p6a0u0I8kd+iCOl5QPn2fnGF) zr8lHc!l`HK*z;lzAn#AJGSQ8IGNC6fIbe{waI4ldIo5nL387JV^hbnd#BHme-R6QOB&;UeI9+VkBNI2j6KIdCoGR7Ad7l}m1=(~A~Z@a%^ zd3ibGT>E|u9T1X3kiG*7d$+oq_40bX)YsQ>@uLe2{Q3)}B_)|B+bjRPMbf(lu!AZu z57E`xfe;Tl(skMvfIm>;JO@h4cbK1H~Cv+%;Y5Ld9 zM@G1F=WhN08XJ#hjLpol%ZDgT!LfTgtU9fS+&l0dS-5CKVNj2_HKKz?WZq&d|^>W6@~VxpFFj zl#+8Tt<25M0bm#gU7a0xJf7VF0Eoq=Ios0e^sA~KrTy}yhpgCP0D%7f+g$Bg`!ruU zd4iW;I-Wg1GAR~wh4|pZHpa&%oc?E@enMqs<%W4eGe~(ZK3n=z^{#FYZ`8zIyaV^^yl*kHyVx8mZE zPuEw%NuiVp#S_%LWo1$^e2V7Yv#GJ}Th`ox1=S4SQ}>vocEUA~uKE8?e*-+U8Wl&L R_EG=<002ovPDHLkV1g8m+Ohxu literal 0 HcmV?d00001 diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000..60bab55 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/website/public/robots.txt b/website/public/robots.txt new file mode 100644 index 0000000..4f04212 --- /dev/null +++ b/website/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://readonly-view.nipesolutions.com/sitemap.xml diff --git a/website/public/site.webmanifest b/website/public/site.webmanifest new file mode 100644 index 0000000..99225eb --- /dev/null +++ b/website/public/site.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "ReadonlyView", + "short_name": "ReadonlyView", + "description": "A deeply readonly, lazy, live view over mutable JavaScript data.", + "icons": [ + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + } + ], + "theme_color": "#15171a", + "background_color": "#f7f8f6", + "display": "standalone" +} diff --git a/website/public/sitemap.xml b/website/public/sitemap.xml new file mode 100644 index 0000000..1eec868 --- /dev/null +++ b/website/public/sitemap.xml @@ -0,0 +1,8 @@ + + + https://readonly-view.nipesolutions.com/ + https://readonly-view.nipesolutions.com/privacy/ + https://readonly-view.nipesolutions.com/impressum/ + https://readonly-view.nipesolutions.com/de/datenschutz/ + https://readonly-view.nipesolutions.com/de/impressum/ + diff --git a/website/styles.css b/website/styles.css index ecbce17..5a7a8e2 100644 --- a/website/styles.css +++ b/website/styles.css @@ -78,19 +78,6 @@ button { gap: 72px; align-items: center; } -.status { - font-size: 13px; - color: var(--muted); - display: flex; - align-items: center; - gap: 8px; -} -.status span { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--success); -} h1 { font-size: clamp(46px, 6vw, 82px); line-height: 0.98; @@ -241,6 +228,11 @@ code { letter-spacing: -0.03em; margin: 0 0 22px; } +.content h3 { + font-size: 18px; + letter-spacing: -0.015em; + margin: 0; +} .content p, .content li, .content dd { @@ -256,6 +248,19 @@ code { border-radius: 9px; overflow: auto; } +.example-grid { + display: grid; + gap: 28px; + margin-top: 36px; +} +.example { + display: grid; + gap: 12px; + min-width: 0; +} +.example pre { + margin: 0; +} .content code { font-size: 0.88em; } @@ -329,6 +334,12 @@ footer { color: var(--muted); font-size: 13px; } +footer nav { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px 22px; +} :focus-visible { outline: 3px solid var(--blue); outline-offset: 3px; @@ -406,6 +417,10 @@ footer { display: grid; gap: 8px; } + footer nav { + display: grid; + justify-content: start; + } } @media (prefers-reduced-motion: reduce) { html { From e3afd4fb2e7dd77de1cd232f06f1effa4ee1d4bf Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Thu, 3 Sep 2026 22:26:28 +0200 Subject: [PATCH 03/14] fix(website): verify production legal routes --- playwright.config.ts | 18 ++++-- scripts/verify-website.mjs | 78 +++++++++++++++++++++++--- test/browser/website.spec.ts | 70 +++++++++++++++-------- test/integration/docs-examples.test.ts | 28 +++++++++ website/de/datenschutz/index.html | 2 +- website/index.html | 30 ++++++++-- website/legal.css | 4 +- website/styles.css | 4 ++ 8 files changed, 190 insertions(+), 44 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 9c31d33..c7e3a0c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,9 +16,17 @@ export default defineConfig({ reporter: process.env.CI ? [['html', { open: 'never' }], ['line']] : 'line', use: { baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry' }, projects, - webServer: { - command: 'npm run build:dist && vite --host 127.0.0.1 --port 4173', - port: 4173, - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: 'vite --host 127.0.0.1 --port 4173 --strictPort', + port: 4173, + reuseExistingServer: !process.env.CI, + }, + { + command: + 'npm run build:website && vite preview --config vite.website.config.ts --host 127.0.0.1 --port 42873 --strictPort', + port: 42873, + reuseExistingServer: !process.env.CI, + }, + ], }); diff --git a/scripts/verify-website.mjs b/scripts/verify-website.mjs index 7256750..bda7892 100644 --- a/scripts/verify-website.mjs +++ b/scripts/verify-website.mjs @@ -67,6 +67,42 @@ for (const example of [ throw new Error('Missing website example: ' + example); } +function exampleCard(heading) { + const marker = `

${heading}

`; + const start = home.indexOf(marker); + const end = home.indexOf('', start); + if (start < 0 || end < 0) + throw new Error('Missing website example card: ' + heading); + return home.slice(start, end); +} + +for (const [heading, expectedContent] of [ + [ + 'Owner-side live updates', + [ + "import { readonlyView } from '@nipe-solutions/readonly-view'", + 'const source =', + 'const view = readonlyView(source)', + ], + ], + [ + 'Mutation rejection', + [ + 'DirectMutationError', + "from '@nipe-solutions/readonly-view'", + 'const source =', + 'const view = readonlyView(source)', + 'error instanceof DirectMutationError', + ], + ], +]) { + const card = exampleCard(heading); + for (const content of expectedContent) { + if (!card.includes(content)) + throw new Error(`${heading} example is not standalone: ${content}`); + } +} + const iconAssets = [ '/favicon.svg', '/favicon-32x32.png', @@ -86,15 +122,39 @@ for (const asset of iconAssets) { } const origin = 'https://readonly-view.nipesolutions.com'; -const canonicalUrls = [ - `${origin}/`, - ...[...pages.keys()].map( - (path) => `${origin}/${path.replace('index.html', '')}`, - ), -]; -for (const url of canonicalUrls) { - if (![...pageHtml.values()].some((html) => html.includes(url))) - throw new Error('Missing canonical page URL: ' + url); +const canonicalUrls = []; +for (const [path, html] of pageHtml) { + const route = path === 'index.html' ? '' : path.replace('index.html', ''); + const expectedUrl = `${origin}/${route}`; + canonicalUrls.push(expectedUrl); + + const canonicalTags = (html.match(/]*>/g) ?? []).filter((tag) => + tag.includes('rel="canonical"'), + ); + if (canonicalTags.length !== 1) + throw new Error( + `${path} has ${canonicalTags.length} canonical tags; expected 1`, + ); + const canonicalTag = canonicalTags[0]; + const canonicalUrl = canonicalTag?.match(/href="([^"]+)"/)?.[1]; + if (canonicalUrl !== expectedUrl) + throw new Error( + `${path} canonical URL is ${canonicalUrl ?? 'missing'}; expected ${expectedUrl}`, + ); + + const openGraphTags = (html.match(/]*>/g) ?? []).filter((tag) => + tag.includes('property="og:url"'), + ); + if (openGraphTags.length !== 1) + throw new Error( + `${path} has ${openGraphTags.length} Open Graph URL tags; expected 1`, + ); + const openGraphTag = openGraphTags[0]; + const openGraphUrl = openGraphTag?.match(/content="([^"]+)"/)?.[1]; + if (openGraphUrl !== expectedUrl) + throw new Error( + `${path} Open Graph URL is ${openGraphUrl ?? 'missing'}; expected ${expectedUrl}`, + ); } const robots = await readFile(new URL('robots.txt', distUrl), 'utf8'); diff --git a/test/browser/website.spec.ts b/test/browser/website.spec.ts index 7a3c865..098cd73 100644 --- a/test/browser/website.spec.ts +++ b/test/browser/website.spec.ts @@ -1,7 +1,9 @@ import { expect, test } from '@playwright/test'; +const websiteOrigin = 'http://127.0.0.1:42873'; + test('documentation navigation and live demo work', async ({ page }) => { - await page.goto('/website/index.html'); + await page.goto(websiteOrigin + '/'); await expect( page.getByRole('heading', { name: 'The source stays mutable. The view does not.', @@ -20,7 +22,7 @@ test('documentation navigation and live demo work', async ({ page }) => { test('documentation has no narrow-screen overflow', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('/website/index.html'); + await page.goto(websiteOrigin + '/'); const widths = await page.evaluate(() => ({ body: document.body.scrollWidth, viewport: document.documentElement.clientWidth, @@ -29,27 +31,51 @@ test('documentation has no narrow-screen overflow', async ({ page }) => { }); const legalRoutes = [ - ['/website/privacy/', 'Privacy Policy', 'Deutsch'], - ['/website/impressum/', 'Impressum', 'Deutsch'], - ['/website/de/datenschutz/', 'Datenschutzerklärung', 'English'], - ['/website/de/impressum/', 'Impressum', 'English'], + ['/privacy/', 'Privacy Policy', 'Deutsch'], + ['/impressum/', 'Impressum', 'Deutsch'], + ['/de/datenschutz/', 'Datenschutzerklärung', 'English'], + ['/de/impressum/', 'Impressum', 'English'], ] as const; for (const [route, heading, language] of legalRoutes) { - test(`${route} has legal navigation and no horizontal overflow`, async ({ - page, - }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(route); - await expect( - page.getByRole('heading', { level: 1, name: heading }), - ).toBeVisible(); - await expect(page.getByRole('link', { name: language })).toBeVisible(); - await expect(page.getByRole('contentinfo')).toBeVisible(); - expect( - await page.evaluate(() => document.body.scrollWidth), - ).toBeLessThanOrEqual( - await page.evaluate(() => document.documentElement.clientWidth), - ); - }); + for (const width of [390, 320]) { + test(`${route} loads styles and has no ${width}px overflow`, async ({ + page, + }) => { + const stylesheets: { status: number; url: string }[] = []; + const failedStylesheets: string[] = []; + page.on('response', (response) => { + if (response.request().resourceType() === 'stylesheet') { + stylesheets.push({ + status: response.status(), + url: response.url(), + }); + } + }); + page.on('requestfailed', (request) => { + if (request.resourceType() === 'stylesheet') { + failedStylesheets.push(request.url()); + } + }); + await page.setViewportSize({ width, height: 844 }); + await page.goto(websiteOrigin + route); + await expect( + page.getByRole('heading', { level: 1, name: heading }), + ).toBeVisible(); + await expect( + page.getByRole('link', { name: language }), + ).toBeVisible(); + await expect(page.getByRole('contentinfo')).toBeVisible(); + expect(failedStylesheets).toEqual([]); + expect(stylesheets).toHaveLength(2); + expect(stylesheets).toEqual( + stylesheets.map(({ url }) => ({ status: 200, url })), + ); + expect( + await page.evaluate(() => document.body.scrollWidth), + ).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth), + ); + }); + } } diff --git a/test/integration/docs-examples.test.ts b/test/integration/docs-examples.test.ts index 4333506..d3b1bd1 100644 --- a/test/integration/docs-examples.test.ts +++ b/test/integration/docs-examples.test.ts @@ -17,6 +17,34 @@ it('matches the primary ownership example', () => { expect(view.user).toEqual({ name: 'Bob', roles: ['admin', 'editor'] }); }); +it('matches the standalone owner-side live updates example', () => { + const source = { user: { name: 'Alice' } }; + const view = readonlyView(source); + + source.user.name = 'Bob'; + + expect(view.user.name).toBe('Bob'); +}); + +it('matches the standalone mutation rejection example', () => { + const source = { item: { id: 1 } }; + const view = readonlyView(source); + + let errorName: string | undefined; + try { + Reflect.set(view.item, 'id', 2); + } catch (error) { + if (error instanceof DirectMutationError) { + errorName = error.name; + } else { + throw error; + } + } + + expect(errorName).toBe('DirectMutationError'); + expect(source.item.id).toBe(1); +}); + it('keeps array, Map, Set, and Date reads live while rejecting writes', () => { const source = { items: [{ id: 1 }], diff --git a/website/de/datenschutz/index.html b/website/de/datenschutz/index.html index 4410f80..9c6e805 100644 --- a/website/de/datenschutz/index.html +++ b/website/de/datenschutz/index.html @@ -59,7 +59,7 @@