diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..d47d1a5 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,37 @@ +name: preview + +on: [pull_request] + +permissions: + contents: read + +jobs: + preview: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Get Version + id: vars + run: echo "version=$(git describe --abbrev=0 --tags 2>/dev/null | sed 's/^v//' || echo '0.0.0')-pr+$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: https://registry.npmjs.com + + - name: Build NPM + run: deno task build:npm ${{steps.vars.outputs.version}} + + - name: Publish Preview Versions + run: npx pkg-pr-new publish './build/npm' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d1cec6d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,109 @@ +name: Publish + +on: + push: + tags: + - "v*" + +permissions: + contents: read + id-token: write + +jobs: + verify-jsr: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Get Version + id: vars + run: echo "version=$(echo ${{github.ref_name}} | sed 's/^v//')" >> "$GITHUB_OUTPUT" + + - name: Build JSR + run: deno task build:jsr ${{steps.vars.outputs.version}} + + - name: dry run publish + run: deno publish --dry-run --allow-dirty + + verify-npm: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Get Version + id: vars + run: echo "version=$(echo ${{github.ref_name}} | sed 's/^v//')" >> "$GITHUB_OUTPUT" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: Build NPM + run: deno task build:npm ${{steps.vars.outputs.version}} + + - name: dry run publish + run: npm publish --dry-run --tag=verify + working-directory: ./build/npm + + - name: upload build + uses: actions/upload-artifact@v4 + with: + name: npm-build + path: ./build/npm + + publish-npm: + needs: [verify-jsr, verify-npm] + runs-on: ubuntu-latest + steps: + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: https://registry.npmjs.com + + - name: download build + uses: actions/download-artifact@v4 + with: + name: npm-build + path: ./build/npm + + - name: Publish NPM + run: npm publish --access=public --tag=latest + working-directory: ./build/npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-jsr: + needs: [verify-jsr, verify-npm] + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Get Version + id: vars + run: echo "version=$(echo ${{github.ref_name}} | sed 's/^v//')" >> "$GITHUB_OUTPUT" + + - name: Build JSR + run: deno task build:jsr ${{steps.vars.outputs.version}} + + - name: Publish JSR + run: deno publish --allow-dirty --token=${{ secrets.JSR_TOKEN }} diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..9e69b40 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,74 @@ +name: Verify + +on: + push: + branches: main + pull_request: + branches: main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: format + run: deno fmt --check + + - name: lint + run: deno lint + + - name: test + run: deno task test + + jsr: + needs: test + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Build JSR + run: deno task build:jsr 0.0.0-verify.0 + + - name: dry run publish + run: deno publish --dry-run --allow-dirty + + npm: + needs: test + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: Build + run: deno task build:npm 0.0.0-verify.0 + + - name: dry run publish + run: npm publish --dry-run --tag=verify + working-directory: ./build/npm diff --git a/.gitignore b/.gitignore index fe2bd21..f1311da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /.agent-shell/ +/build/ +/**/node_modules/ hack.ts diff --git a/AGENTS.md b/AGENTS.md index 47c2313..9a39f2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,61 +2,55 @@ ## Spec-driven development -- Specs in `specs/` are the source of truth. Code conforms to specs, - not the other way around. +- Specs in `specs/` are the source of truth. Code conforms to specs, not the + other way around. -- Never add, remove, or change a public API (interfaces, operations, - context APIs) in code without first updating the relevant spec and - getting explicit approval from the user. This includes changes to - `Node`, `Tree`, `FreedomApi`, `DispatchApi`, `FocusApi`, and any - future spec'd interfaces. +- Never add, remove, or change a public API (interfaces, operations, context + APIs) in code without first updating the relevant spec and getting explicit + approval from the user. This includes changes to `Node`, `Tree`, `FreedomApi`, + `DispatchApi`, `FocusApi`, and any future spec'd interfaces. -- The workflow is: propose the spec change, wait for approval, - then implement. Do not combine spec changes with implementation - in a single step. +- The workflow is: propose the spec change, wait for approval, then implement. + Do not combine spec changes with implementation in a single step. ## Effection patterns -- Never use `sleep(0)` for synchronization. If you need to coordinate - between tasks, use `withResolvers()` from Effection. +- Never use `sleep(0)` for synchronization. If you need to coordinate between + tasks, use `withResolvers()` from Effection. -- Structured concurrency handles cleanup. Do not add explicit "alive" - guards, flags, or checks for whether a scope has been destroyed. - When a scope exits, its signals are inert and its tasks are halted. +- Structured concurrency handles cleanup. Do not add explicit "alive" guards, + flags, or checks for whether a scope has been destroyed. When a scope exits, + its signals are inert and its tasks are halted. -- Do not reimplement middleware. If the spec says `createApi`, use - `createApi` and its `around()` method. Do not maintain a parallel - list of handlers. +- Do not reimplement middleware. If the spec says `createApi`, use `createApi` + and its `around()` method. Do not maintain a parallel list of handlers. - When delegating `[Symbol.iterator]`, bind directly: - `[Symbol.iterator]: source[Symbol.iterator]` — not a wrapper - generator. + `[Symbol.iterator]: source[Symbol.iterator]` — not a wrapper generator. -- Effection methods are pre-bound. Do not use `.bind()` when - assigning them — just assign directly: `child.remove = task.halt`. +- Effection methods are pre-bound. Do not use `.bind()` when assigning them — + just assign directly: `child.remove = task.halt`. ## Naming conventions - API interfaces are named for the domain: `Freedom`, `Dispatch`. -- API constants are the interface name suffixed with `Api`: - `FreedomApi`, `DispatchApi`. +- API constants are the interface name suffixed with `Api`: `FreedomApi`, + `DispatchApi`. - Always export both the interface and the API constant. -- Effection resources use the `useX()` naming convention, not - `createX()`. For example: `useTree()`, not `createTree()`. +- Effection resources use the `useX()` naming convention, not `createX()`. For + example: `useTree()`, not `createTree()`. ## State -- Do not use module-level mutable state (counters, maps, etc.). - Scope state to the resource or tree that owns it. +- Do not use module-level mutable state (counters, maps, etc.). Scope state to + the resource or tree that owns it. ## Testing -- Tests should not need to know internal IDs. If a test needs a - node reference, get it from the tree structure or from the return - value of `append`. -- Use `node.eval()` to run operations in a node's scope from tests. - Do not rely on component bodies having run by the time `createTree` - returns. +- Tests should not need to know internal IDs. If a test needs a node reference, + get it from the tree structure or from the return value of `append`. +- Use `node.eval()` to run operations in a node's scope from tests. Do not rely + on component bodies having run by the time `createTree` returns. - Each test file tests exactly one spec. `freedom.test.ts` tests - `freedom-spec.md`. `focus.test.ts` tests `freedom-focus-spec.md`. - Do not put tests for one spec into another spec's test file. + `freedom-spec.md`. `focus.test.ts` tests `freedom-focus-spec.md`. Do not put + tests for one spec into another spec's test file. diff --git a/README.md b/README.md index dc44e64..73fd998 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # Freedom -A general-purpose abstract component tree built on [Effection](https://frontside.com/effection) structured concurrency. Freedom ("free DOM") maintains a tree of long-lived, stateful component nodes where each node is an Effection resource with a scope, a JSON-like property bag, and ordered children. It is designed to accept a firehose of events of any type through a single synchronous `dispatch()` entry point, and emit change notifications on an output stream for renderers to consume. +A general-purpose abstract component tree built on +[Effection](https://frontside.com/effection) structured concurrency. Freedom +("free DOM") maintains a tree of long-lived, stateful component nodes where each +node is an Effection resource with a scope, a JSON-like property bag, and +ordered children. It is designed to accept a firehose of events of any type +through a single synchronous `dispatch()` entry point, and emit change +notifications on an output stream for renderers to consume. ## Specs @@ -9,11 +15,18 @@ A general-purpose abstract component tree built on [Effection](https://frontside ## Extension Modules -Freedom is extensible through extension modules — operations installed by the root component that add capabilities to the tree using Freedom's context APIs. Extensions use middleware interception, scoped evaluation, and the property bag to layer behavior without modifying the core. +Freedom is extensible through extension modules — operations installed by the +root component that add capabilities to the tree using Freedom's context APIs. +Extensions use middleware interception, scoped evaluation, and the property bag +to layer behavior without modifying the core. ### Focus -The [Focus extension](specs/freedom-focus-spec.md) tracks which node in the tree is currently receiving input. Focus state is observable as a regular node property (`node.props.focused`), and the focus chain is derived from the tree by depth-first traversal. See the [research summary](research/focus.md) for background on focus management across UI paradigms. +The [Focus extension](specs/freedom-focus-spec.md) tracks which node in the tree +is currently receiving input. Focus state is observable as a regular node +property (`node.props.focused`), and the focus chain is derived from the tree by +depth-first traversal. See the [research summary](research/focus.md) for +background on focus management across UI paradigms. Install focus in the root component: diff --git a/deno.json b/deno.json index 9f195a8..e7ce885 100644 --- a/deno.json +++ b/deno.json @@ -5,10 +5,13 @@ "publish": { "include": ["lib", "mod.ts", "README.md"] }, "lock": false, "tasks": { - "test": "deno test" + "test": "deno test", + "build:npm": "deno run -A tasks/build-npm.ts", + "build:jsr": "deno run -A tasks/build-jsr.ts" }, "lint": { "rules": { "exclude": ["prefer-const", "require-yield"] }, + "exclude": ["build"], "plugins": ["lint/prefer-let.ts"] }, "fmt": {}, @@ -20,7 +23,8 @@ "effection": "jsr:@effection/effection@4.1.0-alpha.7", "effection/experimental": "jsr:@effection/effection@4.1.0-alpha.7/experimental", "@std/testing": "jsr:@std/testing@1", - "@std/expect": "jsr:@std/expect@1" + "@std/expect": "jsr:@std/expect@1", + "dnt": "jsr:@deno/dnt@0.42.3" }, "version": "0.0.0" } diff --git a/lib/focus.ts b/lib/focus.ts index be5a5af..c6e0fff 100644 --- a/lib/focus.ts +++ b/lib/focus.ts @@ -31,7 +31,11 @@ function focusChain(node: Node): Node[] { return result; } -function* setFocused(target: Node, value: boolean, self: NodeImpl): Operation { +function* setFocused( + target: Node, + value: boolean, + self: NodeImpl, +): Operation { if (target === self) { yield* set("focused", value); } else { diff --git a/lib/freedom.ts b/lib/freedom.ts index 6412264..2cc9d0c 100644 --- a/lib/freedom.ts +++ b/lib/freedom.ts @@ -1,13 +1,27 @@ -import { spawn, suspend, withResolvers, type Api, type Operation } from "effection"; +import { + type Api, + type Operation, + spawn, + suspend, + withResolvers, +} from "effection"; import { createApi } from "effection/experimental"; -import { createNodeData, type Component, type JsonValue, type Node } from "./types.ts"; +import { + type Component, + createNodeData, + type JsonValue, + type Node, +} from "./types.ts"; import { NodeContext, NodeImpl, spawnEvalLoop } from "./node.ts"; import { TreeContext } from "./state.ts"; import { validateJsonValue } from "./validate.ts"; -const Halt = createNodeData<() => Operation>("freedom:halt", function* () { - throw new Error("Cannot remove root node"); -}); +const Halt = createNodeData<() => Operation>( + "freedom:halt", + function* () { + throw new Error("Cannot remove root node"); + }, +); export interface Freedom { useNode(): Operation; @@ -91,11 +105,17 @@ export const FreedomApi: Api = createApi("freedom:node", { }, }); -export const useNode: typeof FreedomApi.operations.useNode = FreedomApi.operations.useNode; +export const useNode: typeof FreedomApi.operations.useNode = + FreedomApi.operations.useNode; export const get: typeof FreedomApi.operations.get = FreedomApi.operations.get; export const set: typeof FreedomApi.operations.set = FreedomApi.operations.set; -export const update: typeof FreedomApi.operations.update = FreedomApi.operations.update; -export const unset: typeof FreedomApi.operations.unset = FreedomApi.operations.unset; -export const append: typeof FreedomApi.operations.append = FreedomApi.operations.append; -export const remove: typeof FreedomApi.operations.remove = FreedomApi.operations.remove; -export const sort: typeof FreedomApi.operations.sort = FreedomApi.operations.sort; +export const update: typeof FreedomApi.operations.update = + FreedomApi.operations.update; +export const unset: typeof FreedomApi.operations.unset = + FreedomApi.operations.unset; +export const append: typeof FreedomApi.operations.append = + FreedomApi.operations.append; +export const remove: typeof FreedomApi.operations.remove = + FreedomApi.operations.remove; +export const sort: typeof FreedomApi.operations.sort = + FreedomApi.operations.sort; diff --git a/lib/mod.ts b/lib/mod.ts index b8f31cd..fe18a2d 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -24,7 +24,7 @@ export { useNode, } from "./freedom.ts"; -export { DispatchApi, type Dispatch } from "./dispatch.ts"; +export { type Dispatch, DispatchApi } from "./dispatch.ts"; export { advance, diff --git a/lib/node.ts b/lib/node.ts index 58d7e1f..a6d1df6 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -1,13 +1,14 @@ import { type Channel, - type Operation, - type Result, - type Stream, - Err, - Ok, + type Context, createChannel, createContext, + Err, + Ok, + type Operation, + type Result, spawn, + type Stream, withResolvers, } from "effection"; import type { JsonValue, Node, NodeData, NodeDataKey } from "./types.ts"; @@ -105,7 +106,9 @@ export class NodeImpl implements Node { } } -export function* spawnEvalLoop(channel: Channel): Operation { +export function* spawnEvalLoop( + channel: Channel, +): Operation { let ready = withResolvers(); yield* spawn(function* () { @@ -126,4 +129,6 @@ export function* spawnEvalLoop(channel: Channel): Operation("freedom:current-node"); +export const NodeContext: Context = createContext( + "freedom:current-node", +); diff --git a/lib/state.ts b/lib/state.ts index 71e3a00..e1a1540 100644 --- a/lib/state.ts +++ b/lib/state.ts @@ -1,4 +1,4 @@ -import { type Signal, createContext } from "effection"; +import { createContext, type Signal } from "effection"; import type { NodeImpl } from "./node.ts"; export interface TreeState { diff --git a/lib/tree.ts b/lib/tree.ts index 125980f..35755ca 100644 --- a/lib/tree.ts +++ b/lib/tree.ts @@ -1,10 +1,10 @@ import { createSignal, + type Operation, resource, spawn, suspend, withResolvers, - type Operation, } from "effection"; import type { Component, Tree } from "./types.ts"; import { NodeContext, NodeImpl, spawnEvalLoop } from "./node.ts"; diff --git a/lib/types.ts b/lib/types.ts index 7e29927..a3df876 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -15,7 +15,10 @@ export interface NodeDataKey { readonly defaultValue?: T; } -export function createNodeData(name: string, defaultValue?: T): NodeDataKey { +export function createNodeData( + name: string, + defaultValue?: T, +): NodeDataKey { return { symbol: Symbol(name), defaultValue }; } diff --git a/lib/validate.ts b/lib/validate.ts index 843b0d0..e37f9f9 100644 --- a/lib/validate.ts +++ b/lib/validate.ts @@ -13,7 +13,9 @@ export function validateJsonValue(value: unknown): asserts value is JsonValue { } return; } - if (typeof value === "string" || typeof value === "boolean" || value === null) { + if ( + typeof value === "string" || typeof value === "boolean" || value === null + ) { return; } if (typeof value === "function") { diff --git a/research/focus.md b/research/focus.md index b13f386..d5073e8 100644 --- a/research/focus.md +++ b/research/focus.md @@ -5,10 +5,17 @@ Across web, TUI, game UI, and mobile paradigms, focus management converges on: 1. **Focus is always a singleton.** Exactly one node receives input at any time. -2. **Two levels of navigation.** Nearly every system distinguishes *between groups* (Tab) from *within groups* (arrows). -3. **Scoping enables composition.** Without focus scopes, every focusable element competes in a single flat order. Scopes let subtrees manage focus independently (dialogs, menus, panels). -4. **Restoration is universally needed.** When a focus scope closes (modal dismiss, node removal), every system needs a strategy for where focus goes. Dominant pattern: remember what was focused before the scope activated. -5. **Focus lifecycle is tightly coupled to component lifecycle.** When a focused node unmounts, focus must move somewhere — this is a source of bugs in every framework. +2. **Two levels of navigation.** Nearly every system distinguishes _between + groups_ (Tab) from _within groups_ (arrows). +3. **Scoping enables composition.** Without focus scopes, every focusable + element competes in a single flat order. Scopes let subtrees manage focus + independently (dialogs, menus, panels). +4. **Restoration is universally needed.** When a focus scope closes (modal + dismiss, node removal), every system needs a strategy for where focus goes. + Dominant pattern: remember what was focused before the scope activated. +5. **Focus lifecycle is tightly coupled to component lifecycle.** When a focused + node unmounts, focus must move somewhere — this is a source of bugs in every + framework. --- @@ -16,44 +23,65 @@ Across web, TUI, game UI, and mobile paradigms, focus management converges on: ### Native Focus Model (WHATWG HTML Spec) -**Focusable Areas**: Regions that can receive keyboard input — elements with non-null tabindex, natively focusable elements (buttons, inputs, links), image map shapes, scrollable regions, and document viewports. +**Focusable Areas**: Regions that can receive keyboard input — elements with +non-null tabindex, natively focusable elements (buttons, inputs, links), image +map shapes, scrollable regions, and document viewports. **Tabindex Processing Model**: + - Null/omitted: UA determines focusability per platform conventions -- Negative integer: Programmatically focusable via `.focus()` but excluded from sequential tab navigation +- Negative integer: Programmatically focusable via `.focus()` but excluded from + sequential tab navigation - Zero: Focusable and sequentially navigable; position follows DOM tree order - Positive integer: Creates explicit ordering (discouraged by spec) -**Focus Navigation Scopes**: Each Document, shadow host, slot element, and showing popover is a scope owner. The flattened tabindex-ordered focus navigation scope is computed by recursively inlining child scope contents after their scope owners. +**Focus Navigation Scopes**: Each Document, shadow host, slot element, and +showing popover is a scope owner. The flattened tabindex-ordered focus +navigation scope is computed by recursively inlining child scope contents after +their scope owners. -**Shadow DOM**: Shadow hosts are scope owners. `delegatesFocus: true` delegates focus to the first focusable descendant. +**Shadow DOM**: Shadow hosts are scope owners. `delegatesFocus: true` delegates +focus to the first focusable descendant. -**Focus Events**: `focus`/`blur` do not bubble. `focusin`/`focusout` do bubble. Events fire along the focus chain. +**Focus Events**: `focus`/`blur` do not bubble. `focusin`/`focusout` do bubble. +Events fire along the focus chain. -**Focus Navigation Start Point** (per Sarah Higley): When a focused element is removed from the DOM, browsers maintain a "sequential focus navigation starting point" at the removed element's former position. Screen readers largely ignore this mechanism. +**Focus Navigation Start Point** (per Sarah Higley): When a focused element is +removed from the DOM, browsers maintain a "sequential focus navigation starting +point" at the removed element's former position. Screen readers largely ignore +this mechanism. ### ARIA Focus Management Patterns (W3C APG) -**Roving Tabindex**: Manages focus in composite widgets (radio groups, tablists, toolbars, trees, grids): +**Roving Tabindex**: Manages focus in composite widgets (radio groups, tablists, +toolbars, trees, grids): + 1. Set `tabindex="0"` on active element, `tabindex="-1"` on siblings 2. On arrow key: old element → `-1`, new element → `0`, call `.focus()` 3. Browser automatically scrolls newly focused element into view -**aria-activedescendant**: Keeps DOM focus on container while logically pointing to active child. Critical limitations (per Sarah Higley): +**aria-activedescendant**: Keeps DOM focus on container while logically pointing +to active child. Critical limitations (per Sarah Higley): + - Purely a screen reader construct; no effect on keyboard events or DOM focus - No automatic scroll-into-view - VoiceOver Safari macOS essentially ignores it - Mobile screen readers bypass it entirely - Best suited for comboboxes only -**Focus traps** (modals/dialogs): Intercept Tab at boundaries and wrap. Requires initial focus, wrapping Tab/Shift+Tab at boundaries, and focus restoration on close. +**Focus traps** (modals/dialogs): Intercept Tab at boundaries and wrap. Requires +initial focus, wrapping Tab/Shift+Tab at boundaries, and focus restoration on +close. ### Open UI `focusgroup` Proposal The most ambitious standardization attempt: -- Arrow-key navigation within a declared subtree, guaranteed tab stop, automatic last-focused memory + +- Arrow-key navigation within a declared subtree, guaranteed tab stop, automatic + last-focused memory - Crosses shadow DOM boundaries by default; `focusgroup=none` opts out -- **Independence principle**: Nested `focusgroup` exits ancestor's group; each element belongs to exactly one focusgroup +- **Independence principle**: Nested `focusgroup` exits ancestor's group; each + element belongs to exactly one focusgroup - Values: empty (linear), `grid`, `manual-grid`, `none` - Modifiers: `inline`/`block` (direction), `wrap`/`flow` (boundary), `no-memory` - Grid navigation: `row-wrap`, `col-wrap`, `row-flow`, `col-flow` @@ -65,18 +93,23 @@ The most ambitious standardization attempt: ### React Aria FocusScope (Adobe) Three declarative props: + - **`contain`**: Prevents focus from leaving scope; Tab wraps at boundaries -- **`restoreFocus`**: Stores `document.activeElement` on mount; restores on unmount +- **`restoreFocus`**: Stores `document.activeElement` on mount; restores on + unmount - **`autoFocus`**: Focuses first focusable descendant on mount -`useFocusManager()` hook: `focusNext({wrap})`, `focusPrevious({wrap})`, `focusFirst()`, `focusLast()`. +`useFocusManager()` hook: `focusNext({wrap})`, `focusPrevious({wrap})`, +`focusFirst()`, `focusLast()`. Implementation uses sentinel nodes as boundary markers in the DOM. ### react-focus-lock (theKashey) -- **No keyboard event interception**: Watches focus behavior via `focus` events, not `keydown` -- `data-focus-lock=[group-name]` for scattered focus groups/shards (critical for portals) +- **No keyboard event interception**: Watches focus behavior via `focus` events, + not `keydown` +- `data-focus-lock=[group-name]` for scattered focus groups/shards (critical for + portals) - Core `focus-lock` package is 1.5kb ### Radix UI FocusScope @@ -84,7 +117,8 @@ Implementation uses sentinel nodes as boundary markers in the DOM. - Tab interception only at boundaries (native browser handles mid-scope) - `TreeWalker`-based element discovery - Container gets `tabIndex={-1}` as fallback -- Focus scope stack: parent paused when child activates, resumes on child unmount +- Focus scope stack: parent paused when child activates, resumes on child + unmount ### React Issue #16009 — Rethinking Focus @@ -98,10 +132,14 @@ Implementation uses sentinel nodes as boundary markers in the DOM. ### Textual (Python) — Most Complete TUI System -- **Opt-in**: `can_focus` (default `False`) and `can_focus_children` (default `True`) -- **Focus chain**: Computed ordered list of all focusable widgets; Tab/Shift+Tab traverse it -- **`can_focus_children=False`**: Skips children during Tab navigation (primitive containment) -- **Programmatic**: `focus_next()`, `focus_previous()`, `widget.focus()`, `set_focus()` +- **Opt-in**: `can_focus` (default `False`) and `can_focus_children` (default + `True`) +- **Focus chain**: Computed ordered list of all focusable widgets; Tab/Shift+Tab + traverse it +- **`can_focus_children=False`**: Skips children during Tab navigation + (primitive containment) +- **Programmatic**: `focus_next()`, `focus_previous()`, `widget.focus()`, + `set_focus()` - **Events**: `Focus`, `Blur`, `DescendantFocus`, `DescendantBlur` - **CSS**: `:focus` pseudo-class, `has_focus_within` property - **Scroll**: `focus(scroll_visible=True)` @@ -121,11 +159,14 @@ Implementation uses sentinel nodes as boundary markers in the DOM. ### Ratatui (Rust) -- No built-in focus; community crates: `rat-focus` (FocusFlag + FocusBuilder), `ratatui-interact` (FocusManager), `focusable` (derive macro) +- No built-in focus; community crates: `rat-focus` (FocusFlag + FocusBuilder), + `ratatui-interact` (FocusManager), `focusable` (derive macro) ### Common TUI Pattern -All converge on: flat ordered list of focusable widgets, Tab/Shift+Tab navigation. None have built-in focus scoping or containment. Focus ordering is explicit (manual index) or render/mount order. +All converge on: flat ordered list of focusable widgets, Tab/Shift+Tab +navigation. None have built-in focus scoping or containment. Focus ordering is +explicit (manual index) or render/mount order. --- @@ -134,6 +175,7 @@ All converge on: flat ordered list of focusable widgets, Tab/Shift+Tab navigatio ### Unity UI Navigation Four modes per `Selectable`: + - **Automatic**: Spatial proximity-based neighbor computation - **Explicit**: Manual `selectOnUp/Down/Left/Right` per element - **Horizontal/Vertical**: Navigation restricted to one axis @@ -141,57 +183,64 @@ Four modes per `Selectable`: ### ImGui Navigation -Directional algorithm: collect navigable items, score by distance + direction alignment, select lowest score. Two NavLayers (Main, Menu). Window-level focus with cross-window navigation support. +Directional algorithm: collect navigable items, score by distance + direction +alignment, select lowest score. Two NavLayers (Main, Menu). Window-level focus +with cross-window navigation support. ### Microsoft WinUI/UWP 2D Navigation -**XYFocusKeyboardNavigation**: `Auto`, `Enabled`, `Disabled` — creates directional areas. +**XYFocusKeyboardNavigation**: `Auto`, `Enabled`, `Disabled` — creates +directional areas. Three strategies: + 1. **Projection**: Projects focused element's edge in navigation direction -2. **NavigationDirectionDistance**: Extends bounding rect, finds closest to navigation axis +2. **NavigationDirectionDistance**: Extends bounding rect, finds closest to + navigation axis 3. **RectilinearDistance**: Manhattan distance scoring -**TabFocusNavigation**: `Local` (subtree tab indexes), `Once` (group tab stop), `Cycle` (wrap within container). +**TabFocusNavigation**: `Local` (subtree tab indexes), `Once` (group tab stop), +`Cycle` (wrap within container). ### SwiftUI Focus System - `@FocusState`: Property wrapper tracking focus (Boolean or Hashable enum) - `.focused($state, equals:)`: Binds view focus to state variable - `.defaultFocus()`: Initial focus target when scope appears -- `.focusSection()`: Groups views for directional navigation without making section focusable +- `.focusSection()`: Groups views for directional navigation without making + section focusable --- ## Focus Ordering Strategies -| Strategy | Description | Used By | -|----------|-------------|---------| -| DOM/tree order | Source order in document/tree | HTML default, Ink, Textual | -| Explicit tab order | Numeric index overrides tree order | HTML `tabindex`, WinUI | -| Roving within group | Arrows within group, Tab between groups | ARIA composites, `focusgroup` | -| Spatial/directional | Nearest element in pressed direction | WinUI, Unity, ImGui, tvOS | -| Scope-local | Tab order computed within scope | HTML focus scopes, WinUI `Local` | -| Cycle/wrap | Focus wraps at boundaries | WinUI `Cycle`, FocusScope `contain` | -| Once (group stop) | Container is single Tab stop; arrows internal | WinUI `Once`, ARIA composites | -| Render order | Component render/mount order | Ink, Bubble Tea | -| Custom/programmatic | Application logic determines order | All frameworks | +| Strategy | Description | Used By | +| ------------------- | --------------------------------------------- | ----------------------------------- | +| DOM/tree order | Source order in document/tree | HTML default, Ink, Textual | +| Explicit tab order | Numeric index overrides tree order | HTML `tabindex`, WinUI | +| Roving within group | Arrows within group, Tab between groups | ARIA composites, `focusgroup` | +| Spatial/directional | Nearest element in pressed direction | WinUI, Unity, ImGui, tvOS | +| Scope-local | Tab order computed within scope | HTML focus scopes, WinUI `Local` | +| Cycle/wrap | Focus wraps at boundaries | WinUI `Cycle`, FocusScope `contain` | +| Once (group stop) | Container is single Tab stop; arrows internal | WinUI `Once`, ARIA composites | +| Render order | Component render/mount order | Ink, Bubble Tea | +| Custom/programmatic | Application logic determines order | All frameworks | --- ## Focus Scoping Comparison -| System | Scoping Mechanism | Containment | Restoration | Nesting | -|--------|-------------------|-------------|-------------|---------| -| DOM/HTML | Focus navigation scopes | No built-in trap | No built-in | Hierarchical via shadow DOM | -| Open UI `focusgroup` | `focusgroup` attribute | Independent groups | Last-focused memory | Nested auto-exits parent | -| React Aria | `` | `contain` prop | `restoreFocus` prop | Parent paused | -| Radix | `` | `loop` prop | Stores `activeElement` | Focus scope stack | -| react-focus-lock | `data-focus-lock` | Focus observation | `returnFocus` prop | Focus shards | -| WinUI/UWP | `XYFocusKeyboardNavigation` | `Cycle` mode | No built-in | Inheritance | -| SwiftUI | `@FocusState` + sections | Sections guide direction | `.defaultFocus()` | Nestable | -| Textual | `can_focus_children=False` | Prevents Tab entry | No built-in | Widget tree | -| ImGui | Window flags, modals | Modal windows | No built-in | NavLayer | +| System | Scoping Mechanism | Containment | Restoration | Nesting | +| -------------------- | --------------------------- | ------------------------ | ---------------------- | --------------------------- | +| DOM/HTML | Focus navigation scopes | No built-in trap | No built-in | Hierarchical via shadow DOM | +| Open UI `focusgroup` | `focusgroup` attribute | Independent groups | Last-focused memory | Nested auto-exits parent | +| React Aria | `` | `contain` prop | `restoreFocus` prop | Parent paused | +| Radix | `` | `loop` prop | Stores `activeElement` | Focus scope stack | +| react-focus-lock | `data-focus-lock` | Focus observation | `returnFocus` prop | Focus shards | +| WinUI/UWP | `XYFocusKeyboardNavigation` | `Cycle` mode | No built-in | Inheritance | +| SwiftUI | `@FocusState` + sections | Sections guide direction | `.defaultFocus()` | Nestable | +| Textual | `can_focus_children=False` | Prevents Tab entry | No built-in | Widget tree | +| ImGui | Window flags, modals | Modal windows | No built-in | NavLayer | --- @@ -199,7 +248,8 @@ Three strategies: ### Focused Component Unmount -- **DOM**: `document.activeElement` falls back to ``; navigation start point remembered +- **DOM**: `document.activeElement` falls back to ``; navigation start + point remembered - **React**: `onBlur` does not fire on parent when React unmounts focused child - **React Aria**: `restoreFocus` stores previous element, restores on unmount - **Radix**: Stores `activeElement` before activation, restores on unmount @@ -207,9 +257,12 @@ Three strategies: ### Focus Side Effects -- **Scroll into view**: Browser auto-scrolls on `.focus()` and roving tabindex; NOT on `aria-activedescendant` -- **Screen reader**: Focus changes trigger announcement of focused element's name and role -- **Focus visible**: `:focus-visible` distinguishes keyboard from mouse/touch focus +- **Scroll into view**: Browser auto-scrolls on `.focus()` and roving tabindex; + NOT on `aria-activedescendant` +- **Screen reader**: Focus changes trigger announcement of focused element's + name and role +- **Focus visible**: `:focus-visible` distinguishes keyboard from mouse/touch + focus --- diff --git a/specs/freedom-focus-spec.md b/specs/freedom-focus-spec.md index e2236c8..fd5831b 100644 --- a/specs/freedom-focus-spec.md +++ b/specs/freedom-focus-spec.md @@ -8,16 +8,14 @@ ## 1. Purpose -Focus tracks which node in the tree is currently receiving -input. At any point in time, exactly one node is focused. -The focus system provides structured, linear navigation -through focusable nodes and exposes focus state as an +Focus tracks which node in the tree is currently receiving input. At any point +in time, exactly one node is focused. The focus system provides structured, +linear navigation through focusable nodes and exposes focus state as an observable node property. -Focus is an extension to Freedom, not a core primitive. It -is installed by the root component and built entirely on -Freedom's context APIs — property operations, middleware -interception, and scoped evaluation. +Focus is an extension to Freedom, not a core primitive. It is installed by the +root component and built entirely on Freedom's context APIs — property +operations, middleware interception, and scoped evaluation. --- @@ -25,50 +23,43 @@ interception, and scoped evaluation. ### 2.1 Minimal and linear -This specification covers linear focus navigation only: -forward, backward, and explicit. Directional navigation -(up/down/left/right), focus groups, focus trapping, and -focus restoration are deferred to future extensions. +This specification covers linear focus navigation only: forward, backward, and +explicit. Directional navigation (up/down/left/right), focus groups, focus +trapping, and focus restoration are deferred to future extensions. ### 2.2 Opt-in focusability -Nodes are not focusable by default. A node becomes focusable -by calling `yield* focusable()` in its component body. This -sets the `focused` property to `false`, adding the node to -the focus chain without granting it focus. +Nodes are not focusable by default. A node becomes focusable by calling +`yield* focusable()` in its component body. This sets the `focused` property to +`false`, adding the node to the focus chain without granting it focus. ### 2.3 Observable via properties -Focus state is a regular node property: `node.props.focused`. -Renderers observe focus the same way they observe any other -property — by reading props after a notification. No -special subscription mechanism is needed. +Focus state is a regular node property: `node.props.focused`. Renderers observe +focus the same way they observe any other property — by reading props after a +notification. No special subscription mechanism is needed. ### 2.4 Built on Freedom APIs -All focus operations use Freedom's context APIs. Focus -movement uses `node.eval()` to run `set` and `unset` in -the correct node scopes. Focus cleanup uses middleware on -`remove`. No internal state is accessed directly. +All focus operations use Freedom's context APIs. Focus movement uses +`node.eval()` to run `set` and `unset` in the correct node scopes. Focus cleanup +uses middleware on `remove`. No internal state is accessed directly. --- ## 3. Terminology -**Focusable node.** A node whose property bag contains the -key `"focused"`. The value is `true` (this node has focus) -or `false` (this node can receive focus but does not -currently have it). A node without a `"focused"` property -is not focusable. +**Focusable node.** A node whose property bag contains the key `"focused"`. The +value is `true` (this node has focus) or `false` (this node can receive focus +but does not currently have it). A node without a `"focused"` property is not +focusable. -**Focused node.** The unique node whose `focused` property -is `true`. There is always exactly one focused node when -the focus system is installed. +**Focused node.** The unique node whose `focused` property is `true`. There is +always exactly one focused node when the focus system is installed. -**Focus chain.** The ordered sequence of all focusable nodes -in the tree, computed by depth-first traversal. The focus -chain determines the order in which `advance` and `retreat` -move focus. The chain is computed on demand — it is not +**Focus chain.** The ordered sequence of all focusable nodes in the tree, +computed by depth-first traversal. The focus chain determines the order in which +`advance` and `retreat` move focus. The chain is computed on demand — it is not stored. --- @@ -93,87 +84,75 @@ createApi("freedom:focus", { **focusable** -F1. `focusable()` makes the current node focusable by - setting its `focused` property to `false` via - `yield* set("focused", false)`. +F1. `focusable()` makes the current node focusable by setting its `focused` +property to `false` via `yield* set("focused", false)`. -F2. If the current node is already focusable (the - `focused` property already exists), `focusable()` is - a no-op. +F2. If the current node is already focusable (the `focused` property already +exists), `focusable()` is a no-op. -F3. `focusable()` is a `createApi` operation and can be - intercepted by middleware. A parent MAY install - middleware on `focusable` to prevent a child from - becoming focusable. +F3. `focusable()` is a `createApi` operation and can be intercepted by +middleware. A parent MAY install middleware on `focusable` to prevent a child +from becoming focusable. **advance** -F4. `advance()` moves focus to the next focusable node in - the focus chain (§5). +F4. `advance()` moves focus to the next focusable node in the focus chain (§5). -F5. If the currently focused node is the last in the focus - chain, `advance()` wraps to the first focusable node. +F5. If the currently focused node is the last in the focus chain, `advance()` +wraps to the first focusable node. -F6. Focus movement sets the old node's `focused` property - to `false` and the new node's `focused` property to - `true`, using `node.eval()` to run property operations - in each node's scope (§6.2). +F6. Focus movement sets the old node's `focused` property to `false` and the new +node's `focused` property to `true`, using `node.eval()` to run property +operations in each node's scope (§6.2). -F7. If the focus chain contains only one focusable node - (including root), `advance()` is a no-op. +F7. If the focus chain contains only one focusable node (including root), +`advance()` is a no-op. **retreat** -F8. `retreat()` moves focus to the previous focusable node - in the focus chain (§5). +F8. `retreat()` moves focus to the previous focusable node in the focus chain +(§5). -F9. If the currently focused node is the first in the focus - chain, `retreat()` wraps to the last focusable node. +F9. If the currently focused node is the first in the focus chain, `retreat()` +wraps to the last focusable node. -F10. Focus movement follows the same mechanism as `advance` - (F6). +F10. Focus movement follows the same mechanism as `advance` (F6). -F11. If the focus chain contains only one focusable node, - `retreat()` is a no-op. +F11. If the focus chain contains only one focusable node, `retreat()` is a +no-op. **focus** F12. `focus(node)` sets focus to the given node explicitly. -F13. The target node MUST be focusable (its `focused` - property MUST exist). If the target is not focusable, - `focus` MUST raise an error. +F13. The target node MUST be focusable (its `focused` property MUST exist). If +the target is not focusable, `focus` MUST raise an error. -F14. Focus movement follows the same mechanism as `advance` - (F6). +F14. Focus movement follows the same mechanism as `advance` (F6). -F15. If the target node is already focused, `focus()` is - a no-op. +F15. If the target node is already focused, `focus()` is a no-op. **current** -F16. `current()` returns the currently focused node — the - node whose `focused` property is `true`. +F16. `current()` returns the currently focused node — the node whose `focused` +property is `true`. -F17. `current()` always returns a `Node`. It never returns - `undefined`, because the root node is always focusable - and the focus system guarantees exactly one focused - node exists (§7). +F17. `current()` always returns a `Node`. It never returns `undefined`, because +the root node is always focusable and the focus system guarantees exactly one +focused node exists (§7). -F18. `current()` is a `createApi` operation and can be - intercepted by middleware. This enables future - extensions (e.g., focus scoping) to override which - node is considered "current" within a subtree. +F18. `current()` is a `createApi` operation and can be intercepted by +middleware. This enables future extensions (e.g., focus scoping) to override +which node is considered "current" within a subtree. ### 4.3 Middleware Interception -F19. Because `focusable`, `advance`, `retreat`, `focus`, - and `current` are `createApi` operations, they can be - intercepted by middleware installed in ancestor scopes. +F19. Because `focusable`, `advance`, `retreat`, `focus`, and `current` are +`createApi` operations, they can be intercepted by middleware installed in +ancestor scopes. -F20. A parent component MAY install middleware on - `freedom:focus` to redirect focus, prevent focus - changes, or add side effects (e.g., scroll-into-view). +F20. A parent component MAY install middleware on `freedom:focus` to redirect +focus, prevent focus changes, or add side effects (e.g., scroll-into-view). --- @@ -181,38 +160,32 @@ F20. A parent component MAY install middleware on ### 5.1 Computation -FC1. The focus chain is the ordered sequence of all focusable - nodes in the tree, determined by a depth-first traversal - of the tree starting from the root. +FC1. The focus chain is the ordered sequence of all focusable nodes in the tree, +determined by a depth-first traversal of the tree starting from the root. -FC2. A node is included in the focus chain if and only if - its property bag contains the key `"focused"` (with - value `true` or `false`). +FC2. A node is included in the focus chain if and only if its property bag +contains the key `"focused"` (with value `true` or `false`). -FC3. The traversal visits children in their iteration order - (`node.children`), which respects any active sort - function (§5.6 of the Freedom spec). This means a - parent's sort function affects focus order. +FC3. The traversal visits children in their iteration order (`node.children`), +which respects any active sort function (§5.6 of the Freedom spec). This means a +parent's sort function affects focus order. -FC4. The focus chain is computed on demand — when `advance`, - `retreat`, or `current` needs it. It is not stored or - cached. This ensures the chain is always consistent - with the current tree state. +FC4. The focus chain is computed on demand — when `advance`, `retreat`, or +`current` needs it. It is not stored or cached. This ensures the chain is always +consistent with the current tree state. ### 5.2 Dynamic behavior -FC5. The focus chain changes dynamically as nodes become - focusable (`focusable()`), are removed (`remove`), - or are reordered (sort function changes). +FC5. The focus chain changes dynamically as nodes become focusable +(`focusable()`), are removed (`remove`), or are reordered (sort function +changes). -FC6. Adding a new focusable node does not move focus. The - new node joins the chain at its tree-order position - with `focused: false`. +FC6. Adding a new focusable node does not move focus. The new node joins the +chain at its tree-order position with `focused: false`. -FC7. Changing a parent's sort function may reorder the focus - chain. Focus does not move — the focused node retains - `focused: true` regardless of its new position in the - chain. +FC7. Changing a parent's sort function may reorder the focus chain. Focus does +not move — the focused node retains `focused: true` regardless of its new +position in the chain. --- @@ -220,123 +193,105 @@ FC7. Changing a parent's sort function may reorder the focus ### 6.1 Installation -FL1. Focus is installed by calling `yield* useFocus()` in - the root component: +FL1. Focus is installed by calling `yield* useFocus()` in the root component: - ```ts - function* app(): Operation { - yield* useFocus(); - // ... rest of app - } - ``` + ```ts + function* app(): Operation { + yield* useFocus(); + // ... rest of app + } + ``` -FL2. `useFocus()` performs two actions: - 1. Sets `focused: true` on the root node, making it - the initial focus target. - 2. Installs middleware on `freedom:node`'s `remove` - operation to handle focused node removal (§6.3). +FL2. `useFocus()` performs two actions: 1. Sets `focused: true` on the root +node, making it the initial focus target. 2. Installs middleware on +`freedom:node`'s `remove` operation to handle focused node removal (§6.3). -FL3. `useFocus()` is an Effection resource operation. It - runs within the root node's scope, so its middleware - is visible to all descendants. +FL3. `useFocus()` is an Effection resource operation. It runs within the root +node's scope, so its middleware is visible to all descendants. ### 6.2 Focus movement -FL4. When focus moves from node A to node B, the focus - system executes: +FL4. When focus moves from node A to node B, the focus system executes: - ```ts - yield* oldNode.eval(() => set("focused", false)); - yield* newNode.eval(() => set("focused", true)); - ``` + ```ts + yield* oldNode.eval(() => set("focused", false)); + yield* newNode.eval(() => set("focused", true)); + ``` -FL5. Both property changes go through the `freedom:node` - context API. Middleware on `set` sees focus changes - and can react to or redirect them. +FL5. Both property changes go through the `freedom:node` context API. Middleware +on `set` sees focus changes and can react to or redirect them. -FL6. Both property changes trigger `markDirty()` via - Freedom's existing notification middleware. The - changes coalesce into a single notification if they - occur within the same dispatch cycle. +FL6. Both property changes trigger `markDirty()` via Freedom's existing +notification middleware. The changes coalesce into a single notification if they +occur within the same dispatch cycle. ### 6.3 Focused node removal -FL7. `useFocus()` installs middleware on the `remove` - context API operation that advances focus before a - focused node is destroyed: - - ```ts - yield* FreedomApi.around({ - *remove([node], next) { - let focused = yield* node.eval(() => get("focused")); - if (focused === true) { - yield* FocusApi.operations.advance(); - } - yield* next(node); - }, - }); - ``` - -FL8. If the removed node is the only focusable node - (i.e., it is the root), `advance()` is a no-op (F7) - and focus remains on root. This case should not arise - in practice because `remove` on the root is an error - (C-rm3). - -FL9. Focus is advanced before `next(node)` is called, so - the focus chain is walked while the node is still in - the tree. After `next(node)` completes, the node's - scope is destroyed and it leaves the focus chain - naturally (its props, including `focused`, are gone). +FL7. `useFocus()` installs middleware on the `remove` context API operation that +advances focus before a focused node is destroyed: + + ```ts + yield* FreedomApi.around({ + *remove([node], next) { + let focused = yield* node.eval(() => get("focused")); + if (focused === true) { + yield* FocusApi.operations.advance(); + } + yield* next(node); + }, + }); + ``` + +FL8. If the removed node is the only focusable node (i.e., it is the root), +`advance()` is a no-op (F7) and focus remains on root. This case should not +arise in practice because `remove` on the root is an error (C-rm3). + +FL9. Focus is advanced before `next(node)` is called, so the focus chain is +walked while the node is still in the tree. After `next(node)` completes, the +node's scope is destroyed and it leaves the focus chain naturally (its props, +including `focused`, are gone). --- ## 7. Interaction with Notification -FN1. Focus changes are property changes. They flow through - Freedom's existing notification system — no special - notification mechanism is needed. +FN1. Focus changes are property changes. They flow through Freedom's existing +notification system — no special notification mechanism is needed. -FN2. When focus moves during a dispatch cycle (e.g., in - response to a keypress), the focus property changes - coalesce with other property changes into a single - notification. +FN2. When focus moves during a dispatch cycle (e.g., in response to a keypress), +the focus property changes coalesce with other property changes into a single +notification. -FN3. When focus moves outside a dispatch cycle (e.g., - during component initialization via `useFocus()`), - the property change emits its own notification per - Freedom's existing rules (T11). +FN3. When focus moves outside a dispatch cycle (e.g., during component +initialization via `useFocus()`), the property change emits its own notification +per Freedom's existing rules (T11). -FN4. Renderers observe focus by reading - `node.props.focused` after a notification, the same - way they read any other property. +FN4. Renderers observe focus by reading `node.props.focused` after a +notification, the same way they read any other property. --- ## 8. Invariants -FI1. **Singleton focus.** At most one node in the tree has - `focused: true` at any time. When the focus system is - installed, exactly one node has `focused: true`. +FI1. **Singleton focus.** At most one node in the tree has `focused: true` at +any time. When the focus system is installed, exactly one node has +`focused: true`. -FI2. **Focus chain consistency.** The focus chain is always - derivable from the current tree state by depth-first - traversal. No external data structure is needed. +FI2. **Focus chain consistency.** The focus chain is always derivable from the +current tree state by depth-first traversal. No external data structure is +needed. -FI3. **Root always focusable.** After `useFocus()` is - called, the root node always has a `focused` property - (`true` or `false`). Root is always in the focus - chain and serves as the last-resort focus target. +FI3. **Root always focusable.** After `useFocus()` is called, the root node +always has a `focused` property (`true` or `false`). Root is always in the focus +chain and serves as the last-resort focus target. -FI4. **Cleanup on removal.** When a focused node is removed, - focus advances to the next node in the chain before - the node is destroyed. This is guaranteed by - middleware on `remove`. +FI4. **Cleanup on removal.** When a focused node is removed, focus advances to +the next node in the chain before the node is destroyed. This is guaranteed by +middleware on `remove`. -FI5. **API consistency.** All focus operations go through - `createApi` and are middleware-interceptable. Focus - movement uses `node.eval()` and the `freedom:node` - context API for property changes. +FI5. **API consistency.** All focus operations go through `createApi` and are +middleware-interceptable. Focus movement uses `node.eval()` and the +`freedom:node` context API for property changes. --- @@ -346,42 +301,37 @@ The following are explicitly out of scope for this version. ### 9.1 Focus Trapping and Locking -A focus trap prevents focus from leaving a subtree (e.g., -a modal dialog). A focus lock prevents focus from changing -at all. Both require middleware on `advance`, `retreat`, -and `focus` that constrains movement. The current API is -designed to support this via middleware without changes to -the core focus operations. +A focus trap prevents focus from leaving a subtree (e.g., a modal dialog). A +focus lock prevents focus from changing at all. Both require middleware on +`advance`, `retreat`, and `focus` that constrains movement. The current API is +designed to support this via middleware without changes to the core focus +operations. ### 9.2 Focus Groups -Focus groups partition the focus chain into segments -navigated independently — arrow keys move within a group, -Tab moves between groups. This requires a second level of -navigation beyond the linear `advance`/`retreat` model. +Focus groups partition the focus chain into segments navigated independently — +arrow keys move within a group, Tab moves between groups. This requires a second +level of navigation beyond the linear `advance`/`retreat` model. ### 9.3 Focus Restoration -Focus restoration remembers the previously focused node -when entering a focus scope (e.g., opening a modal) and -restores it when the scope exits. This interacts with -focus trapping and the component lifecycle. +Focus restoration remembers the previously focused node when entering a focus +scope (e.g., opening a modal) and restores it when the scope exits. This +interacts with focus trapping and the component lifecycle. ### 9.4 Directional Navigation -Spatial or directional navigation (up/down/left/right) -requires a layout-aware focus algorithm that scores -candidate nodes by position and direction. This is -independent of linear navigation and may require -renderer-specific information (e.g., bounding rectangles). +Spatial or directional navigation (up/down/left/right) requires a layout-aware +focus algorithm that scores candidate nodes by position and direction. This is +independent of linear navigation and may require renderer-specific information +(e.g., bounding rectangles). --- ## 10. Implementation Files -**`lib/focus.ts`** — `createApi("freedom:focus", ...)`. -`focusable`, `advance`, `retreat`, `focus`, `current` -operations. `useFocus()` installation resource. +**`lib/focus.ts`** — `createApi("freedom:focus", ...)`. `focusable`, `advance`, +`retreat`, `focus`, `current` operations. `useFocus()` installation resource. **`lib/mod.ts`** — Re-exports focus API and operations. @@ -391,6 +341,6 @@ operations. `useFocus()` installation resource. Freedom Focus depends on: -- Freedom Specification 0.1 — `freedom:node` context API - (`get`, `set`, `remove`), `Node`, `Tree`, `node.eval()` +- Freedom Specification 0.1 — `freedom:node` context API (`get`, `set`, + `remove`), `Node`, `Tree`, `node.eval()` - Effection 4.1-alpha — `createApi`, `Operation`, `resource` diff --git a/specs/freedom-focus-test-plan.md b/specs/freedom-focus-test-plan.md index 378cb03..fd8f11b 100644 --- a/specs/freedom-focus-test-plan.md +++ b/specs/freedom-focus-test-plan.md @@ -8,9 +8,8 @@ ### 1.1 What This Plan Covers -This test plan defines conformance criteria for the Freedom -Focus extension as specified in the Freedom Focus -Specification. It covers: +This test plan defines conformance criteria for the Freedom Focus extension as +specified in the Freedom Focus Specification. It covers: - Installation via `useFocus()` (§6.1) - `focusable()` operation (§4.2) @@ -37,8 +36,7 @@ It also covers the core spec additions required by focus: ### 1.3 Tiers -**Core:** Tests that every conforming implementation MUST -pass. +**Core:** Tests that every conforming implementation MUST pass. **Extended:** Tests for edge cases and robustness. @@ -48,18 +46,15 @@ pass. ### 2.1 Core: get operation -GA1. `set("k", 42)` then `get("k")` — returns `42`. -GA2. `get("missing")` — returns `undefined`. -GA3. `get` middleware can intercept reads. +GA1. `set("k", 42)` then `get("k")` — returns `42`. GA2. `get("missing")` — +returns `undefined`. GA3. `get` middleware can intercept reads. ### 2.2 Core: remove operation -RA1. `remove(child)` destroys the child node. The child no - longer appears in parent's `children`. -RA2. `remove(root)` raises an error. -RA3. `remove` middleware can intercept removal. -RA4. `remove` middleware runs before teardown — the node is - still in the tree when middleware executes. +RA1. `remove(child)` destroys the child node. The child no longer appears in +parent's `children`. RA2. `remove(root)` raises an error. RA3. `remove` +middleware can intercept removal. RA4. `remove` middleware runs before teardown +— the node is still in the tree when middleware executes. --- @@ -67,9 +62,8 @@ RA4. `remove` middleware runs before teardown — the node is ### 3.1 Core: useFocus -FI1. After `useFocus()`, root has `focused: true`. -FI2. After `useFocus()`, root is in the focus chain. -FI3. `current()` returns root after installation. +FI1. After `useFocus()`, root has `focused: true`. FI2. After `useFocus()`, root +is in the focus chain. FI3. `current()` returns root after installation. --- @@ -77,13 +71,10 @@ FI3. `current()` returns root after installation. ### 4.1 Core: Opt-in -FF1. `focusable()` sets `focused: false` on the current - node. -FF2. After `focusable()`, the node appears in the focus - chain. -FF3. `focusable()` on an already-focusable node is a no-op. -FF4. A node that never calls `focusable()` is not in the - focus chain. +FF1. `focusable()` sets `focused: false` on the current node. FF2. After +`focusable()`, the node appears in the focus chain. FF3. `focusable()` on an +already-focusable node is a no-op. FF4. A node that never calls `focusable()` is +not in the focus chain. --- @@ -91,19 +82,15 @@ FF4. A node that never calls `focusable()` is not in the ### 5.1 Core: Depth-First Order -FC1. Root with children A, B, C — all focusable. Chain - order is root, A, B, C. -FC2. Root with child A (focusable), A has child A1 - (focusable), root has child B (focusable). Chain - order is root, A, A1, B. -FC3. Non-focusable nodes are skipped. Root (focusable), - A (not focusable), B (focusable). Chain is root, B. +FC1. Root with children A, B, C — all focusable. Chain order is root, A, B, C. +FC2. Root with child A (focusable), A has child A1 (focusable), root has child B +(focusable). Chain order is root, A, A1, B. FC3. Non-focusable nodes are +skipped. Root (focusable), A (not focusable), B (focusable). Chain is root, B. ### 5.2 Core: Sort Function Interaction -FC4. Parent has sort function reversing children. Children - A, B, C appended in order but sorted as C, B, A. - Focus chain reflects sorted order. +FC4. Parent has sort function reversing children. Children A, B, C appended in +order but sorted as C, B, A. Focus chain reflects sorted order. --- @@ -111,21 +98,18 @@ FC4. Parent has sort function reversing children. Children ### 6.1 Core: Forward Navigation -FA1. Focus on root, advance → focus moves to first - focusable child. -FA2. Focus on child A, advance → focus moves to child B. -FA3. Old node gets `focused: false`, new node gets - `focused: true`. +FA1. Focus on root, advance → focus moves to first focusable child. FA2. Focus +on child A, advance → focus moves to child B. FA3. Old node gets +`focused: false`, new node gets `focused: true`. ### 6.2 Core: Wrapping -FA4. Focus on last focusable node, advance → wraps to - first focusable node (root). +FA4. Focus on last focusable node, advance → wraps to first focusable node +(root). ### 6.3 Core: Single Node -FA5. Only one focusable node (root). `advance()` is a - no-op. Root stays focused. +FA5. Only one focusable node (root). `advance()` is a no-op. Root stays focused. --- @@ -133,9 +117,8 @@ FA5. Only one focusable node (root). `advance()` is a ### 7.1 Core: Backward Navigation -FR1. Focus on child B, retreat → focus moves to child A. -FR2. Focus on first focusable child, retreat → wraps to - last focusable node. +FR1. Focus on child B, retreat → focus moves to child A. FR2. Focus on first +focusable child, retreat → wraps to last focusable node. ### 7.2 Core: Single Node @@ -147,9 +130,8 @@ FR3. Only one focusable node. `retreat()` is a no-op. ### 8.1 Core: Explicit Focus -FE1. `focus(childB)` — childB gets `focused: true`, old - focused node gets `focused: false`. -FE2. `focus(node)` on a non-focusable node raises an error. +FE1. `focus(childB)` — childB gets `focused: true`, old focused node gets +`focused: false`. FE2. `focus(node)` on a non-focusable node raises an error. FE3. `focus(node)` on the already-focused node is a no-op. --- @@ -158,10 +140,9 @@ FE3. `focus(node)` on the already-focused node is a no-op. ### 9.1 Core: Query -CU1. After `useFocus()`, `current()` returns root. -CU2. After `advance()`, `current()` returns the new - focused node. -CU3. `current()` never returns `undefined`. +CU1. After `useFocus()`, `current()` returns root. CU2. After `advance()`, +`current()` returns the new focused node. CU3. `current()` never returns +`undefined`. --- @@ -169,19 +150,14 @@ CU3. `current()` never returns `undefined`. ### 10.1 Core: Advance on Remove -FR1. Remove the focused node. Focus advances to the next - node in the chain. -FR2. Remove the last focused node in the chain. Focus wraps - to the first. -FR3. After removal, the removed node is gone from the focus - chain. +FR1. Remove the focused node. Focus advances to the next node in the chain. FR2. +Remove the last focused node in the chain. Focus wraps to the first. FR3. After +removal, the removed node is gone from the focus chain. ### 10.2 Extended: Edge Cases -FR4. Remove a non-focused focusable node. Focus does not - move. -FR5. Remove the only non-root focusable node. Focus returns - to root. +FR4. Remove a non-focused focusable node. Focus does not move. FR5. Remove the +only non-root focusable node. Focus returns to root. --- @@ -189,12 +165,9 @@ FR5. Remove the only non-root focusable node. Focus returns ### 11.1 Core: Focus Changes Notify -FN1. `advance()` triggers a tree notification (focus - property changes). -FN2. `focusable()` triggers a tree notification (property - set). -FN3. Multiple focus operations in one dispatch cycle - coalesce into a single notification. +FN1. `advance()` triggers a tree notification (focus property changes). FN2. +`focusable()` triggers a tree notification (property set). FN3. Multiple focus +operations in one dispatch cycle coalesce into a single notification. --- @@ -202,8 +175,6 @@ FN3. Multiple focus operations in one dispatch cycle The following are explicitly NOT tested: -NT1. Focus trapping / containment (§9.1). -NT2. Focus group navigation (§9.2). -NT3. Focus restoration on scope exit (§9.3). -NT4. Directional / spatial navigation (§9.4). -NT5. Focus middleware interception patterns (app-level). +NT1. Focus trapping / containment (§9.1). NT2. Focus group navigation (§9.2). +NT3. Focus restoration on scope exit (§9.3). NT4. Directional / spatial +navigation (§9.4). NT5. Focus middleware interception patterns (app-level). diff --git a/specs/freedom-spec.md b/specs/freedom-spec.md index 667735b..539f1de 100644 --- a/specs/freedom-spec.md +++ b/specs/freedom-spec.md @@ -2,31 +2,28 @@ **Version:** 0.1 — Draft\ **Status:** Normative draft\ -**Depends on:** Effection 4.1-alpha (`createApi`, `createContext`, -`Signal`, `Stream`, `resource`, `spawn`, `Result`) +**Depends on:** Effection 4.1-alpha (`createApi`, `createContext`, `Signal`, +`Stream`, `resource`, `spawn`, `Result`) --- ## 1. Purpose -Freedom ("free DOM") is a general-purpose abstract component -tree built on Effection's structured concurrency. It maintains -a tree of long-lived, stateful component nodes where each node -is an Effection resource with a scope, a property bag, and -ordered children. +Freedom ("free DOM") is a general-purpose abstract component tree built on +Effection's structured concurrency. It maintains a tree of long-lived, stateful +component nodes where each node is an Effection resource with a scope, a +property bag, and ordered children. The tree is a **bidirectional firehose**: -- **Input:** A synchronous `dispatch(event)` entry point - accepts events of any shape at any rate. -- **Output:** A `Stream` emits a notification after - every mutation cycle, signaling renderers to walk the tree - and rebuild output. +- **Input:** A synchronous `dispatch(event)` entry point accepts events of any + shape at any rate. +- **Output:** A `Stream` emits a notification after every mutation cycle, + signaling renderers to walk the tree and rebuild output. -Freedom is renderer-agnostic. It has no opinion about what -properties mean, what events look like, or how output is -produced. Renderers, event vocabularies, and display systems -are consumers that operate on the tree from the outside. +Freedom is renderer-agnostic. It has no opinion about what properties mean, what +events look like, or how output is produced. Renderers, event vocabularies, and +display systems are consumers that operate on the tree from the outside. --- @@ -34,126 +31,108 @@ are consumers that operate on the tree from the outside. ### 2.1 Renderer independence -Freedom provides structure, state, events, and change -notification. It does not provide layout, rendering, styling, -or any visual vocabulary. A clayterm renderer reads -`node.props["clay"]`. A DOM renderer reads -`node.props["html"]`. Freedom does not know or care. +Freedom provides structure, state, events, and change notification. It does not +provide layout, rendering, styling, or any visual vocabulary. A clayterm +renderer reads `node.props["clay"]`. A DOM renderer reads `node.props["html"]`. +Freedom does not know or care. ### 2.2 Event agnosticism -Freedom defines exactly one event operation: -`dispatch(event: unknown)`. It does not define keyboard -events, mouse events, or any other vocabulary. Applications -and framework layers install middleware at the root scope to -demux raw events into their own typed APIs. This allows -Freedom to serve DOM applications, terminal applications, -game engines, or any other event source without modification. +Freedom defines exactly one event operation: `dispatch(event: unknown)`. It does +not define keyboard events, mouse events, or any other vocabulary. Applications +and framework layers install middleware at the root scope to demux raw events +into their own typed APIs. This allows Freedom to serve DOM applications, +terminal applications, game engines, or any other event source without +modification. ### 2.3 Operations over methods -Node mutations (`set`, `update`) and structural operations -(`append`, `remove`, `sort`) are Effection context API -operations, not methods on the Node object. This makes them -interceptable by middleware — a parent scope can wrap `set` to -validate, transform, log, or reject property changes in its -subtree. The component signature is `() => Operation`; -everything is accessed through the ambient scope. +Node mutations (`set`, `update`) and structural operations (`append`, `remove`, +`sort`) are Effection context API operations, not methods on the Node object. +This makes them interceptable by middleware — a parent scope can wrap `set` to +validate, transform, log, or reject property changes in its subtree. The +component signature is `() => Operation`; everything is accessed through +the ambient scope. ### 2.4 Orthogonal concerns -Identity, properties, and child ordering are fully -independent: +Identity, properties, and child ordering are fully independent: -- **Identity** is intrinsic (object reference) with a - framework-assigned unique `id` for external use. -- **Properties** are a JSON-like bag with conventional - namespacing. -- **Ordering** is a separate axis: insertion order by default, - or a custom sort function owned by the parent. +- **Identity** is intrinsic (object reference) with a framework-assigned unique + `id` for external use. +- **Properties** are a JSON-like bag with conventional namespacing. +- **Ordering** is a separate axis: insertion order by default, or a custom sort + function owned by the parent. ### 2.5 Immediate-mode output -The output stream emits `void` — a pure notification that -something changed. Renderers walk the tree and read properties -to produce output. This matches immediate-mode rendering -patterns (e.g., clayterm rebuilds `Op[]` every frame). The -JSON-like property constraint does not foreclose on richer -change records in the future. +The output stream emits `void` — a pure notification that something changed. +Renderers walk the tree and read properties to produce output. This matches +immediate-mode rendering patterns (e.g., clayterm rebuilds `Op[]` every frame). +The JSON-like property constraint does not foreclose on richer change records in +the future. ### 2.6 Read-only Node surface -The `Node` object's data fields — `id`, `name`, `props`, -`children`, `parent` — are for reading only. All property -and structural mutations go through the `freedom:node` -context API, which makes every mutation interceptable by -middleware. `Node` also exposes `eval()` for scoped operation -execution and `remove()` for lifecycle management. `remove()` -is both a convenience method on `Node` and a context API -operation — the method delegates to the operation, ensuring -middleware always participates in teardown. +The `Node` object's data fields — `id`, `name`, `props`, `children`, `parent` — +are for reading only. All property and structural mutations go through the +`freedom:node` context API, which makes every mutation interceptable by +middleware. `Node` also exposes `eval()` for scoped operation execution and +`remove()` for lifecycle management. `remove()` is both a convenience method on +`Node` and a context API operation — the method delegates to the operation, +ensuring middleware always participates in teardown. --- ## 3. Terminology -**Tree.** The root resource that owns all component nodes. -Externally it is an event sink (`dispatch`) and a change -source (`Stream`). Internally it is an Effection scope -that contains the root node, the event loop, and the +**Tree.** The root resource that owns all component nodes. Externally it is an +event sink (`dispatch`) and a change source (`Stream`). Internally it is +an Effection scope that contains the root node, the event loop, and the notification mechanism. -**Node.** A long-lived Effection resource within the tree. -Each node has a framework-assigned unique `id`, an optional -name, a property bag, an ordered list of children, and a -parent (except the root). A node's in-memory identity is its -object reference; its externalizable identity is its `id`. -The Node's data fields are read-only — all property mutations -go through the context API. Each node maintains an eval loop -that accepts operations via `node.eval()`, enabling scoped -execution. `node.remove()` delegates to the `remove` context -API operation, which halts the node's scope and tears down -all descendants. Middleware on `remove` participates in -teardown. - -**Component.** A generator function of type -`() => Operation` that runs within a node's Effection -scope for the node's entire lifetime. Components access node -operations and event middleware through the ambient scope. -A component either does its initialization work and returns, -or enters an infinite loop that reacts to changes. In either -case, the node remains alive — the node's scope outlives the -component generator. - -**Property bag.** A `Record` on each node. -Properties are the node's state AND its renderable -description — they are the same thing. Properties are -namespaced by convention (e.g., `"clay"`, `"aria"`). +**Node.** A long-lived Effection resource within the tree. Each node has a +framework-assigned unique `id`, an optional name, a property bag, an ordered +list of children, and a parent (except the root). A node's in-memory identity is +its object reference; its externalizable identity is its `id`. The Node's data +fields are read-only — all property mutations go through the context API. Each +node maintains an eval loop that accepts operations via `node.eval()`, enabling +scoped execution. `node.remove()` delegates to the `remove` context API +operation, which halts the node's scope and tears down all descendants. +Middleware on `remove` participates in teardown. + +**Component.** A generator function of type `() => Operation` that runs +within a node's Effection scope for the node's entire lifetime. Components +access node operations and event middleware through the ambient scope. A +component either does its initialization work and returns, or enters an infinite +loop that reacts to changes. In either case, the node remains alive — the node's +scope outlives the component generator. + +**Property bag.** A `Record` on each node. Properties are the +node's state AND its renderable description — they are the same thing. +Properties are namespaced by convention (e.g., `"clay"`, `"aria"`). **JsonValue.** The set of permissible property values: `string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }`. No `undefined`. Properties are removed with `unset()`. -**Dispatch.** The synchronous entry point for events. -`tree.dispatch(event)` pushes an event into the tree's -internal Signal. The event is then processed operationally -through middleware. +**Dispatch.** The synchronous entry point for events. `tree.dispatch(event)` +pushes an event into the tree's internal Signal. The event is then processed +operationally through middleware. -**Middleware.** An Effection `createApi` middleware function -that intercepts an operation. In Freedom, middleware is used -for both event handling (`dispatch.around(...)`) and property -mutation interception (`node.around(...)`). +**Middleware.** An Effection `createApi` middleware function that intercepts an +operation. In Freedom, middleware is used for both event handling +(`dispatch.around(...)`) and property mutation interception +(`node.around(...)`). -**Demux.** Application-level middleware installed at the root -scope that routes raw `dispatch` events into typed, -application-specific APIs. Demuxing is NOT a Freedom concept — -it is a pattern that applications implement. +**Demux.** Application-level middleware installed at the root scope that routes +raw `dispatch` events into typed, application-specific APIs. Demuxing is NOT a +Freedom concept — it is a pattern that applications implement. -**Notification.** A `void` emission on the tree's output -stream indicating that the tree may have changed. At most one -notification is emitted per dispatch cycle. A dispatch cycle -that produces no property or structural changes MUST NOT emit -a notification. +**Notification.** A `void` emission on the tree's output stream indicating that +the tree may have changed. At most one notification is emitted per dispatch +cycle. A dispatch cycle that produces no property or structural changes MUST NOT +emit a notification. --- @@ -172,30 +151,29 @@ JsonValue = string ### 4.2 Constraints -J1. `undefined` is NOT a valid JsonValue. Implementations - MUST reject `undefined` in `set()` and `update()`. +J1. `undefined` is NOT a valid JsonValue. Implementations MUST reject +`undefined` in `set()` and `update()`. -J2. `NaN`, `Infinity`, and `-Infinity` are NOT valid - JsonValues. Implementations MUST reject them. +J2. `NaN`, `Infinity`, and `-Infinity` are NOT valid JsonValues. Implementations +MUST reject them. -J3. Functions, symbols, class instances, `Date`, `Map`, - `Set`, `RegExp`, and other non-JSON-serializable values - are NOT valid JsonValues. +J3. Functions, symbols, class instances, `Date`, `Map`, `Set`, `RegExp`, and +other non-JSON-serializable values are NOT valid JsonValues. -J4. Implementations SHOULD validate values at the `set()` - and `update()` boundary. Invalid values MUST NOT be - stored in the property bag. +J4. Implementations SHOULD validate values at the `set()` and `update()` +boundary. Invalid values MUST NOT be stored in the property bag. ### 4.3 Rationale The JsonValue constraint ensures properties are: + - serializable (devtools, snapshots, undo/redo), - structurally comparable (deep equality is well-defined), - diffable (for future change record support). -Application-level state that requires richer types (functions, -class instances, Dates) belongs in the component's generator -scope as local variables, not in the property bag. +Application-level state that requires richer types (functions, class instances, +Dates) belongs in the component's generator scope as local variables, not in the +property bag. --- @@ -205,19 +183,17 @@ scope as local variables, not in the property bag. Each node has: -- **id** (`string`) — a unique, framework-assigned identifier. - Stable for the node's lifetime. Used by renderers and - external systems to reference the node (e.g., clayterm - named elements, ARIA ids, devtools). -- **name** (`string`, optional) — a human-readable label. - Defaults to `""`. Not required to be unique. Used for - debugging and optional lookup. -- **props** (`Record`) — the property bag. - Initially empty. Read-only; mutate via context API only. -- **children** (`Iterable`) — ordered child nodes. - Initially empty. Read-only. -- **parent** (`Node | undefined`) — the parent node. The root - node's parent is `undefined`. Read-only. +- **id** (`string`) — a unique, framework-assigned identifier. Stable for the + node's lifetime. Used by renderers and external systems to reference the node + (e.g., clayterm named elements, ARIA ids, devtools). +- **name** (`string`, optional) — a human-readable label. Defaults to `""`. Not + required to be unique. Used for debugging and optional lookup. +- **props** (`Record`) — the property bag. Initially empty. + Read-only; mutate via context API only. +- **children** (`Iterable`) — ordered child nodes. Initially empty. + Read-only. +- **parent** (`Node | undefined`) — the parent node. The root node's parent is + `undefined`. Read-only. ```ts interface Node { @@ -232,164 +208,132 @@ interface Node { } ``` -The Node's data fields are read-only. All property and -structural mutations go through the `freedom:node` context -API (§6). `eval` is a method on Node because it requires a -specific node reference. `remove` is both a method and a -context API operation — the method delegates to the -operation (§6.2), ensuring middleware participates in -teardown. `data` provides typed, symbol-keyed storage for -private, non-serializable per-node state (§5.7). +The Node's data fields are read-only. All property and structural mutations go +through the `freedom:node` context API (§6). `eval` is a method on Node because +it requires a specific node reference. `remove` is both a method and a context +API operation — the method delegates to the operation (§6.2), ensuring +middleware participates in teardown. `data` provides typed, symbol-keyed storage +for private, non-serializable per-node state (§5.7). ### 5.2 Identity -N1. A node's in-memory identity is its object reference. Two - nodes are the same node if and only if they are the same - object (`===`). +N1. A node's in-memory identity is its object reference. Two nodes are the same +node if and only if they are the same object (`===`). -N2. Each node has a unique `id` string assigned by the - framework at creation time. The `id` is immutable — it - MUST NOT change for the node's lifetime. The `id` MUST - be unique across all nodes in the tree at any given time. - Two nodes are the same node if and only if - `a.id === b.id`. +N2. Each node has a unique `id` string assigned by the framework at creation +time. The `id` is immutable — it MUST NOT change for the node's lifetime. The +`id` MUST be unique across all nodes in the tree at any given time. Two nodes +are the same node if and only if `a.id === b.id`. -N3. The `id` is the externalizable identity. Renderers and - external systems use it to correlate nodes across - render cycles, reference nodes in output (e.g., named - elements in clayterm), and track nodes in devtools. +N3. The `id` is the externalizable identity. Renderers and external systems use +it to correlate nodes across render cycles, reference nodes in output (e.g., +named elements in clayterm), and track nodes in devtools. -N4. The format of `id` is implementation-defined. It MAY be - a monotonic counter, a UUID, or any other scheme that - guarantees uniqueness within the tree. +N4. The format of `id` is implementation-defined. It MAY be a monotonic counter, +a UUID, or any other scheme that guarantees uniqueness within the tree. -N5. Nodes are long-lived Effection resources. A node is - created once and exists until explicitly removed or its - parent scope exits. +N5. Nodes are long-lived Effection resources. A node is created once and exists +until explicitly removed or its parent scope exits. -N6. The `name` field is informational. It does NOT participate - in identity, uniqueness, or reconciliation. +N6. The `name` field is informational. It does NOT participate in identity, +uniqueness, or reconciliation. ### 5.3 Scoped Evaluation -Each node maintains an **eval loop** — a spawned child task -within the node's scope that accepts and executes operations -submitted via `node.eval()`. The eval loop uses a channel- -based "fire and await" pattern: the caller submits an -operation and waits for its result; the eval loop executes -the operation in the node's scope and resolves the result. +Each node maintains an **eval loop** — a spawned child task within the node's +scope that accepts and executes operations submitted via `node.eval()`. The eval +loop uses a channel- based "fire and await" pattern: the caller submits an +operation and waits for its result; the eval loop executes the operation in the +node's scope and resolves the result. -N-eval1. `node.eval(op)` runs `op` within the node's - Effection scope and returns `Result`. If `op` - completes successfully, the result is - `{ ok: true, value: T }`. If `op` throws, the result - is `{ ok: false, error }`. +N-eval1. `node.eval(op)` runs `op` within the node's Effection scope and returns +`Result`. If `op` completes successfully, the result is +`{ ok: true, value: T }`. If `op` throws, the result is `{ ok: false, error }`. -N-eval2. Operations yielded inside `op` see the node's - scope context — including all middleware installed in - the node's scope and its ancestors. +N-eval2. Operations yielded inside `op` see the node's scope context — including +all middleware installed in the node's scope and its ancestors. -N-eval3. The eval loop executes operations sequentially. - If multiple callers submit operations concurrently, - they are processed in order. +N-eval3. The eval loop executes operations sequentially. If multiple callers +submit operations concurrently, they are processed in order. -N-eval4. The eval loop is spawned as a child of the node's - scope. It runs alongside the component and shares the - same parent scope, so middleware installed by the - component is visible to operations executed via `eval`. +N-eval4. The eval loop is spawned as a child of the node's scope. It runs +alongside the component and shares the same parent scope, so middleware +installed by the component is visible to operations executed via `eval`. ### 5.4 Lifecycle -N7. A node is created by `append()`, which spawns a new - Effection child scope within the parent node's scope and - runs the component within it. +N7. A node is created by `append()`, which spawns a new Effection child scope +within the parent node's scope and runs the component within it. -N8. A node is destroyed by `node.remove()`, which halts the - node's spawned task. This triggers structured teardown - of the component, the eval loop, all middleware installed - in the scope, and all descendant nodes. `remove()` does - not return until teardown is complete. +N8. A node is destroyed by `node.remove()`, which halts the node's spawned task. +This triggers structured teardown of the component, the eval loop, all +middleware installed in the scope, and all descendant nodes. `remove()` does not +return until teardown is complete. -N9. When a node is destroyed, its middleware disappears - automatically because the Effection scope is destroyed. +N9. When a node is destroyed, its middleware disappears automatically because +the Effection scope is destroyed. -N10. When a parent node is destroyed, all child nodes are - destroyed in reverse creation order (LIFO), per - Effection's structured concurrency guarantees. +N10. When a parent node is destroyed, all child nodes are destroyed in reverse +creation order (LIFO), per Effection's structured concurrency guarantees. -N11. `node.remove()` delegates to the `remove` context API - operation (§6.2) via - `yield* node.eval(() => remove(node))`. The `remove` - operation runs inside the node's scope, and all - middleware on `remove` participates in the teardown - (outer to inner). The innermost implementation halts - the scope from within. The method remains on `Node` - for ergonomic use by parents: - `yield* child.remove()`. +N11. `node.remove()` delegates to the `remove` context API operation (§6.2) via +`yield* node.eval(() => remove(node))`. The `remove` operation runs inside the +node's scope, and all middleware on `remove` participates in the teardown (outer +to inner). The innermost implementation halts the scope from within. The method +remains on `Node` for ergonomic use by parents: `yield* child.remove()`. -N12. Calling `remove()` on the root node is an error - (C-rm3). +N12. Calling `remove()` on the root node is an error (C-rm3). ### 5.5 Property Bag -N13. Properties are accessed via `node.props`, which returns - a read-only `Record`. Implementations - SHOULD return a frozen or proxied object to enforce - read-only access at runtime. +N13. Properties are accessed via `node.props`, which returns a read-only +`Record`. Implementations SHOULD return a frozen or proxied +object to enforce read-only access at runtime. -N14. Properties MUST NOT be mutated by direct assignment to - the `props` object. The ONLY way to modify properties is - through the `freedom:node` context API operations (`set`, - `update`, `unset`). See §6. +N14. Properties MUST NOT be mutated by direct assignment to the `props` object. +The ONLY way to modify properties is through the `freedom:node` context API +operations (`set`, `update`, `unset`). See §6. -N15. The `props` object MUST reflect the latest state at all - times. There is no staleness window. +N15. The `props` object MUST reflect the latest state at all times. There is no +staleness window. -N16. Properties are namespaced by convention. Top-level keys - (e.g., `"clay"`, `"aria"`, `"app"`) serve as namespaces. - Freedom does not enforce or validate namespaces. +N16. Properties are namespaced by convention. Top-level keys (e.g., `"clay"`, +`"aria"`, `"app"`) serve as namespaces. Freedom does not enforce or validate +namespaces. ### 5.6 Children and Ordering -N17. Children are ordered. The default ordering is - **insertion order** — children appear in the order they - were appended. +N17. Children are ordered. The default ordering is **insertion order** — +children appear in the order they were appended. -N18. A parent MAY install a **sort function** via the - `sort` context API operation. When a sort function is - active, iterating `node.children` applies the sort - function to produce the ordering. Insertion order serves - as tiebreaker for children that compare equal. +N18. A parent MAY install a **sort function** via the `sort` context API +operation. When a sort function is active, iterating `node.children` applies the +sort function to produce the ordering. Insertion order serves as tiebreaker for +children that compare equal. -N19. A parent MAY clear the sort function via - `sort(undefined)`, which reverts to insertion order. +N19. A parent MAY clear the sort function via `sort(undefined)`, which reverts +to insertion order. -N20. The sort function is applied at **read time** — when - `node.children` is iterated. There is no eager - re-sorting on property changes. The sort is always - fresh because it runs against current property values - at iteration time. +N20. The sort function is applied at **read time** — when `node.children` is +iterated. There is no eager re-sorting on property changes. The sort is always +fresh because it runs against current property values at iteration time. -N21. Installing or clearing a sort function via `sort()` - MUST emit a notification (§8), because the iteration - order of children may have changed. +N21. Installing or clearing a sort function via `sort()` MUST emit a +notification (§8), because the iteration order of children may have changed. ### 5.7 Node Data -Node data is typed, symbol-keyed storage for non-serializable, -private values associated with a node. It exists to give -extensions and middleware a place to store per-node state that -is invisible to other extensions, renderers, and application -code. - -The property bag (§5.5) is the node's public, renderable -state — constrained to JsonValue, frozen, and observable via -notifications. Node data is the complement: private state -owned by a specific extension, unconstrained in type, and -invisible to the rest of the system. Only code holding the -key can read or write the data. All NodeData operations are -synchronous — no scope resolution is needed because the node -reference provides direct access to its data. +Node data is typed, symbol-keyed storage for non-serializable, private values +associated with a node. It exists to give extensions and middleware a place to +store per-node state that is invisible to other extensions, renderers, and +application code. + +The property bag (§5.5) is the node's public, renderable state — constrained to +JsonValue, frozen, and observable via notifications. Node data is the +complement: private state owned by a specific extension, unconstrained in type, +and invisible to the rest of the system. Only code holding the key can read or +write the data. All NodeData operations are synchronous — no scope resolution is +needed because the node reference provides direct access to its data. ```ts interface NodeDataKey { @@ -409,23 +353,19 @@ function createNodeData( ): NodeDataKey; ``` -D1. `createNodeData(name, defaultValue?)` creates a typed - data key backed by a `Symbol(name)`. The symbol ensures - true privacy — only code holding the key can access the - data. +D1. `createNodeData(name, defaultValue?)` creates a typed data key backed by a +`Symbol(name)`. The symbol ensures true privacy — only code holding the key can +access the data. -D2. `node.data.get(key)` returns the stored value, or - `undefined` if no value was set. +D2. `node.data.get(key)` returns the stored value, or `undefined` if no value +was set. -D3. `node.data.set(key, value)` stores a value on the node - under the given key. +D3. `node.data.set(key, value)` stores a value on the node under the given key. -D4. `node.data.expect(key)` returns the stored value, or - `defaultValue` if no value was set. If neither exists, - it throws an error. +D4. `node.data.expect(key)` returns the stored value, or `defaultValue` if no +value was set. If neither exists, it throws an error. -D5. Node data does NOT trigger tree notifications. It is - invisible to renderers. +D5. Node data does NOT trigger tree notifications. It is invisible to renderers. The Node interface is extended: @@ -448,8 +388,7 @@ interface Node { ### 6.1 API Definition -The node context API is an Effection API created with -`createApi`: +The node context API is an Effection API created with `createApi`: ``` createApi("freedom:node", { @@ -464,44 +403,41 @@ createApi("freedom:node", { }) ``` -Note: `node.remove()` is a convenience method that delegates -to the `remove` context API operation via -`yield* node.eval(() => remove(node))`. This ensures all -middleware on `remove` participates in teardown. +Note: `node.remove()` is a convenience method that delegates to the `remove` +context API operation via `yield* node.eval(() => remove(node))`. This ensures +all middleware on `remove` participates in teardown. ### 6.2 Operations **useNode** -C-node1. `useNode()` returns the current node from the ambient - scope as a `Node`. +C-node1. `useNode()` returns the current node from the ambient scope as a +`Node`. -C-node2. `useNode()` MUST be called within a node's scope - (inside a component body, middleware, or an `eval` call). - If called outside a node scope, it MUST raise an error. +C-node2. `useNode()` MUST be called within a node's scope (inside a component +body, middleware, or an `eval` call). If called outside a node scope, it MUST +raise an error. -C-node3. `useNode` is a `createApi` operation and can be - intercepted by middleware. A parent MAY install middleware - on `useNode` to substitute a different node reference - (e.g., for virtualization or proxying). +C-node3. `useNode` is a `createApi` operation and can be intercepted by +middleware. A parent MAY install middleware on `useNode` to substitute a +different node reference (e.g., for virtualization or proxying). **get** -C-get1. `get(key)` returns the value of `key` in the current - node's property bag, or `undefined` if the key does not - exist. +C-get1. `get(key)` returns the value of `key` in the current node's property +bag, or `undefined` if the key does not exist. -C-get2. `get` is a `createApi` operation and can be - intercepted by middleware. A parent MAY install middleware - on `get` to transform, log, or virtualize property reads. +C-get2. `get` is a `createApi` operation and can be intercepted by middleware. A +parent MAY install middleware on `get` to transform, log, or virtualize property +reads. **set** -C1. `set(key, value)` stores `value` under `key` in the - current node's property bag. +C1. `set(key, value)` stores `value` under `key` in the current node's property +bag. -C2. `value` MUST be a valid JsonValue (§4). If not, the - operation MUST raise an error. +C2. `value` MUST be a valid JsonValue (§4). If not, the operation MUST raise an +error. C3. If `key` already exists, the previous value is replaced. @@ -509,65 +445,56 @@ C4. `set` triggers a tree notification (§8). **update** -C5. `update(key, fn)` calls `fn` with the current value of - `key` (or `undefined` if the key does not exist) and - stores the result. +C5. `update(key, fn)` calls `fn` with the current value of `key` (or `undefined` +if the key does not exist) and stores the result. -C6. The return value of `fn` MUST be a valid JsonValue (§4). - If not, the operation MUST raise an error. +C6. The return value of `fn` MUST be a valid JsonValue (§4). If not, the +operation MUST raise an error. C7. `update` triggers a tree notification (§8). **unset** -C8. `unset(key)` removes `key` from the current node's - property bag. +C8. `unset(key)` removes `key` from the current node's property bag. -C9. If `key` does not exist, `unset` is a no-op. It MUST - NOT raise an error. +C9. If `key` does not exist, `unset` is a no-op. It MUST NOT raise an error. -C10. `unset` triggers a tree notification (§8) only if the - key existed. +C10. `unset` triggers a tree notification (§8) only if the key existed. **append** -C11. `append(name, component)` creates a new child node with - the given name, spawns an Effection child scope, and - runs the component within it. +C11. `append(name, component)` creates a new child node with the given name, +spawns an Effection child scope, and runs the component within it. C12. `append` returns the newly created Node. C13. `append` triggers a tree notification (§8). -C14. The child node's scope is a child of the current node's - scope. Middleware installed in the child's scope inherits - from the parent's scope per Effection's context - prototype chain. +C14. The child node's scope is a child of the current node's scope. Middleware +installed in the child's scope inherits from the parent's scope per Effection's +context prototype chain. **sort** -C19. `sort(fn)` installs a sort function on the current - node. When `fn` is defined, iterating this node's - `children` applies `fn` to determine ordering. When - `fn` is `undefined`, the sort function is cleared and - ordering reverts to insertion order. +C19. `sort(fn)` installs a sort function on the current node. When `fn` is +defined, iterating this node's `children` applies `fn` to determine ordering. +When `fn` is `undefined`, the sort function is cleared and ordering reverts to +insertion order. C20. `sort` triggers a tree notification (§8) (N19). **remove** -C-rm1. `remove(node)` removes the given node. The innermost - (default) implementation halts the node's scope from - within, triggering structured teardown of the component, - the eval loop, all middleware installed in the scope, and - all descendant nodes. The halt mechanism lives inside the - scope (e.g., a resolver the suspended task awaits) — the - node does not hold a reference to its task. +C-rm1. `remove(node)` removes the given node. The innermost (default) +implementation halts the node's scope from within, triggering structured +teardown of the component, the eval loop, all middleware installed in the scope, +and all descendant nodes. The halt mechanism lives inside the scope (e.g., a +resolver the suspended task awaits) — the node does not hold a reference to its +task. -C-rm2. `remove(node)` is a `createApi` operation and can be - intercepted by middleware. Extensions (e.g., focus) MAY - install middleware on `remove` to perform cleanup before - the node is destroyed. +C-rm2. `remove(node)` is a `createApi` operation and can be intercepted by +middleware. Extensions (e.g., focus) MAY install middleware on `remove` to +perform cleanup before the node is destroyed. C-rm3. `remove(node)` on the root node is an error. @@ -575,18 +502,16 @@ C-rm4. `remove` triggers a tree notification (§8). ### 6.3 Middleware Interception -C21. Because `get`, `set`, `update`, `unset`, `append`, - `remove`, and `sort` are `createApi` operations, they can - be intercepted by middleware installed in ancestor scopes. +C21. Because `get`, `set`, `update`, `unset`, `append`, `remove`, and `sort` are +`createApi` operations, they can be intercepted by middleware installed in +ancestor scopes. -C22. A parent component MAY install middleware on - `freedom:node` to validate, transform, log, or reject - property changes in its subtree. +C22. A parent component MAY install middleware on `freedom:node` to validate, +transform, log, or reject property changes in its subtree. -C23. Middleware on `set` receives the `[key, value]` tuple - and a `next` function. It MAY modify the key or value - before calling `next`, or it MAY skip `next` to reject - the mutation. +C23. Middleware on `set` receives the `[key, value]` tuple and a `next` +function. It MAY modify the key or value before calling `next`, or it MAY skip +`next` to reject the mutation. --- @@ -594,8 +519,7 @@ C23. Middleware on `set` receives the `[key, value]` tuple ### 7.1 The Dispatch API -Freedom provides a dispatch API with event dispatch and -node lookup: +Freedom provides a dispatch API with event dispatch and node lookup: ``` createApi("freedom:dispatch", { @@ -606,99 +530,87 @@ createApi("freedom:dispatch", { }) ``` -E1. The `dispatch` operation accepts a single argument of - type `unknown`. Freedom does not constrain, inspect, or - interpret the event in any way. +E1. The `dispatch` operation accepts a single argument of type `unknown`. +Freedom does not constrain, inspect, or interpret the event in any way. -E2. The default handler (at the bottom of the middleware - chain) returns `{ ok: false }`, indicating the event was - unhandled. +E2. The default handler (at the bottom of the middleware chain) returns +`{ ok: false }`, indicating the event was unhandled. -E3. Middleware that handles an event MUST return - `{ ok: true, value: true }`. +E3. Middleware that handles an event MUST return `{ ok: true, value: true }`. -E4. If middleware throws an exception, the result MUST - capture it as `{ ok: false, error }`. The dispatch loop - MUST NOT crash. The tree MUST remain alive. +E4. If middleware throws an exception, the result MUST capture it as +`{ ok: false, error }`. The dispatch loop MUST NOT crash. The tree MUST remain +alive. -E4a. `getNodeById(id)` returns the node with the given `id`, - or `undefined` if no such node exists. This allows - dispatch middleware to resolve event targets by id and - use `node.eval()` to dispatch application-level APIs - in the target node's scope. +E4a. `getNodeById(id)` returns the node with the given `id`, or `undefined` if +no such node exists. This allows dispatch middleware to resolve event targets by +id and use `node.eval()` to dispatch application-level APIs in the target node's +scope. ### 7.2 The Synchronous Bridge -E5. `tree.dispatch(event)` is synchronous. It pushes the - event into an Effection `Signal`. +E5. `tree.dispatch(event)` is synchronous. It pushes the event into an Effection +`Signal`. -E6. Internally, an event loop reads from the Signal and - dispatches each event through the root node's scope: - `yield* root.eval(() => dispatch.operations.dispatch(event))`. - The event loop itself MAY run anywhere — it enters the - root node's scope via `eval`, so dispatch middleware - installed by the root component is always in the chain. +E6. Internally, an event loop reads from the Signal and dispatches each event +through the root node's scope: +`yield* root.eval(() => dispatch.operations.dispatch(event))`. The event loop +itself MAY run anywhere — it enters the root node's scope via `eval`, so +dispatch middleware installed by the root component is always in the chain. -E7. Events are processed sequentially — one at a time, in - the order they were dispatched. A new event is not - processed until the previous event's entire middleware - chain has completed. +E7. Events are processed sequentially — one at a time, in the order they were +dispatched. A new event is not processed until the previous event's entire +middleware chain has completed. ### 7.3 Demuxing -E8. Freedom does NOT provide built-in demuxing. It does not - define event types, event shapes, or event routing. - -E9. Demuxing is the root component's responsibility. The - root component installs middleware on `freedom:dispatch` - that routes events to application-specific APIs. - -E10. The root component is the first component to run, so - its middleware is the outermost layer — it sees every - event before any child middleware. - -E10a. Dispatch middleware uses `getNodeById()` and - `node.eval()` to route events to specific nodes. - When an event targets a particular node, the middleware - resolves the node by id, then uses `eval` to invoke - an application-level API in that node's scope. The - target node's middleware on that application API - participates because `eval` runs in the node's scope. - - ```ts - // Example demux middleware (application-level, NOT Freedom) - yield* DispatchApi.around({ - *dispatch([event], next) { - if (isKeydown(event)) { - let node = yield* DispatchApi.operations.getNodeById(event.targetId); - if (node) { - yield* node.eval(() => KeyboardApi.operations.keydown(event)); - } - return { ok: true, value: true }; - } - return yield* next(event); - }, - }); - ``` - -E11. Applications define their own event APIs using - `createApi`. These are NOT part of Freedom. - -E12. Multiple demux layers MAY compose. A root breaks raw - events into `keyboard`/`mouse` APIs. A child further - breaks `keyboard` into `shortcut` based on key bindings. - Each layer is middleware on an API. +E8. Freedom does NOT provide built-in demuxing. It does not define event types, +event shapes, or event routing. + +E9. Demuxing is the root component's responsibility. The root component installs +middleware on `freedom:dispatch` that routes events to application-specific +APIs. + +E10. The root component is the first component to run, so its middleware is the +outermost layer — it sees every event before any child middleware. + +E10a. Dispatch middleware uses `getNodeById()` and `node.eval()` to route events +to specific nodes. When an event targets a particular node, the middleware +resolves the node by id, then uses `eval` to invoke an application-level API in +that node's scope. The target node's middleware on that application API +participates because `eval` runs in the node's scope. + + ```ts + // Example demux middleware (application-level, NOT Freedom) + yield* DispatchApi.around({ + *dispatch([event], next) { + if (isKeydown(event)) { + let node = yield* DispatchApi.operations.getNodeById(event.targetId); + if (node) { + yield* node.eval(() => KeyboardApi.operations.keydown(event)); + } + return { ok: true, value: true }; + } + return yield* next(event); + }, + }); + ``` + +E11. Applications define their own event APIs using `createApi`. These are NOT +part of Freedom. + +E12. Multiple demux layers MAY compose. A root breaks raw events into +`keyboard`/`mouse` APIs. A child further breaks `keyboard` into `shortcut` based +on key bindings. Each layer is middleware on an API. ### 7.4 Event Helpers -E13. Per-event-type helper functions are an application-level - pattern, NOT part of Freedom. They are the sanctioned way - for applications and framework layers to build ergonomic - event APIs on top of Freedom's generic `dispatch` + - `around()`. +E13. Per-event-type helper functions are an application-level pattern, NOT part +of Freedom. They are the sanctioned way for applications and framework layers to +build ergonomic event APIs on top of Freedom's generic `dispatch` + `around()`. -A helper wraps the raw `around()` API to hide the tuple -destructuring and provide TypeScript type narrowing: +A helper wraps the raw `around()` API to hide the tuple destructuring and +provide TypeScript type narrowing: ```ts // Definition (in the app's event layer, NOT in Freedom) @@ -730,10 +642,11 @@ function* searchBox(): Operation { ``` The helper is better than raw middleware because: + - The consumer sees `(event, next)` instead of `([event], next)`. - TypeScript narrows `event` to `KeydownEvent` automatically. -- The `keyboard` API reference is encapsulated — the consumer - does not need to know which API object to call `around()` on. +- The `keyboard` API reference is encapsulated — the consumer does not need to + know which API object to call `around()` on. --- @@ -748,76 +661,62 @@ interface Tree extends Stream { } ``` -T1. The tree is an Effection resource. Creating it starts a - root Effection scope that owns all nodes and the event - loop. +T1. The tree is an Effection resource. Creating it starts a root Effection scope +that owns all nodes and the event loop. -T2. `tree.root` is the root node. It is created when the - tree is created and exists for the tree's lifetime. +T2. `tree.root` is the root node. It is created when the tree is created and +exists for the tree's lifetime. -T3. `tree.dispatch(event)` is the synchronous event entry - point (§7.2). +T3. `tree.dispatch(event)` is the synchronous event entry point (§7.2). -T4. The tree is a `Stream`. Subscribing to the - stream yields notifications when the tree changes. +T4. The tree is a `Stream`. Subscribing to the stream yields +notifications when the tree changes. ### 8.2 Creation -T5. `createTree(root: Component): Operation` creates - a tree. It: - 1. Creates the root Effection scope. - 2. Creates the event Signal. - 3. Creates the root node. - 4. Runs the root component within the root node's scope. - 5. Spawns the event loop, which dispatches events through - the root node via `root.eval()`. - 6. Returns the Tree. - -T6. `createTree` returns once the root node's eval loop is - ready to accept operations. The root component runs - concurrently within the root node's scope — it MAY - still be executing when `createTree` returns. The tree - is usable as soon as `eval` is operational. +T5. `createTree(root: Component): Operation` creates a tree. It: 1. +Creates the root Effection scope. 2. Creates the event Signal. 3. Creates the +root node. 4. Runs the root component within the root node's scope. 5. Spawns +the event loop, which dispatches events through the root node via `root.eval()`. +6. Returns the Tree. + +T6. `createTree` returns once the root node's eval loop is ready to accept +operations. The root component runs concurrently within the root node's scope — +it MAY still be executing when `createTree` returns. The tree is usable as soon +as `eval` is operational. ### 8.3 Notification -T7. The tree emits a `void` notification on its output - stream after each dispatch cycle — that is, after one - event has been fully processed through the middleware - chain and all resulting property mutations and - structural changes have settled. +T7. The tree emits a `void` notification on its output stream after each +dispatch cycle — that is, after one event has been fully processed through the +middleware chain and all resulting property mutations and structural changes +have settled. -T8. A dispatch cycle that produces no property changes, - structural changes, or sort function changes MUST NOT - emit a notification. +T8. A dispatch cycle that produces no property changes, structural changes, or +sort function changes MUST NOT emit a notification. -T9. Multiple property mutations within a single dispatch - cycle MUST coalesce into a single notification. The - renderer sees the final state, never intermediate states. +T9. Multiple property mutations within a single dispatch cycle MUST coalesce +into a single notification. The renderer sees the final state, never +intermediate states. -T10. Structural changes (append, remove) and sort changes - within a dispatch cycle also coalesce with property - changes into a single notification. +T10. Structural changes (append, remove) and sort changes within a dispatch +cycle also coalesce with property changes into a single notification. -T11. Property mutations or structural changes that occur - outside of a dispatch cycle (e.g., during component - initialization) MUST also emit notifications. +T11. Property mutations or structural changes that occur outside of a dispatch +cycle (e.g., during component initialization) MUST also emit notifications. -T12. The notification is `void`. It carries no information - about what changed. The renderer MUST walk the tree and - read properties to determine the current state. +T12. The notification is `void`. It carries no information about what changed. +The renderer MUST walk the tree and read properties to determine the current +state. ### 8.4 Lifecycle -T13. Destroying the tree (exiting its Effection scope) - destroys all nodes, stops the event loop, and closes - the output stream. +T13. Destroying the tree (exiting its Effection scope) destroys all nodes, stops +the event loop, and closes the output stream. -T14. Events dispatched after the tree is destroyed are - silently dropped. This is a natural consequence of - structured concurrency — the event Signal is inert - once the tree's scope exits. Implementations do NOT - need an explicit alive check. +T14. Events dispatched after the tree is destroyed are silently dropped. This is +a natural consequence of structured concurrency — the event Signal is inert once +the tree's scope exits. Implementations do NOT need an explicit alive check. --- @@ -829,23 +728,21 @@ T14. Events dispatched after the tree is destroyed are type Component = () => Operation; ``` -P1. A component is a generator function that takes no - arguments and returns `Operation`. +P1. A component is a generator function that takes no arguments and returns +`Operation`. -P2. A component runs within a node's Effection scope. It - accesses node operations and event middleware through - the ambient scope via `createApi` operations. +P2. A component runs within a node's Effection scope. It accesses node +operations and event middleware through the ambient scope via `createApi` +operations. -P3. A component's lifetime is the node's lifetime. When the - node is removed, the component's operation is cancelled - per Effection's structured concurrency. +P3. A component's lifetime is the node's lifetime. When the node is removed, the +component's operation is cancelled per Effection's structured concurrency. ### 9.2 Execution Model -P4. The node's scope outlives the component generator. When - the generator returns, the node remains alive — the - eval loop (§5.3) keeps the scope active, properties - persist, and middleware remains active. +P4. The node's scope outlives the component generator. When the generator +returns, the node remains alive — the eval loop (§5.3) keeps the scope active, +properties persist, and middleware remains active. P5. A component takes one of two forms: @@ -881,52 +778,44 @@ P5. A component takes one of two forms: The eval loop and the component run concurrently regardless. -P6. Steps within a component (setting properties, installing - middleware, appending children) complete in order. This - means middleware and children are established before the - component enters a reactive loop or returns. +P6. Steps within a component (setting properties, installing middleware, +appending children) complete in order. This means middleware and children are +established before the component enters a reactive loop or returns. --- ## 10. Invariants -I1. **Scope-tree correspondence.** The node tree and the - Effection scope tree are isomorphic. Each node's scope - is a child of its parent node's scope. +I1. **Scope-tree correspondence.** The node tree and the Effection scope tree +are isomorphic. Each node's scope is a child of its parent node's scope. -I2. **Structured teardown.** Removing a node destroys all - its descendants in reverse creation order. No orphaned - nodes can exist. +I2. **Structured teardown.** Removing a node destroys all its descendants in +reverse creation order. No orphaned nodes can exist. -I3. **Middleware scope binding.** Middleware installed in a - node's scope is active only while that node exists. When - the node is removed, its middleware is automatically - removed. +I3. **Middleware scope binding.** Middleware installed in a node's scope is +active only while that node exists. When the node is removed, its middleware is +automatically removed. -I4. **Sequential dispatch.** Events are processed one at a - time. No two events are in-flight simultaneously. +I4. **Sequential dispatch.** Events are processed one at a time. No two events +are in-flight simultaneously. -I5. **Consistent notification.** The tree state visible to a - renderer after a notification is complete and consistent - — all mutations from the triggering dispatch cycle have - been applied. +I5. **Consistent notification.** The tree state visible to a renderer after a +notification is complete and consistent — all mutations from the triggering +dispatch cycle have been applied. -I6. **Property validity.** Every value in every node's - property bag is a valid JsonValue at all times. +I6. **Property validity.** Every value in every node's property bag is a valid +JsonValue at all times. -I7. **Ordering consistency.** `node.children` always reflects - the current ordering — either insertion order or the - sort function applied at read time against current - property values. +I7. **Ordering consistency.** `node.children` always reflects the current +ordering — either insertion order or the sort function applied at read time +against current property values. -I8. **Node read-only plus methods.** The Node object's data - fields (`id`, `name`, `props`, `children`, `parent`) are - read-only. All property reads and mutations go through the - `freedom:node` context API (`get`, `set`, `update`, - `unset`, `append`, `remove`, `sort`). `eval()` provides - scoped operation execution. `remove()` is both a - convenience method and a context API operation — the - method delegates to the operation. +I8. **Node read-only plus methods.** The Node object's data fields (`id`, +`name`, `props`, `children`, `parent`) are read-only. All property reads and +mutations go through the `freedom:node` context API (`get`, `set`, `update`, +`unset`, `append`, `remove`, `sort`). `eval()` provides scoped operation +execution. `remove()` is both a convenience method and a context API operation — +the method delegates to the operation. --- @@ -936,80 +825,70 @@ The following are explicitly out of scope for this version. ### 11.1 Rich Change Records -The output stream emits `void`. A future version MAY emit -structured change records (`PropertyChange`, -`StructuralChange`) with per-node, per-property granularity. -The JsonValue constraint and property bag structure are -designed to support this without API changes. +The output stream emits `void`. A future version MAY emit structured change +records (`PropertyChange`, `StructuralChange`) with per-node, per-property +granularity. The JsonValue constraint and property bag structure are designed to +support this without API changes. ### 11.2 JSX Transform -A JSX transform that provides declarative sugar for -`append()` calls is a natural extension. Display properties -would be set separately by each component via the context -API. JSX is a consumer of Freedom's imperative API, not a -core feature. +A JSX transform that provides declarative sugar for `append()` calls is a +natural extension. Display properties would be set separately by each component +via the context API. JSX is a consumer of Freedom's imperative API, not a core +feature. ### 11.3 Reconciliation -Key-based reconciliation (matching old nodes to new -declarations during declarative re-rendering) is not -supported. Nodes are managed imperatively via `append` and -`remove`. A reconciliation layer could be built on top of -Freedom. +Key-based reconciliation (matching old nodes to new declarations during +declarative re-rendering) is not supported. Nodes are managed imperatively via +`append` and `remove`. A reconciliation layer could be built on top of Freedom. ### 11.4 Computed Properties -Derived properties that update automatically when their -dependencies change are not supported. Components can -implement this pattern manually by installing middleware -on `set`. +Derived properties that update automatically when their dependencies change are +not supported. Components can implement this pattern manually by installing +middleware on `set`. ### 11.5 Node Queries -Querying the tree by property values, name patterns, or -other criteria is not provided. `getNodeById()` is provided -as part of the dispatch API (§7.1) for event targeting. -For other queries, consumers walk the tree via `root` and +Querying the tree by property values, name patterns, or other criteria is not +provided. `getNodeById()` is provided as part of the dispatch API (§7.1) for +event targeting. For other queries, consumers walk the tree via `root` and `children`. ### 11.6 Event Bubbling / Capturing -Freedom does not define event propagation phases. Middleware -composition through the scope tree provides a similar -capability, but the traversal order is determined by -middleware installation order, not by a DOM-style -capture/bubble model. +Freedom does not define event propagation phases. Middleware composition through +the scope tree provides a similar capability, but the traversal order is +determined by middleware installation order, not by a DOM-style capture/bubble +model. ### 11.7 Explicit Index Ordering -A built-in ordering mode based on an `index` property is -not provided. If an application wants index-based ordering, -it sets an `index` property on child nodes and provides a -sort function that reads it. +A built-in ordering mode based on an `index` property is not provided. If an +application wants index-based ordering, it sets an `index` property on child +nodes and provides a sort function that reads it. --- ## 12. Implementation Files -**`lib/types.ts`** — `JsonValue`, `Component`, `Node` -interface, `Tree` interface. +**`lib/types.ts`** — `JsonValue`, `Component`, `Node` interface, `Tree` +interface. -**`lib/tree.ts`** — `createTree(root: Component)`. Root -scope, event Signal, output stream, event loop, notification -coalescing. +**`lib/tree.ts`** — `createTree(root: Component)`. Root scope, event Signal, +output stream, event loop, notification coalescing. -**`lib/node.ts`** — Node resource. Child scope, property bag, -children list with read-time sort. +**`lib/node.ts`** — Node resource. Child scope, property bag, children list with +read-time sort. **`lib/dispatch.ts`** — `createApi("freedom:dispatch", ...)`. `dispatch(event: unknown) → Result` and -`getNodeById(id: string) → Node | undefined`. -Sync→operational bridge via Signal. +`getNodeById(id: string) → Node | undefined`. Sync→operational bridge via +Signal. -**`lib/freedom.ts`** — `createApi("freedom:node", ...)`. -`useNode`, `get`, `set`, `update`, `unset`, `append`, `remove`, -`sort` operations. +**`lib/freedom.ts`** — `createApi("freedom:node", ...)`. `useNode`, `get`, +`set`, `update`, `unset`, `append`, `remove`, `sort` operations. **`lib/mod.ts`** — Re-exports. diff --git a/specs/freedom-test-plan.md b/specs/freedom-test-plan.md index 2d74c51..70bd203 100644 --- a/specs/freedom-test-plan.md +++ b/specs/freedom-test-plan.md @@ -8,27 +8,22 @@ ### 1.1 What This Plan Covers -This test plan defines conformance criteria for the Freedom -component tree as specified in the Freedom Specification. It -covers: +This test plan defines conformance criteria for the Freedom component tree as +specified in the Freedom Specification. It covers: - JsonValue validation at the `set`/`update` boundary (§4) -- Node lifecycle: creation via `append`, destruction via - `remove`, structured teardown (§5.3) +- Node lifecycle: creation via `append`, destruction via `remove`, structured + teardown (§5.3) - Node identity: unique `id` assignment (§5.2) - Property bag: `set`, `update`, `unset` operations (§6.2) - Property bag read-only enforcement (§5.4) -- Child ordering: insertion order, custom sort at read time - (§5.5) +- Child ordering: insertion order, custom sort at read time (§5.5) - Sort installation/removal notification (N19) - Node context API middleware interception (§6.3) -- Event dispatch: single API, `Result` signaling, - error capture (§7.1) -- Synchronous bridge: Signal-based dispatch, sequential - processing (§7.2) +- Event dispatch: single API, `Result` signaling, error capture (§7.1) +- Synchronous bridge: Signal-based dispatch, sequential processing (§7.2) - Tree creation and lifecycle (§8.2, §8.4) -- Notification coalescing, including no-change suppression - (§8.3) +- Notification coalescing, including no-change suppression (§8.3) - Component execution model (§9) - All invariants (§10) @@ -41,18 +36,17 @@ covers: - Event bubbling/capturing (§11.6) - Explicit index ordering (§11.7) - Rich change records (§11.1) -- Application-level demux patterns (§7.3) — these are - tested in application test suites, not Freedom's +- Application-level demux patterns (§7.3) — these are tested in application test + suites, not Freedom's - Event helper functions (§7.4) — application-level pattern ### 1.3 Tiers -**Core:** Tests that every conforming implementation MUST -pass. A Freedom implementation is non-conforming if any Core -test fails. +**Core:** Tests that every conforming implementation MUST pass. A Freedom +implementation is non-conforming if any Core test fails. -**Extended:** Tests for edge cases, boundary conditions, and -robustness. Recommended but not strictly required. +**Extended:** Tests for edge cases, boundary conditions, and robustness. +Recommended but not strictly required. --- @@ -60,36 +54,29 @@ robustness. Recommended but not strictly required. ### 2.1 Core: Valid Values -JV1. `set("k", "hello")` — string accepted. -JV2. `set("k", 42)` — number accepted. -JV3. `set("k", 0)` — zero accepted. -JV4. `set("k", -1.5)` — negative float accepted. -JV5. `set("k", true)` — boolean accepted. -JV6. `set("k", false)` — boolean false accepted. -JV7. `set("k", null)` — null accepted. -JV8. `set("k", [1, "a", true, null])` — array accepted. -JV9. `set("k", { a: 1, b: "c" })` — object accepted. -JV10. `set("k", { nested: { deep: [1, 2] } })` — nested - structures accepted. -JV11. `set("k", [])` — empty array accepted. -JV12. `set("k", {})` — empty object accepted. +JV1. `set("k", "hello")` — string accepted. JV2. `set("k", 42)` — number +accepted. JV3. `set("k", 0)` — zero accepted. JV4. `set("k", -1.5)` — negative +float accepted. JV5. `set("k", true)` — boolean accepted. JV6. `set("k", false)` +— boolean false accepted. JV7. `set("k", null)` — null accepted. JV8. +`set("k", [1, "a", true, null])` — array accepted. JV9. +`set("k", { a: 1, b: "c" })` — object accepted. JV10. +`set("k", { nested: { deep: [1, 2] } })` — nested structures accepted. JV11. +`set("k", [])` — empty array accepted. JV12. `set("k", {})` — empty object +accepted. ### 2.2 Core: Invalid Values -JV13. `set("k", undefined)` — MUST raise error (J1). -JV14. `set("k", NaN)` — MUST raise error (J2). -JV15. `set("k", Infinity)` — MUST raise error (J2). -JV16. `set("k", -Infinity)` — MUST raise error (J2). -JV17. `set("k", () => {})` — MUST raise error (J3). -JV18. `set("k", Symbol())` — MUST raise error (J3). -JV19. `set("k", new Date())` — MUST raise error (J3). -JV20. `set("k", new Map())` — MUST raise error (J3). +JV13. `set("k", undefined)` — MUST raise error (J1). JV14. `set("k", NaN)` — +MUST raise error (J2). JV15. `set("k", Infinity)` — MUST raise error (J2). JV16. +`set("k", -Infinity)` — MUST raise error (J2). JV17. `set("k", () => {})` — MUST +raise error (J3). JV18. `set("k", Symbol())` — MUST raise error (J3). JV19. +`set("k", new Date())` — MUST raise error (J3). JV20. `set("k", new Map())` — +MUST raise error (J3). ### 2.3 Core: update() Validation -JV21. `update("k", () => undefined)` — MUST raise error. - The return value of the update function is validated. -JV22. `update("k", () => NaN)` — MUST raise error. +JV21. `update("k", () => undefined)` — MUST raise error. The return value of the +update function is validated. JV22. `update("k", () => NaN)` — MUST raise error. JV23. `update("k", () => 42)` — accepted. --- @@ -98,41 +85,32 @@ JV23. `update("k", () => 42)` — accepted. ### 3.1 Core: Creation -NL1. `append("child", component)` creates a child node. - The returned node has `name === "child"`. -NL2. The child appears in `parent.children`. -NL3. The child's `parent` is the appending node. -NL4. The child's `props` is initially empty (`{}`). -NL5. The child's component runs within the child's scope. -NL6. Multiple children can be appended to the same parent. +NL1. `append("child", component)` creates a child node. The returned node has +`name === "child"`. NL2. The child appears in `parent.children`. NL3. The +child's `parent` is the appending node. NL4. The child's `props` is initially +empty (`{}`). NL5. The child's component runs within the child's scope. NL6. +Multiple children can be appended to the same parent. ### 3.2 Core: Node ID -NL7. Each created node has a non-empty `id` string (N2). -NL8. Two sibling nodes have different `id` values (N2). -NL9. A node's `id` does not change after creation (N2). -NL10. Nodes in different subtrees have different `id` - values (N2 — unique across the tree). -NL11. The root node has an `id`. +NL7. Each created node has a non-empty `id` string (N2). NL8. Two sibling nodes +have different `id` values (N2). NL9. A node's `id` does not change after +creation (N2). NL10. Nodes in different subtrees have different `id` values (N2 +— unique across the tree). NL11. The root node has an `id`. ### 3.3 Core: Destruction -NL12. `remove()` destroys the node. The node no longer - appears in its parent's `children`. -NL13. `remove()` on a node with children destroys all - descendants. -NL14. Descendant destruction occurs in reverse creation order - (LIFO). -NL15. After `remove()`, middleware installed by the node's - component is no longer active. -NL16. `remove()` on the root node MUST raise an error (C18). +NL12. `remove()` destroys the node. The node no longer appears in its parent's +`children`. NL13. `remove()` on a node with children destroys all descendants. +NL14. Descendant destruction occurs in reverse creation order (LIFO). NL15. +After `remove()`, middleware installed by the node's component is no longer +active. NL16. `remove()` on the root node MUST raise an error (C18). ### 3.4 Extended: Edge Cases -NL17. Appending to a node that is being removed — behavior - is implementation-defined but MUST NOT corrupt the tree. -NL18. A component that returns immediately (no loop) — - the node remains alive (P4). +NL17. Appending to a node that is being removed — behavior is +implementation-defined but MUST NOT corrupt the tree. NL18. A component that +returns immediately (no loop) — the node remains alive (P4). --- @@ -140,35 +118,28 @@ NL18. A component that returns immediately (no loop) — ### 4.1 Core: set -PB1. `set("a", 1)` — `node.props["a"]` is `1`. -PB2. `set("a", 1)` then `set("a", 2)` — `node.props["a"]` - is `2`. -PB3. `set("a", 1)` then `set("b", 2)` — both keys present. -PB4. `set("ns", { x: 1, y: 2 })` — namespaced object stored. -PB5. `node.props` reflects the update immediately after - `set` completes (N13). +PB1. `set("a", 1)` — `node.props["a"]` is `1`. PB2. `set("a", 1)` then +`set("a", 2)` — `node.props["a"]` is `2`. PB3. `set("a", 1)` then `set("b", 2)` +— both keys present. PB4. `set("ns", { x: 1, y: 2 })` — namespaced object +stored. PB5. `node.props` reflects the update immediately after `set` completes +(N13). ### 4.2 Core: update -PB6. `set("n", 1)` then `update("n", (v) => v + 1)` — - `node.props["n"]` is `2`. -PB7. `update("missing", (v) => v ?? 0)` — `fn` receives - `undefined`, stores `0`. -PB8. `update` is atomic: `node.props` reflects the new - value after `update` completes. +PB6. `set("n", 1)` then `update("n", (v) => v + 1)` — `node.props["n"]` is `2`. +PB7. `update("missing", (v) => v ?? 0)` — `fn` receives `undefined`, stores `0`. +PB8. `update` is atomic: `node.props` reflects the new value after `update` +completes. ### 4.3 Core: unset -PB9. `set("a", 1)` then `unset("a")` — `"a"` is no longer - in `node.props`. -PB10. `unset("nonexistent")` — no error, no notification - (C9, C10). +PB9. `set("a", 1)` then `unset("a")` — `"a"` is no longer in `node.props`. PB10. +`unset("nonexistent")` — no error, no notification (C9, C10). ### 4.4 Core: Read-Only Enforcement -PB11. Direct assignment to `node.props["x"] = 1` MUST NOT - modify the property bag OR MUST throw (N12). - Implementations SHOULD freeze or proxy `props`. +PB11. Direct assignment to `node.props["x"] = 1` MUST NOT modify the property +bag OR MUST throw (N12). Implementations SHOULD freeze or proxy `props`. --- @@ -176,35 +147,27 @@ PB11. Direct assignment to `node.props["x"] = 1` MUST NOT ### 5.1 Core: Insertion Order -CO1. Append A, B, C. `children` yields A, B, C. -CO2. Append A, B, C. Remove B. `children` yields A, C. -CO3. Append A, B. Remove A. `children` yields B. +CO1. Append A, B, C. `children` yields A, B, C. CO2. Append A, B, C. Remove B. +`children` yields A, C. CO3. Append A, B. Remove A. `children` yields B. ### 5.2 Core: Custom Sort CO4. Set sort function `(a, b) => cmp(a.props.priority, b.props.priority)`. - Append A (priority=3), B (priority=1), C (priority=2). - `children` yields B, C, A. -CO5. With active sort, change B's priority from 1 to 10. - Next iteration of `children` yields C, A, B (sort - applied at read time, N18). -CO6. Clear sort function via `sort(undefined)`. `children` - reverts to insertion order: A, B, C. +Append A (priority=3), B (priority=1), C (priority=2). `children` yields B, C, +A. CO5. With active sort, change B's priority from 1 to 10. Next iteration of +`children` yields C, A, B (sort applied at read time, N18). CO6. Clear sort +function via `sort(undefined)`. `children` reverts to insertion order: A, B, C. ### 5.3 Core: Sort Notification -CO7. Install a sort function. Stream emits notification - (N19, C20). -CO8. Clear a sort function. Stream emits notification - (N19, C20). +CO7. Install a sort function. Stream emits notification (N19, C20). CO8. Clear a +sort function. Stream emits notification (N19, C20). ### 5.4 Extended: Sort Edge Cases -CO9. Sort function that throws — behavior is - implementation-defined but MUST NOT corrupt the - children list. -CO10. Sort with equal comparisons — insertion order is - the tiebreaker (N16). +CO9. Sort function that throws — behavior is implementation-defined but MUST NOT +corrupt the children list. CO10. Sort with equal comparisons — insertion order +is the tiebreaker (N16). --- @@ -213,20 +176,17 @@ CO10. Sort with equal comparisons — insertion order is ### 5b.1 Core: Node Resolution UN1. `useNode()` in the root component returns the root node. - `node === tree.root`. +`node === tree.root`. -UN2. `useNode()` inside a child component returns the child - node. `node.name` matches the child's name and - `node !== tree.root`. +UN2. `useNode()` inside a child component returns the child node. `node.name` +matches the child's name and `node !== tree.root`. -UN3. `useNode()` via `node.eval()` returns the eval target - node. +UN3. `useNode()` via `node.eval()` returns the eval target node. ### 5b.2 Core: Middleware Interception -UN4. Parent installs middleware on `useNode`. Child calls - `useNode()`. Parent middleware intercepts and may - substitute a different node reference. +UN4. Parent installs middleware on `useNode`. Child calls `useNode()`. Parent +middleware intercepts and may substitute a different node reference. --- @@ -234,16 +194,15 @@ UN4. Parent installs middleware on `useNode`. Child calls ### 6.1 Core: Interception -MW1. Parent installs middleware on `set`. Child calls - `set("x", 1)`. Parent middleware receives `["x", 1]` - and `next`. Parent calls `next("x", 1)`. - `child.props["x"]` is `1`. +MW1. Parent installs middleware on `set`. Child calls `set("x", 1)`. Parent +middleware receives `["x", 1]` and `next`. Parent calls `next("x", 1)`. +`child.props["x"]` is `1`. -MW2. Parent middleware transforms: receives `["x", 1]`, - calls `next("x", 2)`. `child.props["x"]` is `2`. +MW2. Parent middleware transforms: receives `["x", 1]`, calls `next("x", 2)`. +`child.props["x"]` is `2`. -MW3. Parent middleware rejects: receives `["x", 1]`, does - NOT call `next`. `child.props["x"]` is unchanged. +MW3. Parent middleware rejects: receives `["x", 1]`, does NOT call `next`. +`child.props["x"]` is unchanged. MW4. Middleware on `update` receives the `[key, fn]` tuple. @@ -253,14 +212,14 @@ MW6. Middleware on `sort` can intercept sort installation. ### 6.2 Core: Scope Isolation -MW7. Middleware installed in node A's scope does NOT - intercept operations in node A's sibling B. +MW7. Middleware installed in node A's scope does NOT intercept operations in +node A's sibling B. -MW8. Middleware installed in a parent's scope intercepts - operations in all descendants (scope inheritance). +MW8. Middleware installed in a parent's scope intercepts operations in all +descendants (scope inheritance). -MW9. After `remove()` on a node, its middleware is inactive. - Subsequent operations in sibling nodes are not affected. +MW9. After `remove()` on a node, its middleware is inactive. Subsequent +operations in sibling nodes are not affected. --- @@ -268,49 +227,41 @@ MW9. After `remove()` on a node, its middleware is inactive. ### 7.1 Core: Basic Dispatch -ED1. Dispatch an event. Root middleware receives it. - Returns `{ ok: true, value: true }`. Result indicates - handled. +ED1. Dispatch an event. Root middleware receives it. Returns +`{ ok: true, value: true }`. Result indicates handled. -ED2. Dispatch an event with no middleware installed beyond - the default handler. Result is `{ ok: false }` — - unhandled. +ED2. Dispatch an event with no middleware installed beyond the default handler. +Result is `{ ok: false }` — unhandled. -ED3. Dispatch two events sequentially. Each is processed - in order. The second event's middleware sees state - changes from the first. +ED3. Dispatch two events sequentially. Each is processed in order. The second +event's middleware sees state changes from the first. ### 7.2 Core: Error Capture -ED4. Middleware throws an exception. Result is - `{ ok: false, error }`. The tree is still alive. - Subsequent dispatches work normally. +ED4. Middleware throws an exception. Result is `{ ok: false, error }`. The tree +is still alive. Subsequent dispatches work normally. -ED5. Middleware throws in a child scope. The error is - captured. The child node is NOT destroyed (the dispatch - loop catches the error, the scope survives). +ED5. Middleware throws in a child scope. The error is captured. The child node +is NOT destroyed (the dispatch loop catches the error, the scope survives). ### 7.3 Core: Middleware Composition -ED6. Root installs dispatch middleware. Child installs - dispatch middleware. Event flows through root - middleware first (outermost), then child middleware. +ED6. Root installs dispatch middleware. Child installs dispatch middleware. +Event flows through root middleware first (outermost), then child middleware. -ED7. Root middleware can short-circuit by not calling `next`. - Child middleware never sees the event. +ED7. Root middleware can short-circuit by not calling `next`. Child middleware +never sees the event. -ED8. Child middleware handles an event (returns handled - result). Root middleware's `next` receives the child's - result. +ED8. Child middleware handles an event (returns handled result). Root +middleware's `next` receives the child's result. ### 7.4 Core: Sequential Processing -ED9. Dispatch event A, then event B while A is still - processing. B is queued. B is processed after A - completes. +ED9. Dispatch event A, then event B while A is still processing. B is queued. B +is processed after A completes. -ED10. Within one dispatch cycle, all property mutations are - visible to subsequent middleware in the same chain. +ED10. Within one dispatch cycle, all property mutations are visible to +subsequent middleware in the same chain. --- @@ -318,51 +269,42 @@ ED10. Within one dispatch cycle, all property mutations are ### 8.1 Core: Creation -TN1. `createTree(root)` returns a Tree. `tree.root` is a - Node. `tree.root.parent` is `undefined`. +TN1. `createTree(root)` returns a Tree. `tree.root` is a Node. +`tree.root.parent` is `undefined`. -TN2. The root component runs before `createTree` returns - (T6). Properties set during initialization are - visible on `tree.root.props`. +TN2. The root component runs before `createTree` returns (T6). Properties set +during initialization are visible on `tree.root.props`. -TN3. Middleware installed by the root component during - initialization is active for the first dispatched - event. +TN3. Middleware installed by the root component during initialization is active +for the first dispatched event. ### 8.2 Core: Notification Stream -TN4. Subscribe to tree stream. Call `set` on any node. - Stream emits `void`. +TN4. Subscribe to tree stream. Call `set` on any node. Stream emits `void`. -TN5. Subscribe to tree stream. Call `append`. Stream emits - `void`. +TN5. Subscribe to tree stream. Call `append`. Stream emits `void`. -TN6. Subscribe to tree stream. Call `remove`. Stream emits - `void`. +TN6. Subscribe to tree stream. Call `remove`. Stream emits `void`. -TN7. Within one dispatch cycle, call `set` three times on - different nodes. Stream emits exactly ONE `void` - (coalescing, T9). +TN7. Within one dispatch cycle, call `set` three times on different nodes. +Stream emits exactly ONE `void` (coalescing, T9). -TN8. Within one dispatch cycle, call `set` and `append`. - Stream emits exactly ONE `void` (T10). +TN8. Within one dispatch cycle, call `set` and `append`. Stream emits exactly +ONE `void` (T10). -TN9. Dispatch an event whose middleware makes no property or - structural changes. Stream MUST NOT emit (T8). +TN9. Dispatch an event whose middleware makes no property or structural changes. +Stream MUST NOT emit (T8). ### 8.3 Core: Initialization Notification -TN10. Root component calls `set` during initialization - (before any dispatch). Stream emits a notification - (T11). +TN10. Root component calls `set` during initialization (before any dispatch). +Stream emits a notification (T11). ### 8.4 Core: Lifecycle -TN11. Destroy the tree (exit its Effection scope). The - output stream closes. +TN11. Destroy the tree (exit its Effection scope). The output stream closes. -TN12. Dispatch an event after the tree is destroyed. - No error, no effect (T14). +TN12. Dispatch an event after the tree is destroyed. No error, no effect (T14). --- @@ -372,22 +314,20 @@ TN12. Dispatch an event after the tree is destroyed. CP1. A component's generator runs when the node is created. -CP2. A component that returns without looping — the node - remains alive (P4). Properties set during execution - persist. Middleware installed during execution remains - active. +CP2. A component that returns without looping — the node remains alive (P4). +Properties set during execution persist. Middleware installed during execution +remains active. -CP3. A component in an infinite loop continues reacting - until the node is removed. +CP3. A component in an infinite loop continues reacting until the node is +removed. -CP4. A component can set properties, install middleware, and - append children before returning or entering a loop (P6). +CP4. A component can set properties, install middleware, and append children +before returning or entering a loop (P6). ### 9.2 Core: Cancellation -CP5. When a node is removed, the component's operation is - cancelled. `finally` blocks in the component run - (Effection structured concurrency). +CP5. When a node is removed, the component's operation is cancelled. `finally` +blocks in the component run (Effection structured concurrency). --- @@ -395,47 +335,37 @@ CP5. When a node is removed, the component's operation is ### 10.1 Core -IV1. After any sequence of operations, the node tree and - scope tree are isomorphic (I1). Verify by walking both - trees and comparing structure. +IV1. After any sequence of operations, the node tree and scope tree are +isomorphic (I1). Verify by walking both trees and comparing structure. -IV2. After removing a subtree, no orphaned nodes exist (I2). - Verify that removed nodes do not appear in any - `children` iteration. +IV2. After removing a subtree, no orphaned nodes exist (I2). Verify that removed +nodes do not appear in any `children` iteration. -IV3. After removing a node, its middleware is inactive (I3). - Verify by dispatching an event and confirming the - removed node's middleware is not called. +IV3. After removing a node, its middleware is inactive (I3). Verify by +dispatching an event and confirming the removed node's middleware is not called. -IV4. Two concurrent dispatches do not interleave (I4). - Verify by dispatching two events that each set a - property, and confirming the final state reflects - sequential execution. +IV4. Two concurrent dispatches do not interleave (I4). Verify by dispatching two +events that each set a property, and confirming the final state reflects +sequential execution. -IV5. After a notification, `node.props` and `node.children` - reflect the final state of all mutations from the - triggering cycle (I5). +IV5. After a notification, `node.props` and `node.children` reflect the final +state of all mutations from the triggering cycle (I5). -IV6. No invalid JsonValue can exist in any node's property - bag at any time (I6). +IV6. No invalid JsonValue can exist in any node's property bag at any time (I6). -IV7. Node fields are read-only (I8). Direct mutation of - `id`, `name`, `props`, `children`, or `parent` is - rejected or has no effect. +IV7. Node fields are read-only (I8). Direct mutation of `id`, `name`, `props`, +`children`, or `parent` is rejected or has no effect. --- ## 11. Explicit Non-Tests -The following scenarios are explicitly NOT tested because -they correspond to deferred extensions (§11 of the spec): - -NT1. JSX transform producing `append` calls. -NT2. Key-based reconciliation of children. -NT3. Computed/derived properties. -NT4. Tree query operations. -NT5. DOM-style event bubbling or capturing phases. -NT6. Structured change records on the output stream. -NT7. Application-level demux middleware (tested by apps). -NT8. Event helper functions like `onkeydown` (app-level). -NT9. Explicit index ordering mode. +The following scenarios are explicitly NOT tested because they correspond to +deferred extensions (§11 of the spec): + +NT1. JSX transform producing `append` calls. NT2. Key-based reconciliation of +children. NT3. Computed/derived properties. NT4. Tree query operations. NT5. +DOM-style event bubbling or capturing phases. NT6. Structured change records on +the output stream. NT7. Application-level demux middleware (tested by apps). +NT8. Event helper functions like `onkeydown` (app-level). NT9. Explicit index +ordering mode. diff --git a/tasks/build-jsr.ts b/tasks/build-jsr.ts new file mode 100644 index 0000000..bf45f1c --- /dev/null +++ b/tasks/build-jsr.ts @@ -0,0 +1,15 @@ +import jsonDeno from "../deno.json" with { type: "json" }; + +const [version] = Deno.args; + +if (!version) { + throw new Error("a version argument is required to build the jsr package"); +} + +await Deno.writeTextFile( + new URL("../deno.json", import.meta.url), + JSON.stringify({ + ...jsonDeno, + version, + }), +); diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts new file mode 100644 index 0000000..34352fd --- /dev/null +++ b/tasks/build-npm.ts @@ -0,0 +1,44 @@ +import { build, emptyDir } from "dnt"; + +const outDir = "./build/npm"; + +await emptyDir(outDir); + +const [version] = Deno.args; +if (!version) { + throw new Error("a version argument is required to build the npm package"); +} + +await build({ + entryPoints: ["./mod.ts"], + outDir, + shims: { + deno: false, + }, + scriptModule: false, + test: false, + typeCheck: false, + compilerOptions: { + lib: ["ESNext", "DOM", "DOM.Iterable"], + target: "ES2020", + sourceMap: true, + }, + package: { + name: "@frontside/freedom", + version, + description: + "A general-purpose abstract component tree built on Effection structured concurrency", + license: "ISC", + repository: { + type: "git", + url: "git+https://github.com/thefrontside/freedom.git", + }, + bugs: { + url: "https://github.com/thefrontside/freedom/issues", + }, + engines: { + node: ">= 20", + }, + sideEffects: false, + }, +}); diff --git a/test/focus.test.ts b/test/focus.test.ts index 1414bd4..ae34c05 100644 --- a/test/focus.test.ts +++ b/test/focus.test.ts @@ -2,15 +2,15 @@ import { describe, it } from "../test/suite.ts"; import { expect } from "../test/helpers.ts"; import { run } from "effection"; import { - useTree, - set, + advance, append, - useFocus, + current, + focus, focusable, - advance, retreat, - focus, - current, + set, + useFocus, + useTree, } from "../mod.ts"; describe("Focus installation", () => { diff --git a/test/freedom.test.ts b/test/freedom.test.ts index 032dce6..41a9168 100644 --- a/test/freedom.test.ts +++ b/test/freedom.test.ts @@ -1,16 +1,16 @@ -import { describe, it, expect } from "../test/suite.ts"; +import { describe, expect, it } from "../test/suite.ts"; import { run, sleep } from "effection"; import { - useTree, + append, + DispatchApi, + FreedomApi, get, set, - update, - unset, - append, sort, + unset, + update, useNode, - FreedomApi, - DispatchApi, + useTree, } from "../mod.ts"; describe("JsonValue validation", () => { diff --git a/test/helpers.ts b/test/helpers.ts index 2215450..76083d0 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,6 +1,6 @@ import { expect as stdExpect } from "@std/expect"; import type { Expected } from "@std/expect"; -import { sleep, type Operation } from "effection"; +import { type Operation, sleep } from "effection"; import type { Node } from "../mod.ts"; interface NodeExpected extends Expected {