diff --git a/README.md b/README.md index 25794e1..64d62aa 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,191 @@ # ReadonlyView -A deeply readonly, lazy, live view over mutable JavaScript data. +Expose live internal data without exposing mutation. [ReadonlyView on npm](https://www.npmjs.com/package/@nipe-solutions/readonly-view) is a lazy, live, deeply readonly runtime membrane for TypeScript; it is not a state manager, clone, frozen snapshot, or mutation API. Read the [documentation](https://readonly-view.nipesolutions.com) or browse the [source on GitHub](https://github.com/NIPE-Solutions/readonly-view). ```ts -import { readonlyView } from '@nipe-solutions/readonly-view'; +import { + DirectMutationError, + readonlyView, +} from '@nipe-solutions/readonly-view'; + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} const source = { user: { name: 'Alice', roles: ['admin'] } }; const view = readonlyView(source); -view.user.name = 'Bob'; // TypeScript error + DirectMutationError +rejectsDirectMutation(() => { + // @ts-expect-error readonly view + view.user.name = 'Eve'; +}); source.user.name = 'Bob'; console.log(view.user.name); // Bob ``` -ReadonlyView is a runtime-enforced readonly membrane with strong TypeScript types. It is not a state manager, clone, frozen snapshot, or mutation API. - ## Install ```bash npm install @nipe-solutions/readonly-view +pnpm add @nipe-solutions/readonly-view +yarn add @nipe-solutions/readonly-view +bun add @nipe-solutions/readonly-view ``` Requires Node.js 22 or 24, or a current evergreen browser. +## Why this exists (SDK) + +SDKs often own mutable connection state but should not let consumers rewrite it through the public view. Keep the state private and publish a view; it gives consumers no new mutation path. + +```ts +import { + DirectMutationError, + readonlyView, +} from '@nipe-solutions/readonly-view'; + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} + +class Client { + #state = { connected: false, user: null as { name: string } | null }; + readonly state = readonlyView(this.#state); + + connect(user: { name: string }) { + this.#state.connected = true; + this.#state.user = user; + } +} + +const client = new Client(); +const state = client.state; +client.connect({ name: 'Alice' }); + +rejectsDirectMutation(() => { + // @ts-expect-error readonly SDK state + state.user!.name = 'Eve'; +}); +console.log(state.connected); // true: the view stays live after rejection +``` + +ReadonlyView only removes mutation capability from the view. The owner must still control other mutable aliases: `connect(user)` above preserves the supplied object reference, so a consumer that retains `user` can still mutate it. Copy or normalize inputs when that is not acceptable. + +## Use ReadonlyView when + +ReadonlyView is a strong fit when these five boundary conditions apply: + +1. One library, SDK, host, or subsystem clearly owns a mutable source graph. +2. Consumers need one stable reference that reflects later owner updates. +3. Consumers should inspect that graph, while writes through the published reference must fail at runtime and in TypeScript. +4. The public graph primarily contains values in ReadonlyView’s documented support matrix. +5. The owner can keep the source and other mutable aliases private or otherwise controlled. + +Typical boundaries include an SDK publishing public state, a registry exposing its entries, or a host giving plugins context. + +```ts +import { + DirectMutationError, + readonlyView, +} from '@nipe-solutions/readonly-view'; + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} + +class Registry { + #entries = new Map(); + readonly entries = readonlyView(this.#entries); + + register(key: string, value: { name: string }) { + this.#entries.set(key, value); + } +} + +const registry = new Registry(); +registry.register('primary', { name: 'Alice' }); +rejectsDirectMutation(() => { + // @ts-expect-error readonly registry Map + registry.entries.set('next', { name: 'Eve' }); +}); +console.log(registry.entries.get('primary')?.name); // Alice +``` + +```ts +import { + DirectMutationError, + readonlyView, + type DeepReadonly, +} from '@nipe-solutions/readonly-view'; + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} + +type PluginContext = { configuration: { retries: number } }; + +function initialize(context: DeepReadonly) { + console.log(context.configuration.retries); + rejectsDirectMutation(() => { + // @ts-expect-error readonly nested plugin configuration + context.configuration.retries = 5; + }); + console.log(context.configuration.retries); // 3 +} + +const source: PluginContext = { configuration: { retries: 3 } }; +initialize(readonlyView(source)); +``` + +## Comparison + +| Capability | TypeScript `readonly` | `Object.freeze` | Deep freeze | Snapshot / Immer | ReadonlyView | +| ----------------------- | --------------------- | -------------------------- | -------------------- | ----------------------- | --------------- | +| Compile-time protection | Yes, where typed | No | No | Optional types | Yes | +| Runtime depth | None | Shallow | Deep | Snapshot/draft-specific | Deep | +| Owner retains mutation | Yes | Top-level no; nested yes | No | Yes, on original | Yes | +| Live owner updates | Yes | Nested updates remain live | N/A: owner is frozen | No: new snapshot/state | Yes | +| Traversal/copying | None | None | Eager traversal | Produces/copies state | Lazy, on access | +| New-state production | No | No | No | Yes | No | + +These tools solve different problems. Immer produces new state through convenient mutations; ReadonlyView exposes existing owner-controlled data without granting mutation through the view. + +## Ownership mental model + +```text +Owner code ──mutable reference──▶ Source object graph + │ + readonlyView() + │ +Consumer code ◀──readonly access── Readonly membrane +``` + +`source` is owned by the library, host, or state container. `readonlyView(source)` gives a consumer a separate capability: read the same live graph. Owner writes are visible through the view; any write through the view is rejected. It does not make the source immutable or revoke mutable aliases the consumer already holds. + ## Guarantees - Writes through the view throw `DirectMutationError`. @@ -34,22 +197,17 @@ Requires Node.js 22 or 24, or a current evergreen browser. See [guarantees and non-guarantees](docs/guarantees.md). -## Comparison +## Supported values -| Capability | TypeScript readonly | Object.freeze | Immer | ReadonlyView | -| -------------------------- | :-----------------: | :-----------: | :--------: | :----------: | -| Compile-time deep readonly | Custom type | No | No | Yes | -| Runtime deep readonly | No | Shallow | Draft only | Yes | -| Lazy | N/A | N/A | No | Yes | -| Live backing object | N/A | Yes | No | Yes | -| Original remains mutable | Yes | No | Yes | Yes | -| Creates new state | No | No | Yes | No | +Fully supported: primitives, plain/null-prototype objects, arrays/tuples, Map, Set, Date, symbols, accessors, shared references, and cycles. Functions and custom classes have documented receiver/private-field semantics. Mutable native buffers, typed arrays, weak collections, Promise, RegExp, Error, URL, and URLSearchParams are rejected. See the [support matrix](docs/supported-types.md). -These tools solve different problems. Immer produces new state through convenient mutations; ReadonlyView exposes existing owner-controlled data without granting mutation through the view. +## When not to use it -## Supported values +Do not use ReadonlyView when you need immutable snapshots, structural sharing with new-state production, or a mechanism to prevent the owner from mutating data. ReadonlyView is not a security sandbox for hostile code; use process, realm, worker, permission, or protocol isolation for that threat model. Use a snapshot, persistent data structure, deep freeze, state-management tool, or isolation boundary designed for the actual job instead. -Fully supported: primitives, plain/null-prototype objects, arrays/tuples, Map, Set, Date, symbols, accessors, shared references, and cycles. Functions and custom classes have documented receiver/private-field semantics. Mutable native buffers, typed arrays, weak collections, Promise, RegExp, Error, URL, and URLSearchParams are rejected. See the [support matrix](docs/supported-types.md). +## Performance + +Initial wrapping creates one proxy and does not walk the graph. Nested proxies are created on access and reused through WeakMaps. Proxy reads still have overhead. See [performance](docs/performance.md) and [benchmarks](benchmarks/README.md). ## API @@ -59,13 +217,9 @@ Fully supported: primitives, plain/null-prototype objects, arrays/tuples, Map, S - `UnsupportedTypeError` exposes `kind`. - `DeepReadonly` models the runtime readonly contract recursively. -## Performance - -Initial wrapping creates one proxy and does not walk the graph. Nested proxies are created on access and reused through WeakMaps. Proxy reads still have overhead. See [performance](docs/performance.md) and [benchmarks](benchmarks/README.md). - ## Documentation -[Documentation site](https://readonly-view.nipesolutions.com) · [Architecture](docs/architecture.md) · [API](docs/api.md) · [Supported types](docs/supported-types.md) · [Compatibility](docs/compatibility.md) · [Migration](docs/migration-v1-v2.md) · [Security](docs/security-and-trust.md) · [Contributing](CONTRIBUTING.md) +[Documentation site](https://readonly-view.nipesolutions.com) · [Mental model](docs/mental-model.md) · [Use cases](docs/use-cases.md) · [Choosing an approach](docs/choosing-an-approach.md) · [Architecture](docs/architecture.md) · [API](docs/api.md) · [Supported types](docs/supported-types.md) · [Compatibility](docs/compatibility.md) · [Migration](docs/migration-v1-v2.md) · [Security](docs/security-and-trust.md) · [Contributing](CONTRIBUTING.md) ## License diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4fa7a3c..3ed3840 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -92,3 +92,14 @@ npm versions are immutable: never attempt to republish an existing version. If t is defective, deprecate it with a precise warning when appropriate, publish a reviewed patch version, and move `latest` only through the guarded workflow. Do not deprecate or otherwise mutate the old `immuview` package. + +## Product-story delivery gate + +The native-hardening release branch must land and publish before the +`docs/product-story-final` branch is merged or deployed. Until +`npm view @nipe-solutions/readonly-view@2.0.1 version` and +`npm view @nipe-solutions/readonly-view dist-tags.latest` both return `2.0.1`, +do not merge the product-story branch to `main`, deploy its hardening-specific +site/README claims, or publish its marketing drafts. This is a handoff order, +not evidence that `2.0.1` has been published: complete the verification in step +6 before proceeding with the product-story delivery. diff --git a/docs/api.md b/docs/api.md index c10fd26..9dd6aa4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,21 +1,212 @@ # API reference -## readonlyView +ReadonlyView exposes a small public API for publishing a live, deeply readonly access path. See the [ownership mental model](mental-model.md) for the owner/consumer boundary and [guarantees and non-guarantees](guarantees.md) for the complete runtime contract. -`readonlyView(source: T): DeepReadonly` returns primitives unchanged and objects as independent deeply readonly live views. +## `readonlyView` -## isReadonlyView +### Signature -`isReadonlyView(value: unknown): boolean` recognizes package views. +```ts +function readonlyView(source: T): DeepReadonly; +``` -## DirectMutationError +### Purpose -Fields: `operation`, optional `property`, and `objectKind`. +Creates an independent readonly membrane around a supported object-like source. It is for an owner that continues to mutate its source while consumers read the current graph without a mutation path through the returned view. -## UnsupportedTypeError +### Minimal example -Field: `kind`. +```ts +import { readonlyView } from '@nipe-solutions/readonly-view'; -## DeepReadonly +const source = { status: 'connecting' }; +const view = readonlyView(source); -A distributive recursive type for objects, arrays/tuples, Map, Set, Date, functions, constructors, optionals, nullability, symbols, unions, and recursive structures. Function and constructor results are deeply readonly. TypeScript cannot generically transform every overload or generic call signature without losing information; complex callable APIs may need a user-defined public interface. +source.status = 'connected'; +console.log(view.status); // connected +``` + +### Guarantees + +- Reads observe the owner’s current source state. +- Writes through supported view paths throw `DirectMutationError`. +- Nested supported values are wrapped lazily, without an eager graph walk. +- Shared references and cycles keep stable identity within one membrane. +- The source is not intentionally frozen, sealed, copied, or otherwise mutated by wrapping. + +### Edge cases + +Primitives are returned unchanged. Passing an existing ReadonlyView returns it unchanged. Each call with a non-view object creates an independent membrane, so wrappers from two calls are not identical even when they wrap the same source. Unsupported roots throw immediately; unsupported nested values throw only when consumer code reaches them. Consult [supported types](supported-types.md) for the inventory and [security and trust](security-and-trust.md) for alias and hostile-code limits. + +## `isReadonlyView` + +### Signature + +```ts +function isReadonlyView(value: unknown): boolean; +``` + +### Purpose + +Recognizes views created by this package. Use it when an API needs to distinguish a ReadonlyView from an ordinary object before deciding whether to wrap or handle it specially. + +### Minimal example + +```ts +import { isReadonlyView, readonlyView } from '@nipe-solutions/readonly-view'; + +const view = readonlyView({ enabled: true }); +console.log(isReadonlyView(view)); // true +console.log(isReadonlyView({ enabled: true })); // false +``` + +### Guarantees + +It returns `true` for package views, which `readonlyView` treats idempotently. It does not claim that an arbitrary proxy, frozen object, or TypeScript readonly value is a ReadonlyView. + +### Edge cases + +The predicate is about the package’s runtime views, not structural typing. Primitives and unrelated objects return `false`. + +## `DirectMutationError` + +### Signature + +```ts +class DirectMutationError extends Error { + readonly name: 'DirectMutationError'; + readonly operation: string; + readonly property?: PropertyKey; + readonly objectKind: string; + constructor(details: { + readonly operation: string; + readonly property?: PropertyKey; + readonly objectKind: string; + }); +} +``` + +### Purpose + +Signals that code attempted to mutate a supported value through a ReadonlyView. Catch it when mutation attempts are an expected boundary check; normally, letting it surface identifies an API misuse. + +### Minimal example + +```ts +import { + DirectMutationError, + readonlyView, +} from '@nipe-solutions/readonly-view'; + +const view = readonlyView({ settings: { retries: 3 } }); + +try { + Reflect.set(view.settings, 'retries', 5); +} catch (error) { + if (error instanceof DirectMutationError) { + console.log(error.operation, error.property, error.objectKind); + } +} +``` + +### Guarantees + +The error has `name === 'DirectMutationError'`, an `operation`, an `objectKind`, and a `property` when that operation applies to a property. These fields are metadata for diagnostics; the source remains unchanged after the rejected write. + +### Edge cases + +Collection and object operations can have different operation/property combinations, so treat `property` as optional. This error describes mutation through a supported view path, not mutation through an original source alias. + +## `UnsupportedTypeError` + +### Signature + +```ts +class UnsupportedTypeError extends Error { + readonly name: 'UnsupportedTypeError'; + readonly kind: string; + constructor(kind: string); +} +``` + +### Purpose + +Signals that ReadonlyView cannot safely protect a native or otherwise unsupported value, or that a sensitive built-in exposes an unclassified native member. + +### Minimal example + +```ts +import { + readonlyView, + UnsupportedTypeError, +} from '@nipe-solutions/readonly-view'; + +try { + readonlyView(/pattern/); +} catch (error) { + if (error instanceof UnsupportedTypeError) { + console.log(error.kind); // RegExp + } +} +``` + +### Guarantees + +The error has `name === 'UnsupportedTypeError'` and a `kind` naming the unsupported value type or native member. Unsupported roots fail during wrapping; unsupported values deeper in an otherwise supported graph fail lazily when accessed. An unclassified Map, Set, or Date prototype member fails before its implementation is invoked with the mutable source. + +### Edge cases + +Do not use this as a generic validation error. It is a deliberate fail-closed boundary for values and native operations that need dedicated handling. See the [supported type matrix](supported-types.md) for the current list and rationale. + +## `DeepReadonly` + +### Signature + +```ts +type DeepReadonly = T extends Primitive + ? T + : T extends abstract new (...arguments_: never[]) => object + ? DeepReadonlyConstructor + : T extends (...arguments_: never[]) => unknown + ? DeepReadonlyFunction + : T extends Date + ? ReadonlyDate + : T extends readonly unknown[] + ? { readonly [Key in keyof T]: DeepReadonly } + : T extends ReadonlyMap + ? ReadonlyMap, DeepReadonly> + : T extends ReadonlySet + ? DeepReadonlySet + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; +``` + +This is the exported conditional type; its private helper aliases are omitted here for readability. See the [source declaration](../src/public-types.ts) for those helper definitions. + +### Purpose + +Models the compile-time shape returned by `readonlyView`. It recursively preserves primitives while presenting objects, arrays/tuples, `Map`, `Set`, and `Date` with readonly operations; nested results are readonly too. + +### Minimal example + +```ts +import { readonlyView, type DeepReadonly } from '@nipe-solutions/readonly-view'; + +type PluginContext = { configuration: { retries: number } }; + +const context: DeepReadonly = readonlyView({ + configuration: { retries: 3 }, +}); + +// @ts-expect-error nested property is readonly +context.configuration.retries = 5; +``` + +### Guarantees + +`DeepReadonly` is distributive across unions and retains optionals, nullability, symbols, recursive structures, readonly collection APIs, and deeply readonly function/constructor results. It models the supported runtime contract but does not itself add runtime enforcement. + +### Edge cases + +TypeScript cannot generically preserve every overloaded or generic callable signature while transforming its return type. For complex callable APIs, publish a user-defined readonly interface at the boundary. A successful `DeepReadonly` type also does not make an unsupported runtime value supported; rely on the [supported types](supported-types.md) list for runtime behavior. diff --git a/docs/choosing-an-approach.md b/docs/choosing-an-approach.md new file mode 100644 index 0000000..639ef28 --- /dev/null +++ b/docs/choosing-an-approach.md @@ -0,0 +1,40 @@ +# Choosing an approach + +ReadonlyView is one way to model ownership. It is not a replacement for every form of readonly or immutable state. Choose the tool that matches the lifecycle you need. + +## TypeScript `readonly` + +Choose TypeScript `readonly` when you need compile-time guidance for a typed API and runtime enforcement is not required. It does not change JavaScript behavior: a consumer can still mutate a value at runtime if it has a mutable reference or casts around the type. + +## `Object.freeze` + +Choose `Object.freeze` when a shallow runtime boundary is enough and preventing top-level writes on that particular object is acceptable. Its nested objects remain mutable, and freezing the owner’s object means the owner cannot continue changing top-level properties. + +## Deep freeze + +Choose deep freeze when a value must become deeply immutable and neither owner nor consumer should update it. It eagerly traverses the reachable graph and changes the source objects’ mutability, so it does not fit a live owner-controlled surface. + +## Cloning and snapshots + +Choose cloning or snapshots when consumers need a historical, isolated point in time. A snapshot intentionally becomes stale after its creation; make a new one when the source changes. + +## Immer-style updates + +Choose an Immer-style immutable-update workflow when the primary job is to produce successor state from convenient draft mutations. The draft is a controlled working surface for an update; the result is a new state value, often with structural sharing. This is a state-production workflow, not a way to publish one owner-controlled live graph to consumers. + +## ReadonlyView + +Choose ReadonlyView when an owner continues to mutate a live graph, consumers must see those changes through an existing reference, and consumers must not mutate through that public access path. It creates a lazy runtime membrane without freezing, copying, or managing state. + +## Comparison + +| Capability | TypeScript `readonly` | `Object.freeze` | Deep freeze | Snapshot / Immer | ReadonlyView | +| ----------------------- | --------------------- | -------------------------- | -------------------- | ----------------------- | --------------- | +| Compile-time protection | Yes, where typed | No | No | Optional types | Yes | +| Runtime depth | None | Shallow | Deep | Snapshot/draft-specific | Deep | +| Owner retains mutation | Yes | Top-level no; nested yes | No | Yes, on original | Yes | +| Live owner updates | Yes | Nested updates remain live | N/A: owner is frozen | No: new snapshot/state | Yes | +| Traversal/copying | None | None | Eager traversal | Produces/copies state | Lazy, on access | +| New-state production | No | No | No | Yes | No | + +These approaches solve different ownership and lifecycle problems. Immer helps create new state; snapshots preserve a past state; freezing constrains the source itself; TypeScript guides typed consumers. ReadonlyView exposes an existing owner-controlled graph without granting mutation through the view. diff --git a/docs/mental-model.md b/docs/mental-model.md new file mode 100644 index 0000000..0087db7 --- /dev/null +++ b/docs/mental-model.md @@ -0,0 +1,57 @@ +# Ownership mental model + +ReadonlyView is for an ownership boundary: one party keeps a mutable source graph, while another party needs to inspect its current state without gaining a mutation path. + +```text +Owner code ──mutable reference──▶ Source object graph + │ + readonlyView() + │ +Consumer code ◀──readonly access── Readonly membrane +``` + +## The parts + +- **Owner:** the library, SDK, host, registry, or state container that owns the source and decides when it changes. +- **Source object graph:** the owner's live objects, arrays, collections, and their nested references. +- **`readonlyView`:** the boundary function that creates a view of that graph. +- **Readonly membrane:** the proxy layer that mediates values reached through the view. It allows reads and rejects writes. +- **Consumer:** code that receives the view, such as an application using an SDK or a plugin running in a host. + +The owner can still mutate the source. Those changes remain visible through an existing view because the view reads the same live graph; it is not a copied snapshot. + +## Lazy, live views + +`readonlyView(source)` creates the top-level view without walking the entire graph. When consumer code reads a supported nested object, ReadonlyView wraps that value then. The wrapper is reused when the same source value is reached again in that membrane, so shared references and cycles keep stable view identity. + +```ts +import { readonlyView } from '@nipe-solutions/readonly-view'; + +const shared = { id: 1 }; +const source = { first: shared, second: shared }; +const view = readonlyView(source); + +console.log(view.first === view.second); // true +source.first.id = 2; +console.log(view.second.id); // 2 +``` + +Each top-level `readonlyView(source)` call creates its own membrane. The corresponding wrappers from separate calls are intentionally not the same identity. Passing an existing ReadonlyView back to `readonlyView` is idempotent and returns that existing view. + +## Protection follows the access path + +ReadonlyView protects access through its view, not the source graph globally. It does not freeze the source, make every alias readonly, or revoke mutable references that a consumer already held. + +```ts +import { readonlyView } from '@nipe-solutions/readonly-view'; + +const source = { configuration: { retries: 3 } }; +const view = readonlyView(source); + +source.configuration.retries = 5; // owner-side mutation remains allowed +console.log(view.configuration.retries); // 5 +``` + +At a boundary, the owner therefore needs to control other mutable aliases too. If an API accepts and retains an object supplied by a consumer, that consumer can still change its retained original reference. Copy or normalize input at that separate boundary when needed. + +For the precise contract, read the [guarantees and non-guarantees](guarantees.md). See the [supported types](supported-types.md) for values that can be viewed and the [security and trust model](security-and-trust.md) for the limits of protection in hostile or untrusted JavaScript. diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 1679885..b5e0cb0 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -4,6 +4,14 @@ This record deliberately separates the released `2.0.0` baseline from the unpublished `2.0.1` hardening candidate. Evidence or guarantees in one section must not be attributed to the other version. +Delivery order: release and verify the native-hardening candidate first. Do not +merge `docs/product-story-final`, deploy its hardening-specific site/README +claims, or post its marketing drafts until +`npm view @nipe-solutions/readonly-view@2.0.1 version` and +`npm view @nipe-solutions/readonly-view dist-tags.latest` both return `2.0.1`. +This records a required handoff order; it does not state that `2.0.1` is +published. + ## Released baseline: 2.0.0 ### Public release evidence @@ -78,6 +86,11 @@ published 2.0.1 containing these commits: These guarantees assume untampered intrinsics and do not turn ReadonlyView into a sandbox. +The dependent product-story work expands the README, website, use-case, and API +content without further runtime or package-export changes. It must not merge or +deploy until this candidate is published and verified as `2.0.1`; that work did +not publish a new npm release. + ### Current local candidate evidence The following evidence was collected from this 2.0.1 worktree on 2026-09-04 diff --git a/docs/superpowers/plans/2026-09-04-product-story-and-launch-content.md b/docs/superpowers/plans/2026-09-04-product-story-and-launch-content.md new file mode 100644 index 0000000..7897b0a --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-product-story-and-launch-content.md @@ -0,0 +1,269 @@ +# ReadonlyView Product Story and Launch Content 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:** Reframe ReadonlyView around the owner/consumer boundary, add concrete use-case and decision documentation, and prepare honest channel-native launch content without changing runtime behavior. + +**Architecture:** Preserve the existing single-page, framework-free Vite site and repository documentation structure. Add product-story sections before the technical reference, keep all code examples synchronized through executable integration tests, and store unpublished campaign drafts in a root `marketing/` directory excluded by the package allowlist. + +**Tech Stack:** Markdown, semantic HTML, CSS, TypeScript, Vite 8, Vitest, Playwright, Node.js 22/24 + +**Spec:** `docs/superpowers/specs/2026-09-04-product-story-and-launch-content.md` + +## Global Constraints + +- Do not modify `src/`, public exports, runtime semantics, or support policy. +- Lead with “Expose live internal data without exposing mutation.” +- Use “owner” and “consumer” consistently; never describe the source as immutable. +- Preserve the current website identity, interactive demo, legal pages, and framework-free implementation. +- Do not add dependencies, tracking, analytics, decorative animation, fabricated social proof, or security claims. +- Keep limitations prominent and comparisons fair to the problem each alternative solves. +- Every published code example must execute in tests. +- Nothing in `marketing/` is posted or scheduled by this work. + +--- + +### Task 1: README product story and executable boundary examples + +**Files:** + +- Modify: `README.md` +- Modify: `test/integration/docs-examples.test.ts` +- Modify: `test/types/docs-examples.ts` + +**Interfaces:** + +- Consumes: `readonlyView`, `DirectMutationError`, and existing integration/type-test conventions. +- Produces: canonical SDK, registry, and plugin examples reused conceptually by the website and focused docs. + +- [ ] **Step 1: Add failing runtime examples** + +Add three tests to `test/integration/docs-examples.test.ts`. The SDK test must construct a class with private `#state`, expose `readonly state = readonlyView(this.#state)`, mutate through `connect`, observe the update through the existing view, reject `Reflect.set(view.user, 'name', ...)`, and verify the source-owned user is unchanged. The registry test must expose a readonly `Map`, allow owner-side `register`, reject `view.set`, and compare entries before/after rejection. The plugin test must pass a readonly context into `initialize`, reject nested configuration mutation, and preserve the source value. + +- [ ] **Step 2: Run the focused test and confirm the new cases fail before fixtures exist** + +Run: `npx vitest run --project unit test/integration/docs-examples.test.ts` + +Expected: failure caused by the newly referenced SDK, registry, or plugin fixtures not yet being defined. + +- [ ] **Step 3: Implement the fixtures and assertions** + +Use complete local fixtures in the test file, including this SDK shape: + +```ts +class Client { + #state = { connected: false, user: null as { name: string } | null }; + readonly state = readonlyView(this.#state); + + connect(user: { name: string }) { + this.#state.connected = true; + this.#state.user = user; + } +} +``` + +Assert owner liveness, rejected consumer mutation, and source snapshots explicitly. + +- [ ] **Step 4: Add compile-time examples** + +In `test/types/docs-examples.ts`, add the SDK public state shape and `@ts-expect-error` assignments for `client.state.connected`, registry `.set`, and nested plugin configuration. Keep valid reads and owner methods free of expected errors. + +- [ ] **Step 5: Rewrite the README** + +Use this section order: opening value proposition and minimal example; Install; Why this exists (SDK); Use ReadonlyView when; comparison table; ownership mental model; guarantees; supported values; When not to use it; performance; API; documentation; license. Include npm/docs/GitHub links, compact npm/pnpm/Yarn/Bun install commands, and no more than three badges after the opening example if they do not displace it. + +The comparison columns must be TypeScript `readonly`, `Object.freeze`, deep freeze, snapshot/Immer, and ReadonlyView. Rows must cover compile-time protection, runtime depth, whether the owner retains mutation, liveness, traversal/copying, and new-state production. + +- [ ] **Step 6: Verify and commit** + +Run: `npx vitest run --project unit test/integration/docs-examples.test.ts && npm run test:types && npx prettier --check README.md test/integration/docs-examples.test.ts test/types/docs-examples.ts` + +Expected: focused runtime and type tests pass; formatting passes. + +Commit: `docs: explain readonly-view through library boundaries` + +### Task 2: Focused mental-model, use-case, choice, and API documentation + +**Files:** + +- Create: `docs/mental-model.md` +- Create: `docs/use-cases.md` +- Create: `docs/choosing-an-approach.md` +- Modify: `docs/api.md` +- Modify: `README.md` + +**Interfaces:** + +- Consumes: canonical examples and vocabulary established in Task 1. +- Produces: authoritative destinations for ownership, fit, comparisons, and every public export. + +- [ ] **Step 1: Create the mental-model page** + +Explain owner, source graph, `readonlyView`, membrane, consumer, lazy wrapping, liveness, stable same-membrane identity, independent top-level membranes, and access-path—not global—protection. Include the exact diagram from the spec and links to guarantees, supported types, and security/trust. + +- [ ] **Step 2: Create the use-case page** + +Include complete SDK, registry, and plugin-context examples aligned with Task 1. For each, label owner, consumer, why a clone becomes stale, allowed owner mutation, and rejected consumer mutation. Add shorter sections for response caches, configuration managers, engine state, and diagnostic state, followed by “When not to use ReadonlyView.” + +- [ ] **Step 3: Create the decision guide** + +Give TypeScript `readonly`, `Object.freeze`, deep freeze, cloning/snapshots, Immer-style updates, and ReadonlyView an individual “choose this when” section. Add the same precise comparison matrix used by the README and conclude that the approaches solve different ownership/lifecycle problems. + +- [ ] **Step 4: Expand the API reference** + +For `readonlyView`, `isReadonlyView`, `DirectMutationError`, `UnsupportedTypeError`, and `DeepReadonly`, add signature, purpose, minimal example, guarantees, and edge cases. Explicitly cover primitives, existing views, independent membranes, error metadata, lazy unsupported nested values, and generic/overloaded callable type limitations. Link detailed semantics rather than duplicating them. + +- [ ] **Step 5: Wire the documentation index** + +Add Mental model, Use cases, and Choosing an approach to the README documentation links. Check every relative link with: + +```bash +node -e "const fs=require('node:fs'); for(const f of ['README.md','docs/mental-model.md','docs/use-cases.md','docs/choosing-an-approach.md','docs/api.md']) { if(!fs.existsSync(f)) throw new Error(f) }" +``` + +- [ ] **Step 6: Verify and commit** + +Run: `npx prettier --check README.md docs/mental-model.md docs/use-cases.md docs/choosing-an-approach.md docs/api.md && npm run test:unit && npm run test:types` + +Expected: formatting, 77+ runtime tests, and strict type examples pass. + +Commit: `docs: add readonly-view adoption guides` + +### Task 3: Website story, use cases, fit, and comparison + +**Files:** + +- Modify: `website/index.html` +- Modify: `website/styles.css` +- Modify: `scripts/verify-website.mjs` +- Modify: `test/browser/website.spec.ts` + +**Interfaces:** + +- Consumes: README positioning and canonical examples from Tasks 1–2. +- Produces: a conversion-oriented home-page journey before the existing technical reference. + +- [ ] **Step 1: Add failing build assertions** + +In `scripts/verify-website.mjs`, require the built home page to contain IDs `why`, `use-cases`, `comparison`, and `fit`; the primary sentence; SDK, registry, and plugin headings; “Good fit” and “Not a fit”; links to npm, GitHub, and the three new GitHub-hosted docs; and canonical production metadata. + +- [ ] **Step 2: Add failing browser expectations** + +In `test/browser/website.spec.ts`, verify sidebar links scroll to the four new IDs, the three CTA destinations have correct `href` values, each new section has one visible level-two heading, and the existing narrow-viewport overflow assertion still applies after navigating to `#use-cases`. + +- [ ] **Step 3: Run red tests** + +Run: `npm run test:website && npm run test:browser -- --project=chromium` + +Expected: failures for missing new sections and navigation links. + +- [ ] **Step 4: Implement semantic HTML** + +Retain the hero demo and proof strip. Insert, before the existing Introduction section: “Why this exists” with the SDK boundary; “Built for public read surfaces” with SDK, registry, and plugin scenarios; “Choose the right tool” with a compact accessible table; and a two-column “Good fit / Not a fit” section followed by npm/docs/GitHub CTAs. Add matching sidebar and on-page links. Update description/OG copy to “Expose live internal data without exposing mutation.” + +- [ ] **Step 5: Extend the existing design system** + +Add focused CSS for `.story`, `.use-case-list`, `.fit-grid`, and `.story-actions`. Use current paper/ink/blue tokens, rules, code surfaces, typography, and breakpoint conventions. Avoid identical rounded cards, new shadows, all-caps eyebrows, and non-interactive motion. Ensure readable lines below 80 characters and one-column mobile layouts. + +- [ ] **Step 6: Run green website verification** + +Run: `npm run test:website && npm run test:browser -- --project=chromium` + +Expected: website build assertions and Chromium browser tests pass. + +- [ ] **Step 7: Commit** + +Run: `git diff --check && npx prettier --check website/index.html website/styles.css scripts/verify-website.mjs test/browser/website.spec.ts` + +Commit: `docs(website): lead with readonly-view use cases` + +### Task 4: Launch-ready marketing library + +**Files:** + +- Create: `marketing/README.md` +- Create: `marketing/launch-announcement.md` +- Create: `marketing/technical-article.md` +- Create: `marketing/comparison-article.md` +- Create: `marketing/sdk-tutorial.md` +- Create: `marketing/mutation-escape-thread.md` + +**Interfaces:** + +- Consumes: canonical positioning, examples, guarantees, and limitations from Tasks 1–3. +- Produces: drafts for manual human review and publication; no automated external actions. + +- [ ] **Step 1: Create the campaign index** + +Document audience, goal, recommended publication sequence, canonical URLs, voice, prohibited claims, and a checklist requiring final channel-rule review, live-link checks, code formatting, disclosure, proofreading, and date/version confirmation. + +- [ ] **Step 2: Draft channel-native launch announcements** + +Write distinct GitHub/NIPE, personal LinkedIn, NIPE LinkedIn, X/Bluesky thread, and adaptable community versions. Each must state the boundary problem, show or link one concise example, identify `@nipe-solutions/readonly-view`, link the production site and repository, and acknowledge that it is not a sandbox or state manager. + +- [ ] **Step 3: Draft the technical article** + +Write “Why a readonly Proxy is harder than it looks” with concrete sections on property descriptors, getters/method returns, callback collection arguments, iterators, internal slots, and future native APIs. Explain shadow targets and fail-closed inventories in approachable language and end with appropriate/non-appropriate use cases. + +- [ ] **Step 4: Draft the comparison article and SDK tutorial** + +Keep the comparison fair and decision-oriented. In the tutorial, evolve a mutable leaked SDK reference into the tested `Client` pattern, add consumer and owner tests, then cover unsupported values, Proxy overhead, and trust boundaries. + +- [ ] **Step 5: Draft the seven-part escape-route thread** + +Use one post each for the naïve proxy premise, descriptors, returns/getters, callbacks, iterators, internal-slot built-ins, and future methods/fail-closed behavior. Finish with the repository and documentation links without alarmist security language. + +- [ ] **Step 6: Verify package exclusion and commit** + +Run: `npx prettier --check marketing && npm pack --dry-run --json | node -e "let s=''; process.stdin.on('data', d => s += d).on('end', () => { const p=JSON.parse(s)[0]; if(p.files.some(f => f.path.startsWith('marketing/'))) throw new Error('marketing shipped') })"` + +Expected: all drafts format cleanly and no `marketing/` path appears in the tarball. + +Commit: `docs(marketing): prepare readonly-view launch campaign` + +### Task 5: Discoverability, complete verification, and handoff + +**Files:** + +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `docs/release-readiness.md` + +**Interfaces:** + +- Consumes: all prior content and tests. +- Produces: focused npm keywords and final evidence for a communication-only pull request. + +- [ ] **Step 1: Update package keywords** + +Set both manifests to the spec’s ordered keyword list: `readonly`, `deep-readonly`, `immutable`, `immutability`, `proxy`, `membrane`, `typescript`, `javascript`, `live-view`, `runtime`, `object`, `map`, `set`. Do not change the published version. + +- [ ] **Step 2: Update release-readiness documentation** + +Add a short post-release communication note stating that README/site/use-case/API content was expanded without runtime or export changes. Do not overwrite the existing `STABLE READY` evidence or imply a new npm release was published. + +- [ ] **Step 3: Run the complete local gate** + +Run: `npm run release:check` + +Expected: formatting, lint, typecheck, 77+ runtime tests, strict types, size limits, package consumers, website verification, benchmark smoke, release metadata, and package artifact all pass. + +- [ ] **Step 4: Run the local browser suite** + +Run: `npm run test:browser` + +Expected: Chromium and Firefox pass; WebKit follows the existing macOS 14 exclusion and remains required in Linux CI. + +- [ ] **Step 5: Inspect scope and package** + +Run: `git diff origin/main...HEAD --stat && git diff origin/main...HEAD -- src/ && npm pack --dry-run --json` + +Expected: no `src/` diff, no new exports or dependencies, and the same seven intended package files apart from updated package metadata/README content. + +- [ ] **Step 6: Commit final metadata** + +Commit: `docs: finalize readonly-view product story` + +- [ ] **Step 7: Request review and open a pull request** + +Use `superpowers:requesting-code-review`, address verified findings, push `docs/product-story`, and open a PR against `main`. State explicitly that the PR changes communication and development-only drafts, not runtime semantics, and that nothing was posted externally. diff --git a/docs/superpowers/specs/2026-09-04-product-story-and-launch-content.md b/docs/superpowers/specs/2026-09-04-product-story-and-launch-content.md new file mode 100644 index 0000000..42ef127 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-product-story-and-launch-content.md @@ -0,0 +1,283 @@ +# ReadonlyView Product Story and Launch Content Design + +**Date:** 2026-09-04 + +**Status:** Approved + +## Objective + +Make ReadonlyView immediately understandable and compelling to library and SDK +authors without weakening its precise technical claims. Preserve the current +runtime, public API, documentation architecture, visual identity, and +framework-free website. This work changes product communication only. + +The primary message is: + +> Expose live internal data without exposing mutation. + +The supporting definition remains: + +> ReadonlyView creates deeply readonly, runtime-enforced views over mutable +> JavaScript data. The owner retains mutation authority; consumers receive a +> live read surface. + +“Readonly membrane” remains useful technical vocabulary, but it follows the +problem statement rather than leading it. + +## Audience and Jobs + +The primary audience is authors of JavaScript and TypeScript libraries, SDKs, +plugin systems, developer tools, engines, and long-lived service objects. They +need to expose state that consumers can inspect and that owners can continue to +update, without returning a mutable internal reference. + +The content must help a visitor answer, in order: + +1. What problem does this solve? +2. Is that a problem I have? +3. Why not use TypeScript `readonly`, `Object.freeze`, a clone, or Immer? +4. What does the ownership model look like in real code? +5. What guarantees and limitations can I rely on? +6. How do I install and use it? + +## Editorial Principles + +- Lead with the user’s boundary problem, not Proxy terminology. +- Use “owner” and “consumer” consistently. +- Say that the source remains mutable; avoid presenting the source as + immutable. +- Use concrete examples before abstraction. +- Keep every comparison scoped to the problem each tool actually solves. +- State limitations close to claims, including Proxy overhead, unsupported + values, custom-class caveats, and the fact that this is not a sandbox. +- Do not claim “secure,” “zero cost,” “faster than Immer,” “tamper-proof,” or + broad state-management capabilities. +- Do not invent customers, testimonials, adoption numbers, or benchmarks. +- Keep code examples executable and covered by tests. + +## README Design + +The README remains concise enough for npm and GitHub scanning. Its content +order becomes: + +1. Product name and value proposition: “Expose live internal data without + exposing mutation.” +2. One-sentence definition and the existing minimal source/view example. +3. Compact installation command and links to npm, documentation, and GitHub. +4. “Why this exists” using an SDK with private mutable state and a public live + readonly view. +5. “Use ReadonlyView when” with five concrete boundary conditions. +6. Comparison table covering TypeScript `readonly`, `Object.freeze`, deep + freeze, immutable snapshots/Immer, and ReadonlyView. +7. Ownership mental model rendered as a small text diagram. +8. Guarantees, supported values, and “When not to use it.” +9. Performance statement, API summary, and deeper documentation links. + +The README may use a small badge row after the opening example, limited to npm +version, CI, and license. Badges are omitted if their visual weight pushes the +value proposition or example down significantly. Installation alternatives +for pnpm, Yarn, and Bun appear in a compact collapsed or inline form; this does +not constitute a compatibility claim beyond installing the package. + +The comparison table distinguishes “live backing object” from “runtime +protection through the exposed reference.” `Object.freeze` is described as +shallow and as constraining the frozen object itself. Deep freeze is described +as recursive and owner-constraining. Immer is described as producing new state, +not as a direct competitor or a live view. + +## Website Design + +The current visual identity, typography, palette, interactive two-sided demo, +responsive layout, legal routes, and framework-free Vite implementation remain. +No decorative animation or redesign is introduced. + +The home-page journey becomes: + +1. Existing hero, with the headline or lede strengthened by the primary + problem statement. +2. Existing proof strip. +3. New “Why this exists” section centered on the owner/consumer boundary and + an SDK example. +4. New “Built for public read surfaces” section with three materially distinct + scenarios: + - SDK state that stays current after connection changes; + - a registry whose owner can register entries while consumers only read; + - a plugin context that prevents accidental configuration mutation. +5. New compact “Choose the right tool” comparison. +6. New “Good fit / not a fit” section. +7. Existing technical documentation, examples, support matrix, API, migration, + and FAQ. + +Primary calls to action are “Install from npm,” “Read the documentation,” and +“View on GitHub.” They appear near the hero and at the end of the fit section. +The page does not use pricing-style cards, fake social proof, or generic feature +tiles. New structural elements use the existing rules, spacing, blue accent, +and code surfaces. + +The sidebar and on-page navigation gain entries for the new story sections. +Metadata continues to use the canonical production origin. Open Graph copy is +updated to the plain-language value proposition while preserving the existing +image and canonical URL. + +## Documentation Design + +Create these focused documents: + +### `docs/mental-model.md` + +Explain source ownership, the view, the membrane, liveness, laziness, identity, +and the access-path boundary. Include this conceptual flow: + +```text +Owner code ──mutable reference──▶ Source object graph + │ + readonlyView() + │ +Consumer code ◀──readonly access── Readonly membrane +``` + +State explicitly that controlling one access path does not make the underlying +graph globally immutable. + +### `docs/use-cases.md` + +Include complete examples for SDK public state, an internal registry, and a +plugin context. Add shorter treatments of response caches, configuration +managers, shared engine state, and diagnostics. Each major example states the +owner, consumer, why a clone would be stale, and which mutations remain valid. +Include “When not to use ReadonlyView.” + +### `docs/choosing-an-approach.md` + +Compare TypeScript `readonly`, `Object.freeze`, deep freeze, cloning/snapshots, +Immer-style immutable updates, and ReadonlyView. Give each approach a fair +“choose this when” explanation. Avoid declaring a universal winner. + +### `docs/api.md` + +Expand every public export with purpose, signature, minimal example, +guarantees, and edge cases. Cover independent membranes, re-wrapping an +existing view, error metadata, primitive behavior, unsupported roots and +nested values, and the type/runtime caveats of `DeepReadonly`. + +Update the README and website documentation indexes to link these pages. Do not +duplicate detailed reflection, custom-class, or unsupported-type material that +already has an authoritative home; link to it. + +## Executable Example Coverage + +Extend `test/integration/docs-examples.test.ts` with consumer fixtures matching +the new SDK, registry, and plugin examples. Tests verify both halves of the +contract: + +- owner mutations remain visible through an existing view; +- consumer mutation attempts throw `DirectMutationError`; +- rejected attempts leave the source unchanged; +- nested objects and collection values remain protected; +- examples compile under the existing strict type test configuration where + TypeScript-specific mutation assertions are appropriate. + +Extend `scripts/verify-website.mjs` to assert the new home-page sections, core +copy, links, and examples are present in the production build. Extend the +website Playwright suite to verify the new navigation targets and CTA links, +and retain mobile overflow checks. + +No runtime tests or implementation changes are expected because this project +does not alter semantics. + +## Marketing Deliverables + +Create a root `marketing/` directory. It is development material and remains +excluded from the npm tarball by the existing package `files` allowlist. + +### `marketing/launch-announcement.md` + +Provide channel-native drafts for: + +- a concise GitHub/NIPE announcement; +- a personal LinkedIn post; +- a NIPE Solutions LinkedIn post; +- an X/Bluesky thread; +- a short community post that can be adapted only where self-promotion is + permitted. + +Each version leads with the boundary problem, includes one small example, names +the package, links to the canonical site and repository, and includes one +honest limitation. Do not paste identical copy across platforms. + +### `marketing/technical-article.md` + +Draft “Why a readonly Proxy is harder than it looks.” Cover naïve Proxy leaks +through descriptors, method returns, callbacks, iterator values, native +internal slots, and future platform methods. Explain the fail-closed method +inventory without reproducing internal source code. End with when the technique +is appropriate and link to the repository. + +### `marketing/comparison-article.md` + +Draft a practical decision guide comparing compile-time readonly types, +freezing, snapshots, immutable-update libraries, and live readonly views. + +### `marketing/sdk-tutorial.md` + +Draft a step-by-step tutorial that evolves an SDK from returning a mutable +internal state reference to exposing a live readonly view. Cover testing and +limitations. + +### `marketing/mutation-escape-thread.md` + +Draft a compact seven-part technical thread about common readonly Proxy escape +routes. The tone is educational rather than alarmist. + +Every draft includes a short publishing checklist for final link verification, +channel rules, disclosure, formatting, and proofreading. Nothing is posted or +scheduled by this work. + +## Search and Discoverability + +Update package keywords to the focused set: + +```text +readonly, deep-readonly, immutable, immutability, proxy, membrane, +typescript, javascript, live-view, runtime, object, map, set +``` + +Retain accurate page titles, canonical URLs, sitemap entries, Open Graph tags, +and social image references. Copy should naturally contain terms developers +search for, such as “runtime readonly,” “deep readonly,” and “live readonly +view”; do not keyword-stuff headings or prose. + +## Non-Goals + +- No runtime, public API, type, package-name, or support-policy changes. +- No React or framework integration. +- No state-management positioning. +- No analytics, newsletter, comments, search service, or tracking scripts. +- No visual redesign, animation campaign, or documentation framework migration. +- No automatic social posting or use of authenticated social accounts. +- No fabricated benchmarks, endorsements, users, or security claims. + +## Verification and Acceptance + +The change is accepted when: + +- a first-time library author can identify the problem and intended audience + from the README’s opening sections; +- README, website, and focused docs consistently explain owner versus consumer; +- SDK, registry, and plugin examples are executable and tested; +- comparison claims are precise and limitations remain prominent; +- every public export has useful API documentation; +- all marketing drafts are complete, channel-specific, and publication-ready + after final human review; +- the package tarball still excludes website, docs source, tests, and marketing; +- `npm run release:check` passes; +- the full Playwright browser matrix passes in CI; +- no runtime files or public exports change. + +## Delivery Strategy + +Implement this as a dedicated pull request based on `main`, independent of the +post-release evidence PR. Keep commits reviewable by grouping documentation and +README work, website work, marketing drafts, and verification updates. The PR +must state that it changes communication only and does not publish marketing +content or a new npm version. diff --git a/docs/use-cases.md b/docs/use-cases.md new file mode 100644 index 0000000..c784baa --- /dev/null +++ b/docs/use-cases.md @@ -0,0 +1,188 @@ +# Use cases + +Use ReadonlyView when an owner must publish **live** state for consumers to inspect, while retaining the only intended mutation path. It is especially useful at public API, plugin, and subsystem boundaries. + +The examples below use a small helper so the expected runtime rejection is explicit. + +```ts +import { DirectMutationError } from '@nipe-solutions/readonly-view'; + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} +``` + +## SDK public state + +- **Owner:** the SDK. +- **Consumer:** the application using it. +- **Why a clone becomes stale:** connection state can change when the SDK connects, disconnects, or receives new data. +- **Allowed owner mutation:** the SDK changes its private state through methods such as `connect`. +- **Rejected consumer mutation:** application code cannot change public state through `state`. + +```ts +import { + DirectMutationError, + readonlyView, +} from '@nipe-solutions/readonly-view'; + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} + +class Client { + #state = { connected: false, user: null as { name: string } | null }; + readonly state = readonlyView(this.#state); + + connect(user: { name: string }) { + this.#state.connected = true; // allowed owner mutation + this.#state.user = user; + } +} + +const client = new Client(); +const state = client.state; +client.connect({ name: 'Alice' }); + +rejectsDirectMutation(() => { + // @ts-expect-error readonly SDK state + state.user!.name = 'Eve'; +}); +console.log(state.connected); // true: the existing view stays live +``` + +`connect(user)` deliberately preserves the supplied object reference. That means a consumer retaining `user` can still mutate that separate alias. ReadonlyView closes the public `state` path; it does not fix input ownership by itself. + +## Internal registry + +- **Owner:** the registry. +- **Consumer:** code that searches or renders registered entries. +- **Why a clone becomes stale:** the next registration changes the `Map`. +- **Allowed owner mutation:** the registry adds entries through `register`. +- **Rejected consumer mutation:** consumers cannot call mutating `Map` methods through `entries`. + +```ts +import { + DirectMutationError, + readonlyView, +} from '@nipe-solutions/readonly-view'; + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} + +class Registry { + #entries = new Map(); + readonly entries = readonlyView(this.#entries); + + register(key: string, value: { name: string }) { + this.#entries.set(key, value); // allowed owner mutation + } +} + +const registry = new Registry(); +registry.register('primary', { name: 'Alice' }); + +rejectsDirectMutation(() => { + // @ts-expect-error readonly registry Map + registry.entries.set('next', { name: 'Eve' }); +}); +console.log(registry.entries.get('primary')?.name); // Alice +``` + +## Plugin context + +- **Owner:** the host. +- **Consumer:** a plugin. +- **Why a clone becomes stale:** it stops reflecting host-side configuration changes. +- **Allowed owner mutation:** the host updates its source configuration. +- **Rejected consumer mutation:** plugin code cannot mutate nested settings through its context. + +```ts +import { + DirectMutationError, + readonlyView, + type DeepReadonly, +} from '@nipe-solutions/readonly-view'; + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} + +type PluginContext = { configuration: { retries: number } }; + +class Plugin { + #context: DeepReadonly = readonlyView({ + configuration: { retries: 0 }, + }); + + initialize(context: DeepReadonly) { + this.#context = context; + } + + retries() { + return this.#context.configuration.retries; + } + + configureRetries(retries: number) { + return Reflect.set(this.#context.configuration, 'retries', retries); + } +} + +const source: PluginContext = { configuration: { retries: 3 } }; +const plugin = new Plugin(); +plugin.initialize(readonlyView(source)); +console.log(plugin.retries()); // 3 + +source.configuration.retries = 4; // allowed host-side mutation +console.log(plugin.retries()); // 4: the retained context stays live + +rejectsDirectMutation(() => plugin.configureRetries(5)); +console.log(plugin.retries()); // 4: the rejected plugin update preserves it +``` + +## Other good fits + +### Response caches + +A client or service owns a response cache while middleware and diagnostics inspect it. A cache snapshot is stale after a revalidation or eviction; publish a view when consumers need current entries but must not alter cache contents. + +### Configuration managers + +A configuration manager owns the active, resolved configuration while components read it. A clone becomes stale after a reload or a feature-flag change. Keep updates in the manager and expose a view to components. + +### Engine state + +An engine owns its current scene, document, or session graph while renderers and tools inspect it. A snapshot is the wrong surface when those tools must see the next owner-driven tick without receiving write capability. + +### Diagnostic state + +A long-lived service owns counters, status, or recent events while support tooling reads them. Views let diagnostics see changes as they happen without turning observability into a mutation API. + +## When not to use ReadonlyView + +Do not use ReadonlyView when you need an immutable point-in-time snapshot, structural sharing and new-state production, subscriptions or state lifecycle features, or a way to stop the owner from mutating data. Use snapshots, persistent/immutable data structures, deep freeze, or a state-management tool designed for those needs. It is also not a security sandbox: see the [security and trust model](security-and-trust.md). diff --git a/marketing/README.md b/marketing/README.md new file mode 100644 index 0000000..8fefe77 --- /dev/null +++ b/marketing/README.md @@ -0,0 +1,121 @@ +# ReadonlyView launch library + +> Status: draft only. Nothing in this directory is approved or published. + +This directory contains launch-ready source material for `@nipe-solutions/readonly-view`. Every draft is for manual human review and manual publication. There is no publishing automation in this directory. + +## Campaign brief + +### Goal + +Help TypeScript and JavaScript library authors recognize a specific ownership problem: an owner needs to keep changing a live object graph, while consumers need to inspect that graph without receiving a mutation path through the public reference. + +The campaign should make the decision easy: + +- use ReadonlyView for a lazy, live, deeply readonly access path; +- use another tool when the requirement is a snapshot, immutable state production, freezing the owner, subscriptions, or isolation from hostile code. + +The campaign is successful when a reader can explain both what ReadonlyView does and what it deliberately does not do. Do not define success using unverified download, adoption, conversion, performance, or security claims. + +### Primary audiences + +1. **SDK and library authors** who expose connection, session, cache, registry, or diagnostic state. +2. **Plugin and platform authors** who give extensions a live context but want writes to stay behind host-owned APIs. +3. **TypeScript developers** who understand compile-time `readonly` and need to decide whether runtime enforcement belongs at a boundary. +4. **JavaScript infrastructure maintainers** interested in Proxy invariants, membranes, and fail-closed API design. + +### Canonical positioning + +> Expose live internal data without exposing mutation. + +ReadonlyView is a lazy, live, deeply readonly runtime membrane with TypeScript types. The owner keeps the mutable source. Consumers receive a separate view that follows owner-side changes and rejects writes made through supported view paths. + +Always include the limit alongside the promise: ReadonlyView does not freeze the source, revoke other mutable aliases, manage state, create snapshots, or isolate hostile JavaScript. + +## Draft inventory + +| File | Purpose | Suggested destination | +| -------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------ | +| [`launch-announcement.md`](launch-announcement.md) | Channel-native launch copy | GitHub/NIPE, personal LinkedIn, NIPE LinkedIn, X/Bluesky, relevant communities | +| [`technical-article.md`](technical-article.md) | Explain the JavaScript mechanics behind a readonly membrane | Engineering blog or article platform | +| [`comparison-article.md`](comparison-article.md) | Help readers choose among readonly and immutable tools | Engineering blog, documentation, or article platform | +| [`sdk-tutorial.md`](sdk-tutorial.md) | Build and test a live readonly SDK state surface | Engineering blog or tutorial platform | +| [`mutation-escape-thread.md`](mutation-escape-thread.md) | Seven concrete ways a naive readonly Proxy can leak values | X, Bluesky, Mastodon, or another threaded channel after length review | + +## Recommended publication sequence + +1. **Release first.** Merge and release the native-hardening branch before this product-story branch. Do not merge this branch to `main`, deploy its hardening claims to the production site, or post any hardening-dependent draft until `npm view @nipe-solutions/readonly-view@2.0.1 version` and `npm view @nipe-solutions/readonly-view dist-tags.latest` both return `2.0.1`. +2. **Run the launch checklist below.** After that npm verification, confirm the package version, links, examples, claims, and target-channel rules on the actual publication date. +3. **Publish the GitHub/NIPE announcement.** Treat it as the short source-of-truth launch note and link it from later posts if the destination supports that pattern. +4. **Publish the technical article.** It supplies the reasoning behind the implementation and gives technical readers something useful beyond the announcement. +5. **Publish the comparison article.** It catches readers who have the ownership problem but may need a snapshot, freeze, or immutable-update workflow instead. +6. **Publish the SDK tutorial.** It turns the positioning into an adoptable pattern with consumer-side and owner-side tests. +7. **Publish the personal and NIPE LinkedIn posts at different times.** The personal post tells the engineering story; the company post frames the library as a focused open-source primitive. +8. **Publish one short-form thread.** Choose the launch thread in `launch-announcement.md` for product context or `mutation-escape-thread.md` for Proxy mechanics. Do not post both back-to-back. +9. **Adapt the community draft separately for each community.** Answer questions in the venue and disclose the author/project relationship where required. + +This is a sequence, not an automation schedule. A human chooses destinations and dates. + +## Canonical URLs + +Use these URLs unless the destination requires a channel-specific campaign link: + +- Product and documentation site: +- Repository: +- npm package: +- Ownership mental model: +- Use cases: +- Choosing an approach: +- Guarantees and non-guarantees: +- Supported types: +- Security and trust model: + +Do not invent tracking URLs, shortened links, testimonials, customer logos, or publication URLs before they exist. + +## Voice + +- **Precise before promotional.** Name the ownership boundary and lifecycle instead of calling the package “magic,” “bulletproof,” or “the only solution.” +- **Educational.** Give readers a useful mental model or example even if they never install the package. +- **Direct and calm.** Prefer “writes through the view throw” over threat language. +- **Fair to alternatives.** TypeScript `readonly`, freezing, snapshots, and Immer-style workflows solve legitimate but different problems. +- **Concrete.** Say who owns the source, who receives the view, which writes remain allowed, and which access path rejects writes. +- **Transparent.** In personal or community posts, disclose the author’s relationship to the project according to the channel’s rules. + +Capitalize the project as **ReadonlyView**. Use the exact package name `@nipe-solutions/readonly-view` in every standalone launch post. + +## Claims that must not be published + +Do not claim or imply any of the following: + +- that ReadonlyView is a security sandbox, capability isolation for hostile JavaScript, or a substitute for process/realm isolation; +- that it prevents the owner or holders of another mutable alias from changing the source; +- that it is a state manager, subscription system, mutation API, clone, frozen snapshot, deep freeze, or Immer replacement; +- that all JavaScript values are supported or that arbitrary user getters, functions, and consumer proxies are made side-effect-free; +- that it has “zero overhead,” is faster than another approach, or improves application performance; +- adoption, customer, production-usage, reliability, audit, certification, vulnerability, or incident claims without current public evidence; +- bundle-size, dependency, compatibility, or version claims copied from memory instead of confirmed from the release artifact and current package metadata; +- that a TypeScript error alone is runtime protection, or that the runtime membrane makes an unsupported type safe. + +It is accurate to say that supported view paths reject writes, nested wrapping is lazy, owner changes remain visible, and unsupported values fail explicitly. Keep those statements scoped exactly that way. + +## Launch checklist + +Complete this checklist separately for every destination immediately before publication: + +- [ ] Verify release order: the native-hardening branch was released before this product-story branch is merged or deployed, and `npm view @nipe-solutions/readonly-view@2.0.1 version` plus `npm view @nipe-solutions/readonly-view dist-tags.latest` both return `2.0.1`. Until both are true, do not merge, deploy, or post hardening-dependent claims. +- [ ] Confirm the destination’s current rules for promotional content, disclosures, links, hashtags, formatting, post length, code blocks, edits, and cross-posting. +- [ ] Disclose the author’s or NIPE Solutions’ relationship to ReadonlyView wherever the channel requires it; when uncertain, disclose plainly. +- [ ] Open every included link in a clean browser session and verify that it is live, public, canonical, and points to the intended version. +- [ ] Install the released package in a clean temporary project. Treat unlabelled executable code blocks as runnable examples and run them exactly as rendered. For every block labelled “illustrative fragment,” place it in a complete harness with its omitted imports, fixtures, and declarations, then type-check or run that harness as appropriate without removing the fragment label from the draft. +- [ ] Check code fences, syntax highlighting, smart quotes, escaped characters, indentation, and line wrapping after pasting into the destination editor. +- [ ] Confirm the released package name and exact version from npm, the supported Node/browser range from current documentation, and the publication date. +- [ ] Revalidate any bundle-size, dependency, compatibility, or benchmark statement against the release artifact; delete it if it is not necessary. +- [ ] Confirm that the post says what ReadonlyView is **not**: at minimum, not a sandbox and not a state manager. +- [ ] Confirm that the post does not imply protection for other aliases, owner-side immutability, or support for values outside the current type inventory. +- [ ] Proofread once for technical accuracy and once for plain-language flow; have a second human review long-form articles. +- [ ] Replace or remove any venue placeholders, optional hashtags, or disclosure notes. +- [ ] Record the final published URL outside this repository only after a human publishes the piece. + +## Source-of-truth rule + +If a marketing draft and the released documentation disagree, stop publication and update the draft from the released documentation. Marketing copy is downstream of the contract; it does not redefine it. diff --git a/marketing/comparison-article.md b/marketing/comparison-article.md new file mode 100644 index 0000000..670f144 --- /dev/null +++ b/marketing/comparison-article.md @@ -0,0 +1,211 @@ +# Readonly, frozen, snapshot, or live view? Choose by lifecycle + +> Publication status: unpublished draft for human review. +> +> Publication gate: complete the [launch checklist](README.md#launch-checklist) for this draft. Code fragments must keep their labels and be validated in a complete harness before publication. + +“Make this readonly” can describe at least five different requirements: + +1. prevent accidental writes in typed application code; +2. reject top-level writes at runtime; +3. make an entire value immutable for everyone; +4. preserve one point in time or produce a successor state; +5. let one owner keep updating data while consumers receive a live read-only access path. + +Those requirements sound related, but they imply different ownership and lifecycle choices. TypeScript `readonly`, `Object.freeze`, deep freeze, snapshots, immutable-update libraries, and live readonly views are complementary tools—not progressively “stronger” versions of one idea. + +The fastest way to choose is to ask who may mutate the data and whether consumers need the past or the present. + +## The short decision guide + +Choose **TypeScript `readonly`** when compile-time guidance is enough. + +Choose **`Object.freeze`** when a shallow runtime constraint on that exact object is enough. + +Choose **deep freeze** when the reachable graph should stop changing for both owner and consumer. + +Choose a **clone or snapshot** when consumers need an isolated point in time. + +Choose an **Immer-style immutable-update workflow** when the main operation is producing successor state, often with structural sharing. + +Choose a **live readonly view** when one owner keeps changing a graph, existing consumer references must reflect those changes, and writes through the public access path should fail. + +If none of these descriptions matches, an explicit query API or event/subscription interface may be a better boundary than publishing an object graph at all. + +## Comparison at a glance + +| Question | TypeScript `readonly` | `Object.freeze` | Deep freeze | Snapshot / clone | Immer-style update | Live readonly view | +| --------------------------------------------------------- | --------------------------------- | --------------------------------------------------- | ---------------- | ---------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------- | +| Compile-time guidance | Yes, where typed | No by itself | No by itself | Optional | Optional | Yes with an accompanying deep-readonly type | +| Runtime write rejection | No | Top-level only | Deep | No inherent rejection; writes stay in the copy | Draft-specific | Deep through supported view paths | +| May the owner keep mutating the same source? | Yes | Not at the frozen top level | No | Yes | Original stays separate; normal updates produce a successor | Yes | +| Do existing consumer references show later owner changes? | Yes, because runtime is unchanged | Nested changes can; frozen top-level changes cannot | No owner changes | No | No; consumers receive a state version | Yes | +| Eager graph traversal or copying | No | No | Usually yes | Yes for the captured graph | Produces/copies state as needed | No initial graph walk; nested wrapping is lazy | +| Produces new application state | No | No | No | Produces a copy | Yes | No | + +This table describes typical use, not every library implementation. Snapshot algorithms support different value types, and immutable-update libraries make different trade-offs. Check the tool you actually plan to use. + +## TypeScript `readonly`: guidance for typed consumers + +TypeScript can express an API that consumers should not mutate: + +```ts +type Connection = { + readonly status: 'connecting' | 'connected'; +}; + +function render(connection: Connection) { + // connection.status = 'connected'; // compile-time error +} +``` + +This is often exactly enough. It is cheap, communicates intent, and catches mistakes before code runs. + +It does not change the JavaScript value. A cast, untyped caller, reflected assignment, or another mutable alias can still write at runtime. Choose types alone when callers are trusted, the boundary is internal, or runtime rejection would add complexity without meaningful benefit. + +## `Object.freeze`: a shallow runtime rule + +`Object.freeze` changes one object so its existing own properties cannot be reassigned or deleted and new properties cannot be added. + +```ts +const configuration = Object.freeze({ + retries: 3, + labels: { environment: 'production' }, +}); + +Reflect.set(configuration, 'retries', 5); // fails +configuration.labels.environment = 'test'; // nested object is still mutable +``` + +The operation is shallow. It also constrains the exact source object, which means its owner cannot later replace `retries` on that object. + +Choose it for small constants, finalized records, or surfaces where shallow enforcement matches the real requirement. Do not describe a frozen outer object as a deeply immutable graph unless its nested values are handled separately. + +## Deep freeze: make the graph stop changing + +A deep-freeze utility traverses reachable values and freezes them recursively. It fits values that should become final for everyone: parsed configuration after startup, immutable fixtures, or finalized domain values. + +That finality is also the limitation. If an SDK needs to change `connected` after opening a socket, freezing the owned state conflicts with its lifecycle. Deep freeze usually performs an eager graph walk, must define behavior for cycles and unusual values, and changes the mutability of the source objects themselves. + +Choose deep freeze when shared immutability is the goal. Do not choose it when the owner must keep updating the same graph. + +## Clones and snapshots: preserve a point in time + +A snapshot separates the consumer’s value from future owner changes: + +_Illustrative fragment: define the initial `source` fixture in the publication validation harness._ + +```ts +const snapshot = structuredClone(source); + +source.status = 'connected'; +snapshot.status; // the earlier status +``` + +That staleness is a feature when building audit records, undo history, request payloads, test captures, or render inputs tied to one version. It is a bug when the consumer expects an existing `state` reference to stay current. + +Cloning also has semantics to choose: which types are supported, whether prototypes survive, how identity and cycles are handled, and whether the copy itself should be frozen. “Just clone it” is a lifecycle decision, not merely an implementation detail. + +Choose a snapshot when time and isolation matter more than liveness. Produce another snapshot when the owner changes. + +## Immer-style updates: produce the next state + +Immutable-update libraries solve a state-production problem. They let update code work against a controlled draft and then produce a new state value, often reusing unchanged structure. + +Conceptually: + +_Illustrative fragment: import `produce` and define `previousState` in the publication validation harness._ + +```ts +const nextState = produce(previousState, (draft) => { + draft.connection.status = 'connected'; +}); +``` + +This is a strong fit for reducers, undo/redo, predictable state transitions, and systems where versions of state are first-class. Consumers move from `previousState` to `nextState` rather than watching one reference reflect owner-side mutation. + +A live readonly view is not a replacement for that workflow. It produces no successor state, tracks no transactions, and manages no lifecycle. Conversely, an immutable-update library is not primarily a way to publish one owner-controlled mutable graph through a stable consumer reference. + +Choose based on whether the central verb is **produce** or **observe**. + +## Live readonly views: separate owner and consumer capabilities + +Consider an SDK with long-lived state: + +```ts +import { readonlyView } from '@nipe-solutions/readonly-view'; + +class Client { + #state = { connected: false }; + readonly state = readonlyView(this.#state); + + connect() { + this.#state.connected = true; + } +} + +const client = new Client(); +const state = client.state; + +client.connect(); +console.log(state.connected); // true +Reflect.set(state, 'connected', false); // throws +``` + +The owner retains the ordinary mutable source. The consumer gets a different capability: read the current graph through a stable view. Supported nested values are protected lazily rather than copied or frozen. + +This model fits SDK public state, registries, plugin contexts, configuration managers, caches, engines, and diagnostic surfaces when consumers need current data but writes should stay behind owner APIs. + +It comes with boundaries: + +- it does not create a snapshot or emit change notifications; +- it does not prevent owner-side mutation; +- it protects only access through the view, not another source alias; +- Proxy reads have runtime overhead; +- native values need deliberate support, and unsupported values should fail explicitly; +- it is not a security sandbox or a state manager. + +For `@nipe-solutions/readonly-view`, Map, Set, Date, arrays, plain objects, cycles, shared references, reflection, callbacks, and iterators have documented semantics. Other internal-slot built-ins may be unsupported rather than partially wrapped. Check the current support matrix before choosing the boundary. + +## Three questions that usually decide it + +### 1. Who is allowed to mutate? + +- **Only typed discipline matters:** TypeScript `readonly` may be enough. +- **Nobody should mutate:** freeze or deep freeze the finalized value. +- **Update logic should create versions:** use an immutable-update workflow. +- **One owner may mutate, consumers may not through the public path:** use a live readonly view. + +### 2. Should an existing consumer reference represent now or then? + +- **Then:** return a snapshot. +- **Now:** use a query API, selector, subscription-backed store, or live view depending on whether consumers need a graph and whether they need notifications. + +### 3. Is the consumer trusted JavaScript in the same process? + +A runtime readonly view can reject writes through its supported paths, but it cannot contain arbitrary code or revoke references already held elsewhere. If the consumer is hostile, use an isolation boundary designed for that threat model. If it is trusted but mistake-prone plugin or application code, a readonly view may be useful defense in depth for the public API. + +## Common combinations + +These approaches are not mutually exclusive: + +- publish TypeScript readonly types for every public API, then add runtime enforcement only at important boundaries; +- take snapshots from an owner-controlled source for history while exposing a live view for current diagnostics; +- freeze finalized configuration inputs, but expose changing operational state through a view; +- use an immutable-update workflow inside the owner and expose the owner’s current state through selectors or an appropriate readonly surface. + +The combination should preserve one clear answer to “who owns mutation?” If several parts of the system can still reach mutable aliases, wrapping one export does not settle ownership by itself. + +## A practical recommendation + +Write the requirement without using the word “readonly”: + +> The SDK owns connection state. It may update that state in place. Applications holding the public `state` reference must see later SDK updates, and writes through that reference must throw. + +That sentence points to a live readonly view. + +Change it to “applications need the state exactly as it was when the request completed,” and it points to a snapshot. Change it to “each action should produce a new state version,” and it points to an immutable-update workflow. Change it to “this configuration must never change again,” and it points to freezing. + +Choose the lifecycle first. The implementation choice usually follows. + +For a complete live-view contract, see the ReadonlyView [decision guide](https://github.com/NIPE-Solutions/readonly-view/blob/main/docs/choosing-an-approach.md), [documentation](https://readonly-view.nipesolutions.com/), and [source](https://github.com/NIPE-Solutions/readonly-view). `@nipe-solutions/readonly-view` is not a sandbox or state manager; it is one focused option for an owner-controlled live graph. diff --git a/marketing/launch-announcement.md b/marketing/launch-announcement.md new file mode 100644 index 0000000..147a52a --- /dev/null +++ b/marketing/launch-announcement.md @@ -0,0 +1,186 @@ +# Launch announcement drafts + +> Publication status: unpublished drafts for human review. Choose one channel section at a time; do not paste this whole file into a destination. +> +> Publication gate: complete the [launch checklist](README.md#launch-checklist) separately for every channel draft in this file. Unlabelled blocks are intended to run as rendered; labels identify intentionally abbreviated fragments. + +## GitHub / NIPE announcement + +### ReadonlyView v2: expose live data without exposing mutation + +We have released `@nipe-solutions/readonly-view`, a small TypeScript and JavaScript primitive for a specific library boundary: + +- your SDK, host, or registry owns a mutable object graph; +- consumers need to read its current state through a stable reference; +- consumers should not gain a mutation path through that public reference. + +```ts +import { readonlyView } from '@nipe-solutions/readonly-view'; + +const source = { connection: { status: 'connecting' } }; +const state = readonlyView(source); + +source.connection.status = 'connected'; +console.log(state.connection.status); // 'connected' + +Reflect.set(state.connection, 'status', 'offline'); // throws +``` + +The view is lazy, live, and deeply readonly for supported values. ReadonlyView does not clone or freeze the source: owner-side changes remain visible, and nested values are wrapped when consumers reach them. Maps, Sets, Dates, arrays, shared references, cycles, descriptors, iterators, and callback arguments have explicit handling rather than relying on a shallow `get`/`set` Proxy. + +Equally important, this is not a sandbox or a state manager. It only removes mutation capability from the view. It cannot revoke a source reference that somebody already holds, and unsupported native values fail explicitly instead of being exposed with incomplete protection. + +Install: + +```sh +npm install @nipe-solutions/readonly-view +``` + +Documentation: + +Source and issue tracker: + +ReadonlyView is MIT licensed. We welcome concrete bug reports and small reproductions, especially around JavaScript reflection and new native APIs. + +## Personal LinkedIn + +I kept running into an API design problem that sounds simple until JavaScript gets involved. + +An SDK owns live mutable state. The application needs to inspect that state. Returning the object leaks a write path; cloning it gives the application yesterday’s answer. + +That led me to build `@nipe-solutions/readonly-view`: + +_Illustrative fragment: add the import and `source` fixture in the publication validation harness._ + +```ts +const state = readonlyView(source); + +source.connected = true; // the owner can update it +state.connected; // true +Reflect.set(state, 'connected', false); // throws +``` + +The interesting work was not the `set` trap. It was closing less obvious routes: descriptor values, getter and method returns, callback arguments, iterators, collection methods, and built-ins with internal slots. The library uses a lazy membrane and fails explicitly when a value cannot be protected safely. + +I also want to be clear about its boundary. ReadonlyView is not a sandbox and it is not a state manager. It does not freeze the owner’s source or revoke another mutable alias. It gives a consumer a live, deeply readonly access path for supported values. + +If you maintain an SDK, plugin host, registry, configuration manager, or diagnostic surface, I’d be interested to hear how you model this boundary today. + +Docs: + +Repository: + +## NIPE LinkedIn + +Open-source release: `@nipe-solutions/readonly-view`. + +ReadonlyView is designed for public read surfaces where one component owns mutable data and another component needs a live view without receiving a mutation path. + +_Illustrative fragment: add the import and `internalState` fixture in the publication validation harness._ + +```ts +const publicState = readonlyView(internalState); + +internalState.status = 'ready'; +publicState.status; // 'ready' +Reflect.deleteProperty(publicState, 'status'); // throws +``` + +The package combines TypeScript’s recursive readonly shape with runtime enforcement across supported nested objects, arrays, Map, Set, Date, reflection, callbacks, and iteration. Wrapping is lazy, so creating a view does not walk the entire graph. + +The scope is intentionally narrow. ReadonlyView is not a sandbox, state manager, snapshot, or deep-freeze utility. The owner remains responsible for other mutable aliases, and values that cannot be safely adapted are rejected explicitly. + +Read the guide and interactive example: + +Review the implementation: + +## X / Bluesky launch thread + +> Review current per-post limits after links are pasted. Numbering is included so the sequence survives reposting. + +**1/5** + +An SDK owns mutable state. Consumers need to see the latest state—but returning the object also returns a write path, while cloning it produces a snapshot. + +`@nipe-solutions/readonly-view` is built for that boundary. 🧵 + +**2/5** + +_Illustrative fragment: add the import and `source` fixture in the publication validation harness._ + +```ts +const state = readonlyView(source); + +source.status = 'ready'; +state.status; // 'ready' + +Reflect.set(state, 'status', 'offline'); // throws +``` + +The source stays mutable. The view does not. + +**3/5** + +“Readonly” has more exits than a `set` trap: nested values, descriptors, getters, method returns, callback arguments, iterators, Map/Set methods, and native internal slots. + +ReadonlyView handles supported exposure routes through one lazy membrane. + +**4/5** + +The boundary matters: ReadonlyView is not a sandbox or state manager. It does not freeze the source or revoke other aliases. Unsupported native values throw explicitly rather than receiving partial protection. + +**5/5** + +Use `@nipe-solutions/readonly-view` when an owner keeps mutating a live graph and consumers need a stable, deeply readonly access path. + +Docs: + +Source: + +## Adaptable community post + +### Show: a live readonly view for SDK and plugin boundaries + +Disclosure: I am involved in ReadonlyView. I’m sharing it because the underlying API-design trade-off may be useful even if this package is not the right choice for your project. + +Suppose a library owns this state: + +_Illustrative two-part excerpt: the second block uses `source` from the first; validate both together in a complete harness._ + +```ts +const source = { + connection: { status: 'connecting' }, + peers: new Map(), +}; +``` + +Returning `source` lets callers mutate library-owned data. A deep clone removes that mutation path but becomes stale as soon as the library connects or receives another peer. Deep-freezing the source also blocks the library’s own updates. + +`@nipe-solutions/readonly-view` takes a different approach: keep the source private and return a live readonly membrane. + +_Illustrative continuation: this block uses the `source` declared in the preceding excerpt._ + +```ts +import { readonlyView } from '@nipe-solutions/readonly-view'; + +const state = readonlyView(source); + +source.connection.status = 'connected'; +console.log(state.connection.status); // 'connected' + +Reflect.set(state.connection, 'status', 'offline'); // throws +state.peers.clear(); // throws +``` + +The implementation had to account for more than property assignment. Values can escape through getters, descriptors, methods, array callbacks, iterators, and native collections. Supported values are wrapped lazily and share stable identity inside one membrane; unsupported native values throw when reached. + +This is intentionally not a sandbox or a state manager. It cannot revoke a mutable alias a consumer already has, prevent side effects inside arbitrary user code, create historical snapshots, or manage subscriptions. It is one runtime boundary for code that already has an owner/consumer trust model. + +The source, tests, limitations, and decision guide are here: + +- Documentation: +- Repository: + +I’d value feedback on the boundary and unsupported-type policy. If you expose live library-owned state today, do you return snapshots, freeze values, publish selectors, or use another pattern? + +> Before posting: adapt the title, disclosure, formatting, and invitation to the specific community. Remove this note and confirm that project posts are permitted. diff --git a/marketing/mutation-escape-thread.md b/marketing/mutation-escape-thread.md new file mode 100644 index 0000000..ab84f73 --- /dev/null +++ b/marketing/mutation-escape-thread.md @@ -0,0 +1,37 @@ +# Seven escape routes from a naïve readonly Proxy + +> Publication status: unpublished draft for human review. Publish as one seven-part thread only after checking the destination’s current character-count, disclosure, link, and code-formatting rules. +> +> Publication gate: complete the [launch checklist](README.md#launch-checklist) for this draft before posting any part of the thread. + +## 1/7 — The premise + +A JavaScript readonly Proxy looks simple: reject `set` and `delete`. That blocks `view.x = 1`, but nested reads can still return mutable objects. Deep readonly is an exposure problem: every route out needs the same membrane. `@nipe-solutions/readonly-view` + +## 2/7 — Descriptors + +`Object.getOwnPropertyDescriptor(view, 'child')` can expose a raw `descriptor.value`. Proxy invariants also constrain non-configurable properties. A controlled shadow target lets a view report wrapped values without changing source descriptors. + +## 3/7 — Returns and getters + +Getters and methods are exits too. Their results need wrapping, and user code should receive the readonly `this`. Binding `this` to the source restores mutation. Closures can still have outside effects; a readonly view does not make code pure. + +## 4/7 — Callbacks + +Callbacks can retain their arguments. Array callbacks need viewed items; Map `forEach` needs wrapped values, keys, and the Map view; Set needs wrapped values and its view. A read-only-sounding method can still leak a raw reference. + +## 5/7 — Iterators + +Iterators delay exposure: the value appears later from `next()`. `values()`, `entries()`, `Symbol.iterator`, `for...of`, and destructuring all need protected yields, with the same identity consumers see through ordinary reads. + +## 6/7 — Internal-slot built-ins + +Map, Set, and Date rely on native internal slots. A generic Proxy may fail receiver checks; binding every method to the source enables mutators. Dedicated adapters give reviewed reads valid receivers, wrap outputs, and reject writes. + +## 7/7 — Future methods and failing closed + +Native APIs evolve. Treat each method as a reviewed read, rejected mutator, or unsupported operation; fail explicitly until classified. ReadonlyView is not a sandbox or state manager. + +Docs: https://readonly-view.nipesolutions.com/ + +Source: https://github.com/NIPE-Solutions/readonly-view diff --git a/marketing/sdk-tutorial.md b/marketing/sdk-tutorial.md new file mode 100644 index 0000000..f601e33 --- /dev/null +++ b/marketing/sdk-tutorial.md @@ -0,0 +1,345 @@ +# Build a live readonly state surface for a TypeScript SDK + +> Publication status: unpublished draft for human review. +> +> Publication gate: complete the [launch checklist](README.md#launch-checklist) for this draft. The examples form one sequential project; validate the assembled `client.ts` and test files rather than treating each intermediate excerpt as a standalone file. + +SDKs often need to expose state that they continue to own: connection status, the active account, cached capabilities, or recent events. The straightforward implementation returns an object. Unfortunately, that makes the SDK’s internal write path public too. + +In this tutorial we will turn that leaked mutable reference into a tested, live readonly surface using `@nipe-solutions/readonly-view`. + +The target contract is precise: + +- the `Client` owns and mutates its source state; +- consumers keep one stable `client.state` reference; +- that reference reflects later `Client` updates; +- writes made through `client.state` throw at runtime; +- caller-owned input objects do not remain aliases into the SDK state. + +ReadonlyView is not a sandbox or state manager. The last sections cover the trust, type-support, and performance boundaries that still belong in the SDK design. + +## 1. Start with the leaked reference + +Here is a small client with public mutable state: + +```ts +type User = { + id: string; + name: string; +}; + +type ClientState = { + connected: boolean; + user: User | null; +}; + +export class Client { + readonly state: ClientState = { + connected: false, + user: null, + }; + + connect(user: User) { + this.state.connected = true; + this.state.user = user; + } +} +``` + +`readonly state` only prevents replacing the `state` property on `Client`. It does not make the object stored there readonly: + +_Illustrative fragment: run this consumer code against the preceding `Client` fixture._ + +```ts +const client = new Client(); +client.connect({ id: 'a-1', name: 'Alice' }); + +client.state.connected = false; +client.state.user!.name = 'Eve'; +``` + +Both assignments change SDK-owned state without going through an SDK method. + +We could return a clone, but then a previously returned value would not reflect a later `connect` or `disconnect`. We could freeze the state, but the `Client` itself needs to keep changing it. This API needs two capabilities over the same live graph: an owner reference and a consumer view. + +## 2. Keep the mutable source private + +First, separate the source from the public property: + +_Illustrative fragment: this intermediate class reuses the preceding `User` and `ClientState` declarations._ + +```ts +export class Client { + #state: ClientState = { + connected: false, + user: null, + }; + + connect(user: User) { + this.#state.connected = true; + this.#state.user = user; + } +} +``` + +Private syntax stops consumers from naming `#state`, but they still need a supported way to inspect it. Returning `this.#state` from a getter would recreate the leak. Returning a new clone on every read would change the lifecycle to snapshots. + +Adding a TypeScript `DeepReadonly` return type alone would improve editor guidance, but the JavaScript object would remain mutable at runtime. The public value needs both a deep-readonly type and a runtime boundary. + +## 3. Install and expose a view + +Install the package: + +```sh +npm install @nipe-solutions/readonly-view +``` + +Then create the public view once, beside the private source: + +```ts +import { readonlyView } from '@nipe-solutions/readonly-view'; + +type User = { + id: string; + name: string; +}; + +type ClientEvent = { + type: 'connected' | 'disconnected'; +}; + +type ClientState = { + connected: boolean; + user: User | null; + events: ClientEvent[]; +}; + +export class Client { + #state: ClientState = { + connected: false, + user: null, + events: [], + }; + + readonly state = readonlyView(this.#state); + + connect(user: User) { + this.#state.connected = true; + this.#state.user = { id: user.id, name: user.name }; + this.#state.events.push({ type: 'connected' }); + } + + disconnect() { + this.#state.connected = false; + this.#state.user = null; + this.#state.events.push({ type: 'disconnected' }); + } +} +``` + +`readonlyView(this.#state)` does not copy or freeze `#state`. It creates a separate membrane. The `Client` continues to use the ordinary mutable reference, while consumers receive a `DeepReadonly` view whose supported nested values are wrapped lazily. + +Notice that `connect` copies the two fields from `user`. That is not required by ReadonlyView; it is a separate input-ownership decision. Without the copy, a caller retaining the original `user` object could change that alias later. A readonly output boundary cannot repair a mutable input alias. + +For a richer input graph, validate and copy or normalize the parts the client intends to own. + +## 4. Use one stable consumer reference + +The view stays live, so consumers do not need to request a replacement after each owner action: + +_Illustrative fragment: run this consumer code against the complete `Client` from the preceding section._ + +```ts +const client = new Client(); +const state = client.state; + +console.log(state.connected); // false + +client.connect({ id: 'a-1', name: 'Alice' }); + +console.log(state.connected); // true +console.log(state.user?.name); // 'Alice' +console.log(state.events[0]?.type); // 'connected' +``` + +The same `state` reference reads the owner’s current graph. If the owner replaces `#state.user`, the next `state.user` read sees the replacement. If the same nested source object is reachable through several paths, one membrane reuses one wrapper, preserving shared identity and cycles. + +Liveness is not reactivity. No event is emitted merely because the source changes. Add your SDK’s normal events, observable, callback, or subscription API if consumers need notifications; use the view as the payload or read surface, not as the state lifecycle manager. + +## 5. Add consumer-boundary tests + +The first test contains both type-level expectations and runtime assertions, but they are verified by separate tools. TypeScript checks the `@ts-expect-error` directives; Vitest transpiles the file and checks the emitted JavaScript behavior. + +```ts +import { describe, expect, it } from 'vitest'; +import { DirectMutationError } from '@nipe-solutions/readonly-view'; +import { Client } from './client'; + +describe('Client public state', () => { + it('rejects consumer writes through every tested view path', () => { + const client = new Client(); + client.connect({ id: 'a-1', name: 'Alice' }); + const state = client.state; + + expect(() => { + // @ts-expect-error public state is readonly + state.connected = false; + }).toThrow(DirectMutationError); + + expect(() => { + // @ts-expect-error nested state is readonly + state.user!.name = 'Eve'; + }).toThrow(DirectMutationError); + + expect(() => { + // @ts-expect-error readonly arrays do not expose push + state.events.push({ type: 'disconnected' }); + }).toThrow(DirectMutationError); + + expect(state.connected).toBe(true); + expect(state.user?.name).toBe('Alice'); + expect(state.events.map((event) => event.type)).toEqual(['connected']); + }); +}); +``` + +Run both checks explicitly (adjust the test path or project command to match your repository): + +```sh +npx tsc --noEmit +npx vitest run test/client.test.ts +``` + +`tsc --noEmit` fails if an `@ts-expect-error` write unexpectedly becomes legal or if another type error appears. Vitest does not perform that type check; it executes the runtime assertions that expect `DirectMutationError`. Keep both commands in CI. + +Use `Reflect.set` when you want a runtime-only assertion without deliberately writing type-invalid source: + +_Illustrative fragment: place this assertion inside the complete Vitest fixture above._ + +```ts +expect(() => Reflect.set(client.state, 'connected', false)).toThrow( + DirectMutationError, +); +``` + +Add cases for the shapes your real SDK exposes: array methods, Map and Set mutation methods, nested descriptors, and any custom class behavior at the boundary. + +## 6. Add owner-liveness and alias tests + +Consumer rejection is only half the contract. The next test proves that legitimate owner operations remain visible through the existing view and that the copied input is no longer a mutable alias: + +```ts +import { describe, expect, it } from 'vitest'; +import { Client } from './client'; + +describe('Client-owned updates', () => { + it('keeps one view live while owner methods update the source', () => { + const client = new Client(); + const state = client.state; + const input = { id: 'a-1', name: 'Alice' }; + + client.connect(input); + + expect(client.state).toBe(state); + expect(state.connected).toBe(true); + expect(state.user).toEqual({ id: 'a-1', name: 'Alice' }); + expect(state.events.map((event) => event.type)).toEqual(['connected']); + + input.name = 'Mallory'; + expect(state.user?.name).toBe('Alice'); + + client.disconnect(); + + expect(client.state).toBe(state); + expect(state.connected).toBe(false); + expect(state.user).toBeNull(); + expect(state.events.map((event) => event.type)).toEqual([ + 'connected', + 'disconnected', + ]); + }); +}); +``` + +These tests state the ownership model more clearly than a generic “is readonly” assertion: + +- the public reference stays stable; +- owner methods still work; +- consumer writes through the view do not; +- the SDK does not accidentally retain the tested caller-owned alias. + +## 7. Decide what belongs in public state + +A runtime membrane still needs a supported value policy. `@nipe-solutions/readonly-view` fully supports primitives, plain and null-prototype objects, arrays and tuples, Map, Set, Date, shared references, cycles, symbols, and accessors. Functions and custom classes have documented receiver and private-field trade-offs. + +Several native families are deliberately unsupported, including RegExp, Error, URL, URLSearchParams, ArrayBuffer, SharedArrayBuffer, DataView, typed arrays, WeakMap, WeakSet, and Promise. They contain internal-slot or backing-memory behavior that a generic object Proxy cannot safely make readonly. + +An unsupported root throws immediately. An unsupported nested value throws lazily when a consumer reaches it: + +```ts +import { + readonlyView, + UnsupportedTypeError, +} from '@nipe-solutions/readonly-view'; + +const state = readonlyView({ matcher: /online/i }); + +try { + void state.matcher; // throws only when this nested value is reached +} catch (error) { + if (!(error instanceof UnsupportedTypeError)) throw error; +} +``` + +This is useful feedback during SDK design. Convert an unsupported value into an owned plain-data representation, expose an explicit read method, or keep it outside public state. Do not cast the TypeScript type and assume the runtime value became supported. + +Check the current [supported-type matrix](https://github.com/NIPE-Solutions/readonly-view/blob/main/docs/supported-types.md) before adding a new public-state field. + +## 8. Account for Proxy overhead + +Creating a view classifies and wraps the root; it does not walk the entire state graph. The first read of a supported nested object creates its proxy, and later reads in the same membrane reuse that identity. + +Laziness avoids eager copying, but every mediated read still has Proxy overhead. Collection adapters and protected iterators also allocate results. Keep the boundary at an API edge rather than wrapping and rewrapping values inside a hot inner loop, and benchmark the access patterns that matter to your SDK. Do not infer application-level performance from bundle size or a synthetic ratio. + +If consumers read a small stable projection at very high frequency, an explicit getter or selector may be simpler and avoid repeated Proxy access. The goal is a clear ownership boundary, not using a membrane everywhere. + +## 9. State the trust boundary in your API docs + +Document these points beside the public state property: + +1. **The view is not the source.** Writes through supported view paths throw, but the owner can continue to mutate its private reference. +2. **Other aliases still matter.** Anyone holding a source or retained input reference can use that reference. Copy or normalize inputs when the SDK needs exclusive ownership. +3. **Functions and getters are code.** They may close over mutable values or cause external side effects. A readonly receiver does not make arbitrary code pure. +4. **Consumer proxies are trusted inputs.** Their traps may execute while the membrane interacts with them. +5. **This is not hostile-code isolation.** Use an appropriate process, realm, worker, permission, or protocol boundary for untrusted code. + +That language prevents “runtime readonly” from being mistaken for a broader security guarantee. + +## 10. Know when to choose another shape + +This `Client` pattern fits when the SDK is the clear owner, the data must stay live, and consumers benefit from inspecting a graph through a stable reference. + +Choose a different approach when: + +- a response should capture one point in time—return a snapshot; +- each update should produce a new state version—use an immutable-update workflow; +- the entire graph should become immutable for the owner too—deep-freeze it; +- consumers need notification and lifecycle behavior—add a subscription or state-management mechanism; +- the public API can be smaller—prefer explicit query methods or selectors; +- consumers are hostile—use an isolation boundary rather than an object Proxy. + +## The completed boundary + +The final design is deliberately small: + +```text +Client methods ──mutate──▶ private #state + │ + readonlyView() + │ +Application ◀────reads──── client.state +``` + +One owner keeps ordinary mutation authority. Consumers receive a lazy, live, deeply readonly access path for supported values. Tests cover both sides of that contract, and input handling closes aliases that the output membrane cannot control. + +Read the complete [ownership model](https://github.com/NIPE-Solutions/readonly-view/blob/main/docs/mental-model.md), try the [documentation site](https://readonly-view.nipesolutions.com/), or inspect the [repository and tests](https://github.com/NIPE-Solutions/readonly-view). The package used in this tutorial is `@nipe-solutions/readonly-view`. diff --git a/marketing/technical-article.md b/marketing/technical-article.md new file mode 100644 index 0000000..7168f61 --- /dev/null +++ b/marketing/technical-article.md @@ -0,0 +1,256 @@ +# Why a readonly Proxy is harder than it looks + +> Publication status: unpublished draft for human review. +> +> Publication gate: complete the [launch checklist](README.md#launch-checklist) for this draft. Code fragments must keep their labels and be validated in a complete harness before publication. + +JavaScript makes the first version of a readonly object look almost trivial: + +```ts +function naiveReadonly(source: T): T { + return new Proxy(source, { + set() { + throw new Error('readonly'); + }, + deleteProperty() { + throw new Error('readonly'); + }, + }); +} +``` + +This blocks `view.count = 2` and `delete view.count`. It does not yet create a deeply readonly boundary. + +The hard part is not recognizing writes. It is making sure that every read path returns either a primitive or another protected view, never a mutable object from behind the boundary. JavaScript can expose values through property descriptors, accessors, methods, callbacks, iterators, prototypes, and native objects with hidden state. A serious implementation has to account for all of them while still obeying the language’s Proxy invariants. + +That requirement leads to a more useful mental model: a readonly Proxy is not one wrapper. It is a **membrane** around an object graph. + +## Start with the ownership boundary + +Imagine an SDK that owns mutable connection state: + +```ts +const source = { + connection: { status: 'connecting' }, +}; +``` + +The SDK should be able to change `source.connection.status`. An application should be able to observe that change through an existing reference, but it should not be able to change the same state through that public reference. + +A shallow Proxy only protects the first object: + +_Illustrative fragment: this continues the preceding `naiveReadonly` and `source` example._ + +```ts +const view = naiveReadonly(source); + +view.connection.status = 'offline'; // writes to the raw nested object +``` + +A membrane adds an identity-aware wrapping operation to every exposure route: + +_Illustrative fragment: this continues the same `source`/`view` example; validate it with the real package in a complete harness._ + +```ts +const nested = view.connection; + +nested === view.connection; // stable identity inside this membrane +Reflect.set(nested, 'status', 'offline'); // throws + +source.connection.status = 'connected'; +nested.status; // 'connected' +``` + +The wrapper cache matters. If the same source object appears in two places, consumers should see the same view in both places. It also lets cycles terminate without recursively copying the graph. The result remains live because reads still consult the owner’s current source; wrapping does not freeze or clone it. + +That solves ordinary nested reads. Then reflection opens another door. + +## Escape route 1: property descriptors + +`Object.getOwnPropertyDescriptor` can reveal a property without using an ordinary property read: + +_Illustrative fragment: include the earlier `naiveReadonly` declaration in the validation harness._ + +```ts +const child = { editable: true }; +const source = { child }; +const view = naiveReadonly(source); + +const descriptor = Object.getOwnPropertyDescriptor(view, 'child'); +descriptor?.value === child; // true in a naive implementation +``` + +If `descriptor.value` is the raw child, the consumer can mutate it. A deep readonly view therefore has to virtualize descriptors too: a data descriptor returned for the view contains the wrapped value, and an accessor descriptor must not hand out a source-bound getter or setter. + +There is a second, less obvious problem. Proxy traps are constrained by invariants. Consider a non-configurable, non-writable property: + +```ts +const child = { editable: true }; +const source = {}; + +Object.defineProperty(source, 'child', { + configurable: false, + writable: false, + value: child, +}); +``` + +If `source` is the Proxy target, the `get` trap is not allowed to report a different value for `child`. Returning a protected wrapper instead of the exact `child` reference violates the invariant and causes a `TypeError`. Returning the exact value obeys the invariant but leaks mutation access. + +### Why shadow targets help + +A controlled **shadow target** breaks that deadlock. The Proxy targets a fresh object owned by the membrane rather than the source itself. Traps read the live shape from the source, while the shadow carries only the invariant-sensitive shape needed by the Proxy. + +This lets the view expose a configurable descriptor whose value is another view, even when the source descriptor is non-configurable. It also means a frozen, sealed, or non-extensible source can remain untouched. The view describes the virtual readonly surface; it does not promise byte-for-byte descriptor identity with the source. + +Shadows have their own constraints. Arrays need array shadows so `Array.isArray(view)` remains true, and their non-configurable `length` descriptor must stay compatible. Callable values need callable shadows. A live view generally keeps its shadow extensible and rejects `preventExtensions`, because future owner-side keys still need to appear. + +## Escape route 2: getters and method returns + +A property may produce an object by running code: + +```ts +const source = { + child: { editable: true }, + get selected() { + return this.child; + }, + findSelected() { + return this.child; + }, +}; +``` + +Wrapping `source.child` is not enough. Both `view.selected` and `view.findSelected()` must pass their results back through the same membrane. + +The receiver is just as important as the return value. If the membrane binds a user method or getter to the mutable source, code inside it can write through `this`: + +```ts +const source = { + count: 0, + increment() { + this.count += 1; + }, +}; +``` + +Calling `view.increment()` should use the view as `this`. The assignment then reaches the readonly trap and is rejected. Binding the method to `source` would turn an apparently safe method call into a mutation escape. + +This decision has honest trade-offs. Methods that depend on JavaScript private fields may fail their native brand check when `this` is a Proxy. Rebinding those methods to the source would make them work, but it would also let public methods mutate the source. A conservative readonly boundary keeps the protected receiver and documents the private-field limitation. + +The membrane also cannot remove effects that do not travel through `this`. A function that closes over `source`, writes a module variable, sends a request, or changes another object can still do those things. Readonly access is not arbitrary-code isolation. + +## Escape route 3: callback arguments + +Collections expose values by calling consumer code. Consider an array method: + +_Illustrative fragment: import `readonlyView` in the validation harness._ + +```ts +const source = [{ status: 'queued' }]; +const view = readonlyView(source); + +view.forEach((item) => { + Reflect.set(item, 'status', 'running'); // must throw +}); +``` + +If the array implementation runs against the raw source, the callback receives the raw item. Non-mutating generic array methods need to operate against the view so index reads pass through the membrane. Their returned objects must be wrapped too. + +`Map.prototype.forEach` and `Set.prototype.forEach` add more detail. A Map callback receives `(value, key, map)`; all object-valued entries must be protected, and the third argument must be the readonly Map view rather than the mutable source. A Set callback receives the same value twice plus the Set, so both value positions need the same wrapped identity. + +Callbacks are easy to overlook because the collection method itself sounds read-only. The consumer can retain any callback argument after the call finishes. One raw argument is enough to reopen the mutation path. + +## Escape route 4: iterators + +Iteration is another value-returning API spread over several calls: + +_Illustrative fragment: define `view` with a supported collection fixture in the validation harness._ + +```ts +const iterator = view.values(); +const result = iterator.next(); +const item = result.value; +``` + +Protecting only `view.values()` is insufficient. The iterator’s later `next()` call is where the nested value appears. The same applies to `keys()`, `entries()`, `Symbol.iterator`, array iterators, `for...of`, and destructuring. + +A readonly adapter can expose an iterator facade that forwards `next()` and wraps each yielded value before returning it. Entry iterators need to protect both the key and the value. Values reached by iteration should resolve to the same view identities as values reached by `get`, indexing, or another property path. + +This is why “deep” should describe exposure routes, not merely property depth. The object graph includes anything consumer code can reach through the interface, regardless of whether it arrived through `view.child`, a callback, or the fourth call to `next()`. + +## Escape route 5: native internal slots + +Some built-ins store their real state in specification-level internal slots rather than ordinary properties. A generic Proxy does not acquire those slots. + +```ts +const map = new Map([['selected', { id: 1 }]]); +const proxy = new Proxy(map, {}); + +proxy.get('selected'); // typically fails an incompatible-receiver check +``` + +Blindly binding every Map method to the source fixes the receiver error but reintroduces mutation: + +_Illustrative fragment: this continues the preceding `proxy` example._ + +```ts +proxy.clear(); // would run with the mutable Map as its receiver +``` + +Supported built-ins therefore need dedicated adapters. For a Map, readonly operations can run with the valid native receiver, but returned keys and values must be wrapped. `set`, `delete`, and `clear` must reject before touching the source. Set needs the analogous treatment. Date read methods require a real Date receiver, while its `set*` family must be blocked. + +There is another distinction inside a native instance: an inventoried member reached on the expected intrinsic prototype may receive the internal-slot source when its semantics have been reviewed, while a custom method attached by user code should still receive the readonly view. This relies on the ordinary-library assumption that those intrinsic prototypes have not been tampered with before access. Otherwise an innocent-looking custom method becomes a source-bound escape. + +Other built-ins need their own designs. Typed arrays and `DataView` expose mutable backing memory. URL objects have native setters and mutators. Promise, WeakMap, WeakSet, RegExp, Error, ArrayBuffer, and similar objects have semantics that a generic object membrane cannot safely guess. Returning a half-working Proxy is worse than producing an explicit unsupported-type error. + +## Escape route 6: future native APIs + +JavaScript’s standard library evolves. A handwritten list that blocks every mutator today can become incomplete when a runtime adds a new method tomorrow. + +That creates a maintenance rule: treat each supported native surface as an inventory, not an open-ended passthrough. Every method needs an understood category: + +- a read whose receiver and return values are adapted; +- a mutation that rejects before reaching the source; +- or an unsupported operation that fails explicitly until its semantics are implemented and tested. + +Tests can compare the methods present on target runtime prototypes with the inventory, including legacy methods that are easy to forget. New platform methods should trigger review rather than inherit a “probably read-only” assumption. + +ReadonlyView follows the same fail-closed principle at the type-family level: Map, Set, and Date have dedicated behavior, while documented native families without safe adapters throw `UnsupportedTypeError`. Unsupported roots fail when wrapped; unsupported nested values fail lazily when a consumer reaches them. The support matrix—not a successful TypeScript type—is the runtime authority. + +This policy does not make future JavaScript changes automatically safe. It makes reviewing them an explicit part of maintaining the readonly contract. + +## What the membrane guarantees—and what it cannot + +For supported values, a complete membrane can reject writes made through the view, lazily protect nested exposures, retain stable identity within one membrane, preserve cycles, and keep owner-side changes visible. It can do that without intentionally freezing, sealing, copying, or rewriting the source. + +It cannot revoke another reference. If an SDK accepts an object from a consumer and stores that same object, the consumer can still mutate its original reference. Input copying or normalization is a separate ownership decision. Getters and functions can cause external side effects, and consumer-supplied proxies can run arbitrary traps. Those are trust-boundary questions, not problems a readonly view can solve alone. + +The membrane is neither a sandbox nor a state manager. It does not isolate arbitrary code, create successor state, emit subscriptions, or manage an application lifecycle. + +Proxy reads also carry overhead. Laziness avoids an eager walk of the graph, but it does not make mediated access free. Measure your own workload when the boundary is on a hot path. + +## When this technique is appropriate + +A live readonly membrane is a good fit when: + +- an SDK publishes current connection or session state; +- a registry exposes current Map entries while keeping registration behind owner methods; +- a host gives plugins a live configuration or context; +- cache, engine, or diagnostic consumers need an existing reference to reflect later owner updates; +- the owner can keep the source and other mutable aliases under its control. + +It is not the right technique when: + +- consumers need an immutable point-in-time snapshot; +- an update should produce a new state value with structural sharing; +- nobody, including the owner, should mutate the graph; +- subscriptions, transactions, reducers, persistence, or lifecycle management are required; +- hostile code needs process, realm, or capability isolation; +- the graph primarily contains unsupported native values. + +Those cases call for snapshots, deep freeze, persistent data structures, an immutable-update library, a state manager, or a stronger isolation boundary. + +The lesson is broader than one package: readonly behavior is a property of every route by which a value crosses a boundary. A `set` trap is only the beginning. + +`@nipe-solutions/readonly-view` is an open-source implementation of this design. Read the [documentation](https://readonly-view.nipesolutions.com/) or inspect the [source and tests](https://github.com/NIPE-Solutions/readonly-view). diff --git a/package-lock.json b/package-lock.json index 016b081..6b50fd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,21 @@ "name": "@nipe-solutions/readonly-view", "version": "2.0.1", "license": "MIT", + "keywords": [ + "readonly", + "deep-readonly", + "immutable", + "immutability", + "proxy", + "membrane", + "typescript", + "javascript", + "live-view", + "runtime", + "object", + "map", + "set" + ], "devDependencies": { "@eslint/js": "^10.0.1", "@playwright/test": "^1.62.1", diff --git a/package.json b/package.json index 346eaf0..76a2b7d 100644 --- a/package.json +++ b/package.json @@ -51,10 +51,18 @@ }, "keywords": [ "readonly", + "deep-readonly", + "immutable", + "immutability", "proxy", "membrane", "typescript", - "immutable-view" + "javascript", + "live-view", + "runtime", + "object", + "map", + "set" ], "author": "Nicholas Petrasek", "license": "MIT", diff --git a/scripts/verify-website.mjs b/scripts/verify-website.mjs index bda7892..5f01331 100644 --- a/scripts/verify-website.mjs +++ b/scripts/verify-website.mjs @@ -35,6 +35,54 @@ for (const [path, expectedContent] of pages) { const home = pageHtml.get('index.html'); if (home === undefined) throw new Error('Missing built home page'); +for (const id of ['why', 'use-cases', 'comparison', 'fit']) { + if (!home.includes(`id="${id}"`)) + throw new Error('Missing website story section: #' + id); +} + +for (const content of [ + 'Expose live internal data without exposing mutation.', + 'SDK public state', + 'Internal registry', + 'Plugin context', + 'Good fit', + 'Not a fit', +]) { + if (!home.includes(content)) + throw new Error('Missing website story content: ' + content); +} + +for (const destination of [ + 'https://www.npmjs.com/package/@nipe-solutions/readonly-view', + 'https://github.com/NIPE-Solutions/readonly-view', + 'https://github.com/NIPE-Solutions/readonly-view/blob/main/docs/mental-model.md', + 'https://github.com/NIPE-Solutions/readonly-view/blob/main/docs/use-cases.md', + 'https://github.com/NIPE-Solutions/readonly-view/blob/main/docs/choosing-an-approach.md', +]) { + if (!home.includes(`href="${destination}"`)) + throw new Error( + 'Home page does not link story destination: ' + destination, + ); +} + +const descriptionTags = home.match(/]*>/g) ?? []; +for (const [attribute, value] of [ + ['name', 'description'], + ['property', 'og:description'], +]) { + const tag = descriptionTags.find((candidate) => + candidate.includes(`${attribute}="${value}"`), + ); + if ( + tag === undefined || + !tag.includes( + 'content="Expose live internal data without exposing mutation."', + ) + ) { + throw new Error(`Home page has incorrect ${value} metadata`); + } +} + for (const heading of [ 'Ownership model', 'Supported types', diff --git a/test/browser/website.spec.ts b/test/browser/website.spec.ts index da9b005..d043d0e 100644 --- a/test/browser/website.spec.ts +++ b/test/browser/website.spec.ts @@ -20,9 +20,63 @@ test('documentation navigation and live demo work', async ({ page }) => { ).toBeAttached(); }); +test('story navigation and calls to action lead to their destinations', async ({ + page, +}) => { + await page.goto(websiteOrigin + '/'); + + const sidebar = page.getByRole('navigation', { name: 'Documentation' }); + for (const [name, id] of [ + ['Why this exists', 'why'], + ['Use cases', 'use-cases'], + ['Choose the right tool', 'comparison'], + ['Good fit / Not a fit', 'fit'], + ] as const) { + await sidebar.getByRole('link', { name, exact: true }).click(); + await expect(page).toHaveURL(`${websiteOrigin}/#${id}`); + await expect(page.locator(`#${id}`)).toBeInViewport(); + } + + for (const [name, href] of [ + [ + 'Install from npm', + 'https://www.npmjs.com/package/@nipe-solutions/readonly-view', + ], + ['Read the documentation', '#introduction'], + ['View on GitHub', 'https://github.com/NIPE-Solutions/readonly-view'], + ] as const) { + const links = page.getByRole('link', { name, exact: true }); + await expect(links).toHaveCount(2); + for (const link of await links.all()) { + await expect(link).toHaveAttribute('href', href); + } + } +}); + +test('story sections have one visible level-two heading each', async ({ + page, +}) => { + await page.goto(websiteOrigin + '/'); + + for (const [id, name] of [ + ['why', 'Why this exists'], + ['use-cases', 'Built for public read surfaces'], + ['comparison', 'Choose the right tool'], + ['fit', 'Is ReadonlyView a fit?'], + ] as const) { + const headings = page + .locator(`#${id}`) + .getByRole('heading', { level: 2 }); + await expect(headings).toHaveCount(1); + await expect(headings).toHaveText(name); + await expect(headings).toBeVisible(); + } +}); + test('documentation has no narrow-screen overflow', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(websiteOrigin + '/'); + await page.goto(websiteOrigin + '/#use-cases'); + await expect(page.locator('#use-cases')).toBeInViewport(); const widths = await page.evaluate(() => ({ body: document.body.scrollWidth, viewport: document.documentElement.clientWidth, @@ -30,6 +84,33 @@ test('documentation has no narrow-screen overflow', async ({ page }) => { expect(widths.body).toBeLessThanOrEqual(widths.viewport); }); +test('sticky documentation navigation keeps keyboard focus visible in a short viewport', async ({ + page, +}) => { + await page.setViewportSize({ width: 800, height: 600 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(websiteOrigin + '/#docs'); + + const navigation = page.getByRole('navigation', { + name: 'Documentation', + }); + const links = navigation.getByRole('link'); + const firstLink = links.first(); + const lastLink = links.last(); + const linkCount = await links.count(); + + await firstLink.focus(); + for (let index = 1; index < linkCount; index += 1) { + await page.keyboard.press('Tab'); + } + + await expect(lastLink).toBeFocused(); + const bounds = await lastLink.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.y).toBeGreaterThanOrEqual(6); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(594); +}); + const legalRoutes = [ ['/privacy/', 'Privacy Policy', 'Deutsch'], ['/impressum/', 'Impressum', 'Deutsch'], diff --git a/test/integration/docs-examples.test.ts b/test/integration/docs-examples.test.ts index d3b1bd1..e6677cc 100644 --- a/test/integration/docs-examples.test.ts +++ b/test/integration/docs-examples.test.ts @@ -1,5 +1,58 @@ import { expect, it } from 'vitest'; -import { DirectMutationError, readonlyView } from '../../src/index'; +import { + DirectMutationError, + readonlyView, + type DeepReadonly, +} from '../../src/index'; + +class Client { + #state = { connected: false, user: null as { name: string } | null }; + readonly state = readonlyView(this.#state); + + connect(user: { name: string }) { + this.#state.connected = true; + this.#state.user = user; + } +} + +class Registry { + #entries = new Map(); + readonly entries = readonlyView(this.#entries); + + register(key: string, value: { name: string }) { + this.#entries.set(key, value); + } +} + +type PluginContext = { configuration: { retries: number } }; + +class Plugin { + #context: DeepReadonly = readonlyView({ + configuration: { retries: 0 }, + }); + + initialize(context: DeepReadonly) { + this.#context = context; + } + + retries() { + return this.#context.configuration.retries; + } + + configureRetries(retries: number) { + return Reflect.set(this.#context.configuration, 'retries', retries); + } +} + +function rejectsDirectMutation(action: () => void) { + try { + action(); + } catch (error) { + if (error instanceof DirectMutationError) return; + throw error; + } + throw new Error('Expected DirectMutationError'); +} it('matches the primary ownership example', () => { const source = { user: { name: 'Alice', roles: ['admin'] } }; @@ -105,3 +158,51 @@ it('preserves shared identity across objects and collections', () => { DirectMutationError, ); }); + +it('matches the SDK public state example and its retained-alias limit', () => { + const client = new Client(); + const view = client.state; + const user = { name: 'Alice' }; + + client.connect(user); + user.name = 'Bob'; + const sourceUserSnapshot = { ...user }; + + expect(view.connected).toBe(true); + expect(view.user).toEqual({ name: 'Bob' }); + rejectsDirectMutation(() => { + Reflect.set(view.user!, 'name', 'Eve'); + }); + expect(user).toEqual(sourceUserSnapshot); +}); + +it('matches the registry read-only Map example', () => { + const registry = new Registry(); + const view = registry.entries; + + registry.register('primary', { name: 'Alice' }); + + expect([...view.entries()]).toEqual([['primary', { name: 'Alice' }]]); + const entriesBeforeRejection = [...view.entries()]; + rejectsDirectMutation(() => + (view as Map).set('next', { name: 'Eve' }), + ); + expect([...view.entries()]).toEqual(entriesBeforeRejection); + expect([...registry.entries.entries()]).toEqual(entriesBeforeRejection); +}); + +it('matches the plugin read-only context example', () => { + const source: PluginContext = { configuration: { retries: 3 } }; + const plugin = new Plugin(); + const context = readonlyView(source); + + plugin.initialize(context); + + expect(plugin.retries()).toBe(3); + source.configuration.retries = 4; + expect(plugin.retries()).toBe(4); + + rejectsDirectMutation(() => plugin.configureRetries(5)); + expect(source.configuration.retries).toBe(4); + expect(plugin.retries()).toBe(4); +}); diff --git a/test/types/docs-examples.ts b/test/types/docs-examples.ts index c91b599..fe9e5c1 100644 --- a/test/types/docs-examples.ts +++ b/test/types/docs-examples.ts @@ -1,4 +1,4 @@ -import { readonlyView } from '../../src/index'; +import { readonlyView, type DeepReadonly } from '../../src/index'; const source = { user: { name: 'Alice', roles: ['admin'] } }; const view = readonlyView(source); @@ -9,3 +9,67 @@ view.user.name = 'Bob'; view.user.roles.push('editor'); source.user.name = 'Bob'; + +class Client { + #state = { connected: false, user: null as { name: string } | null }; + readonly state = readonlyView(this.#state); + + connect(user: { name: string }) { + this.#state.connected = true; + this.#state.user = user; + } +} + +const client = new Client(); +client.connect({ name: 'Alice' }); +client.state.connected; +client.state.user?.name; + +// @ts-expect-error readonly SDK state +client.state.connected = false; + +class Registry { + #entries = new Map(); + readonly entries = readonlyView(this.#entries); + + register(key: string, value: { name: string }) { + this.#entries.set(key, value); + } +} + +const registry = new Registry(); +registry.register('primary', { name: 'Alice' }); +registry.entries.get('primary')?.name; + +// @ts-expect-error readonly registry Map +registry.entries.set('next', { name: 'Eve' }); + +type PluginContext = { configuration: { retries: number } }; + +class Plugin { + #context: DeepReadonly = readonlyView({ + configuration: { retries: 0 }, + }); + + initialize(context: DeepReadonly) { + this.#context = context; + } + + retries() { + return this.#context.configuration.retries; + } +} + +const pluginSource: PluginContext = { configuration: { retries: 3 } }; +const pluginContext = readonlyView(pluginSource); +const plugin = new Plugin(); +plugin.initialize(pluginContext); +plugin.retries(); // 3 + +pluginSource.configuration.retries = 4; +plugin.retries(); // 4: the retained plugin context stays live + +// @ts-expect-error readonly nested plugin configuration +pluginContext.configuration.retries = 5; + +plugin.retries(); // 4: the rejected consumer update preserves the owner value diff --git a/website/index.html b/website/index.html index cc8af07..04dd909 100644 --- a/website/index.html +++ b/website/index.html @@ -5,7 +5,7 @@ @@ -15,7 +15,7 @@ />

The source stays mutable.
The view does not.

- A deeply readonly, lazy, live view over mutable - JavaScript data. Runtime enforcement and TypeScript - types, without cloning or freezing. + Expose live internal data without exposing mutation. + ReadonlyView adds runtime enforcement and TypeScript + types without cloning or freezing.

npm install @nipe-solutions/readonly-viewThe source stays mutable.
The view does not. Copy
+
The source stays mutable.
The view does not.
v2Zero runtime dependencies. 1.9 kB minified + gzip - baseline. + >Zero runtime dependencies. ESM and CommonJS.
+
+

Why this exists

+

+ An SDK often owns mutable connection state while + applications need to inspect that state without + rewriting it. Keep the source private and publish a + live readonly view. +

+
import { readonlyView } from '@nipe-solutions/readonly-view'
+
+class Client {
+  #state = { connected: false }
+  readonly state = readonlyView(this.#state)
+
+  connect() {
+    this.#state.connected = true
+  }
+}
+
+const client = new Client()
+const state = client.state
+client.connect()
+
+state.connected // true
+Reflect.set(state, 'connected', false) // throws
+

+ ReadonlyView removes mutation capability from the + view. The owner must still control any other mutable + aliases. +

+

+ Read the ownership mental model. +

+
+
+

Built for public read surfaces

+

+ Use ReadonlyView at an ownership boundary where + consumers need current data but should not gain a + mutation path through the public view. +

+
+
+

SDK public state

+

+ Connection state stays current after the SDK + connects, disconnects, or receives new data. + Applications can inspect it but cannot + rewrite it through state. +

+
+
+

Internal registry

+

+ The registry keeps adding entries through + its own API. Search and rendering code sees + the current Map without access + to mutating collection methods. +

+
+
+

Plugin context

+

+ A host can update source configuration while + plugins receive a live context that rejects + accidental nested configuration writes. +

+
+
+

+ Explore complete use-case examples. +

+
+
+

Choose the right tool

+

+ Readonly and immutable tools solve different + ownership and lifecycle problems. Match the tool to + the behavior you need. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ A compact guide to readonly and immutable + approaches +
NeedChooseBehavior
Typed API guidance + TypeScript readonly + + Compile-time protection with no + runtime change. +
Shallow runtime boundaryObject.freeze + Blocks top-level writes on the + frozen object; nested values remain + mutable. +
Deeply immutable valueDeep freeze + Eagerly traverses the graph and also + prevents owner mutation. +
Historical or successor stateSnapshot / Immer + Preserves a point in time or + produces new state. +
Owner-controlled live graphReadonlyView + Adds lazy, deep protection through + the view while owner updates stay + live. +
+
+

+ Read the detailed decision guide. +

+
+
+

Is ReadonlyView a fit?

+
+
+

Good fit

+
    +
  • + One owner controls the mutable source. +
  • +
  • + Existing views must reflect later owner + updates. +
  • +
  • + Public writes need TypeScript guidance + and runtime rejection. +
  • +
  • + Lazy wrapping is preferable to eager + traversal or copying. +
  • +
+
+
+

Not a fit

+
    +
  • You need a point-in-time snapshot.
  • +
  • + You need structural sharing or new-state + production. +
  • +
  • + You need to prevent the owner from + mutating the source. +
  • +
  • + You need a security sandbox for hostile + JavaScript or other mutable aliases. +
  • +
+
+
+ +

Introduction

@@ -475,7 +700,11 @@

Contributing

diff --git a/website/styles.css b/website/styles.css index 3ec167c..c780918 100644 --- a/website/styles.css +++ b/website/styles.css @@ -192,6 +192,24 @@ code { font-size: 14px; color: var(--muted); } +.story-actions { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + margin-top: 28px; +} +.story-actions a { + min-height: 44px; + display: inline-flex; + align-items: center; + border-bottom: 2px solid var(--rule); + font-size: 14px; + font-weight: 650; +} +.story-actions a:first-child { + color: var(--blue); + border-bottom-color: var(--blue); +} .docs-layout { max-width: 1440px; margin: auto; @@ -206,6 +224,13 @@ code { top: 96px; align-self: start; } +.sidebar { + max-height: calc(100vh - 120px); + overflow-y: auto; + overscroll-behavior-y: contain; + padding: 6px; + scrollbar-gutter: stable; +} .sidebar nav, .toc { display: grid; @@ -241,6 +266,70 @@ code { color: #444950; max-width: 72ch; } +.story p, +.story li { + max-width: 68ch; +} +.story > p:first-of-type { + font-size: 19px; + line-height: 1.6; +} +.story > p a { + color: var(--blue); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} +.use-case-list { + display: grid; + margin: 36px 0; + border-block: 1px solid var(--rule); +} +.use-case-list article { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 24px; + padding: 24px 0; +} +.use-case-list article + article { + border-top: 1px solid var(--rule); +} +.use-case-list p { + margin: 0; +} +.comparison-table { + overflow-x: auto; + margin: 36px 0; +} +.comparison-table table { + min-width: 620px; +} +.comparison-table caption { + padding: 0 12px 12px; + color: var(--muted); + text-align: left; +} +.fit-grid { + display: grid; + grid-template-columns: 1fr 1fr; + margin: 36px 0 8px; + border-block: 1px solid var(--rule); +} +.fit-grid > div { + padding: 28px 28px 28px 0; +} +.fit-grid > div + div { + padding-right: 0; + padding-left: 28px; + border-left: 1px solid var(--rule); +} +.fit-grid ul { + margin: 18px 0 0; + padding-left: 20px; +} +.fit-grid li + li { + margin-top: 10px; +} .content pre { background: var(--code); color: #e9edf2; @@ -405,11 +494,35 @@ footer nav { position: static; margin-bottom: 56px; border-bottom: 1px solid var(--rule); - padding-bottom: 24px; + max-height: none; + overflow-y: visible; + overscroll-behavior-y: auto; + padding: 0 0 24px; + scrollbar-gutter: auto; } .sidebar nav { grid-template-columns: 1fr 1fr; } + .story-actions, + .use-case-list article, + .fit-grid { + grid-template-columns: 1fr; + } + .story-actions { + display: grid; + gap: 0; + } + .use-case-list article { + gap: 8px; + } + .fit-grid > div, + .fit-grid > div + div { + padding: 24px 0; + } + .fit-grid > div + div { + border-top: 1px solid var(--rule); + border-left: 0; + } .content h2 { font-size: 30px; }