@sylwellsoftware/fray 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,462 +1,174 @@
1
1
  # Fray
2
2
 
3
- Fray is a browser-only component and DOM runtime built around
4
- Glue emitters. It targets modern evergreen browsers. Its `0.x` API may change
5
- with documented migration notes.
3
+ Fray is a browser-only TypeScript component runtime built around Glue
4
+ emitters. It provides TSX rendering, explicit component lifecycle, accessible
5
+ controls and data views, scoped services and routing, and dependency-collected
6
+ structural CSS.
6
7
 
7
- Install it together with its Glue peer:
8
+ Fray 1.x is ESM-only and targets current evergreen browsers. Install it with
9
+ its Glue peer:
8
10
 
9
11
  ```bash
10
12
  pnpm add @sylwellsoftware/glue @sylwellsoftware/fray
11
13
  ```
12
14
 
13
- Fray is ESM-only and targets the current and previous major versions of
14
- Chromium, Firefox, and Safari at candidate time. Its reproducible test matrix
15
- uses Playwright's pinned Chromium, Firefox, and WebKit builds. Repository
16
- tooling requires Node 22+ and pnpm 10; Fray's runtime itself is browser-only.
15
+ ## Design and ownership
17
16
 
18
- ## Why Fray
19
-
20
- Fray presents application values without requiring developers to translate
21
- them into a second UI-specific state system. A control writes the same Glue
22
- emitter that a derivation or query can observe, and a component renders the
23
- downstream value it actually needs. State remains owned and explicit, while
24
- reactive propagation and rendering mechanics stay library concerns.
25
-
26
- Presentation should be equally direct. Native HTML already defines buttons,
27
- inputs, tables, lists, progress, dialogs, and landmarks, so Fray uses those
28
- elements when their semantics match. Components that need another boundary use
29
- readable light-DOM host names rather than framework identity classes.
30
- Within a component host, Fray uses classes for owned parts, purely visual
31
- states with no semantic equivalent, and meaningful reusable traits; those
32
- classes do not identify components. Native and ARIA state attributes remain the
33
- sole semantic state source and are targeted directly by CSS. Renderer markers,
34
- diagnostics, interoperability, and actual data use `data-*`; Fray does not use
35
- `data-*` as its routine component-CSS hook.
36
-
37
- ## Design model
38
-
39
- Fray is the presentation half of a deliberately two-layer architecture:
17
+ Fray presents application values without moving them into a second UI-specific
18
+ state system. Controls write ordinary Glue emitters, components read the
19
+ downstream values they need, and applications retain ownership of domain
20
+ policy and asynchronous work.
40
21
 
41
22
  ```text
42
- consumer application
43
- domain policy, composition, endpoints, active theme/color
44
-
45
-
23
+ application
24
+ domain policy, composition, services, endpoints, routes, theme selection
25
+
26
+
46
27
  Fray
47
- TSX/h(), components, DOM, events, lifecycle, structural CSS
48
- │ get / subscribe / set
49
-
28
+ TSX, DOM, events, lifecycle, accessibility, structural presentation
29
+ │ get / subscribe / set
30
+
50
31
  Glue
51
- mutable and derived values, live queries, status, causality
32
+ mutable values, derived values, live queries, commands, diagnostics
52
33
  ```
53
34
 
54
- The libraries share a protocol, not a monolithic application framework. Glue
55
- remains usable without a UI; Fray does not introduce hooks, a hidden component
56
- state store, a query language, or transport policy to compete with Glue.
57
-
58
- Fray follows these design rules:
59
-
60
- - **Declarative structure, ordinary TypeScript logic.** TSX or `h()` describes
61
- the current DOM. Normal methods and event handlers express algorithms and
62
- commands.
63
- - **Small components compose into larger widgets.** A table, for example, is
64
- assembled from headers, cells, filtering, selection, loading, and error
65
- pieces instead of becoming one opaque primitive.
66
- - **Use the browser.** Native elements and semantics are preferred for inputs,
67
- buttons, labels, tables, progress, dialogs, and landmarks. Fray's custom host
68
- names are light-DOM ownership/styling hooks, not registered Web Components.
69
- - **One reactive model.** Shared or composable state lives in Glue emitters;
70
- derived values replace manually mirrored state; live data lives in
71
- `LiveQuery`. Short-lived presentation details may remain explicit component
72
- fields when no other object must observe them.
73
- - **Explicit ownership and cleanup.** Components own the child components,
74
- subscriptions, listeners, emitters, and queries they create, and release
75
- them with their lifecycle.
76
- - **Stable browser state during updates.** The synchronous keyed patcher
77
- preserves compatible DOM nodes, focus, selection, input state, and event
78
- listener cardinality while reconciling a component's new vnode tree.
79
- - **Progressive tooling.** JSX and generated structural CSS are build-time
80
- conveniences over the same small runtime contracts; they are not separate
81
- execution models.
82
-
83
- Responsibility stays at the narrowest layer that understands it:
35
+ The boundaries are deliberate:
84
36
 
85
37
  | Concern | Owner |
86
38
  | --- | --- |
87
- | Domain state, service implementations/providers, endpoint configuration, page composition, theme availability and selection policy | Application |
88
- | DOM structure, native events, accessibility, component lifetime, service-scope propagation, visual async states | Fray components/runtime |
89
- | Mutable/computed values, query timing/results, fetch state, optional causality | Glue |
90
- | Remote wire serialization and retrieval mechanism | Injected Glue query handler/application adapter |
91
- | Browser navigation placement, restoration, and routed component integration | Caller-owned Fray router with an injected navigation adapter |
92
- | Layout/flow CSS and stable component/part hooks | Fray structural styling |
93
- | Look-and-feel treatment and palette | Separately loaded Fray-compatible theme/color CSS |
94
-
95
- ## Set up a browser application
96
-
97
- Fray ships ESM, TypeScript declarations, automatic/classic JSX runtimes,
98
- variable defaults, replaceable theme treatments, and replaceable color
99
- palettes. Glue is a peer dependency. Applications collect structural CSS from
100
- the component classes they actually declare.
101
-
102
- ```ts
39
+ | Domain state, validation policy, endpoint configuration, service providers, routes, page composition | Application |
40
+ | DOM structure, native events, accessible semantics, component lifetime, visual async states | Fray |
41
+ | Mutable and computed values, query execution and status, command lifecycle, optional causality | Glue |
42
+ | Retrieval, wire serialization, persistence | Application-supplied handlers and adapters |
43
+ | Structural selectors and component layout | Fray component CSS |
44
+ | Theme treatment, palette, application layout | Separately loaded CSS and application CSS |
45
+
46
+ Fray prefers native HTML when it expresses the contract. Custom `fray-*`
47
+ hosts are readable light-DOM ownership and styling boundaries; they are not
48
+ registered custom elements and do not use Shadow DOM.
49
+
50
+ ## Set up TSX
51
+
52
+ Use Fray's automatic JSX runtime:
53
+
54
+ ```json
55
+ {
56
+ "compilerOptions": {
57
+ "jsx": "react-jsx",
58
+ "jsxImportSource": "@sylwellsoftware/fray"
59
+ }
60
+ }
61
+ ```
62
+
63
+ Load the variable base, one color palette, and one theme. Register the root
64
+ component before mounting so Fray can collect its structural CSS dependencies:
65
+
66
+ ```tsx
103
67
  import {Emitter} from '@sylwellsoftware/glue'
104
68
  import {
105
69
  Button,
106
70
  Component,
107
- Header,
108
71
  Panel,
109
- Sidebar,
110
72
  Textbox,
111
73
  Toolbar,
112
74
  createFrayRuntime,
113
- h,
114
75
  } from '@sylwellsoftware/fray'
76
+
115
77
  import '@sylwellsoftware/fray/themes/base.css'
116
78
  import '@sylwellsoftware/fray/colors/iceblue/colors.css'
117
79
  import '@sylwellsoftware/fray/themes/minimal/theme.css'
118
80
 
119
- const name = new Emitter('Ada')
120
-
121
- class App extends Component {
122
- static dependencies = [Button, Panel, Textbox, Toolbar]
81
+ class ProfileApp extends Component {
82
+ readonly name = new Emitter('Ada')
123
83
 
124
84
  render() {
125
- return h(Panel, {
126
- className: 'fray-fill-horizontal fray-fill-vertical',
127
- header: 'Profile',
128
- },
129
- h(Textbox, {label: 'Name', valueEmitter: name}),
130
- h(Toolbar, {label: 'Profile actions'},
131
- h(Button, {label: 'Save', onClick: () => save(name.get())})))
132
- }
133
- }
134
-
135
- const runtime = createFrayRuntime()
136
- runtime.registerStyles(App).injectStyles(document)
137
- runtime.mount(runtime.create(App), document.querySelector('#app')!)
138
-
139
- function save(value: string) {
140
- console.log(value)
141
- }
142
- ```
143
-
144
- Root sizing is application-owned. Add `fray-fill-horizontal` to a rendered
145
- application root to claim exactly `100vw`, `fray-fill-vertical` to claim
146
- exactly `100vh`, or both for a fullscreen application. Each axis supplies its
147
- own root overflow fallback and zero minimum; descendant islands are bounded
148
- and scroll on only the opted-in axes. Other components retain their intrinsic
149
- minimums and their own structural overflow contracts. A root with neither
150
- modifier keeps its ordinary embedded/content-sized behavior.
151
-
152
- The prebuilt structural file targets Fray's default `fray-` hosts. Applications
153
- with custom components or configured host names may instead register their root
154
- dependencies and call `runtime.injectStyles(document)`; collection remains
155
- idempotent and produces one application-scoped structural style element.
156
-
157
- For automatic JSX, configure TypeScript with `"jsx": "react-jsx"` and
158
- `"jsxImportSource": "@sylwellsoftware/fray"`. Classic JSX uses `h` as `jsxFactory` and
159
- `Fragment` as `jsxFragmentFactory`. JSX and `h()` produce the same vnodes.
160
-
161
- The same root can be written with automatic JSX:
162
-
163
- ```tsx
164
- class App extends Component {
165
- render() {
166
- return <Panel header="Profile">
167
- <Textbox label="Name" valueEmitter={name} />
168
- <Button label="Save" onClick={() => save(name.get())} />
85
+ return <Panel
86
+ className="fray-fill-horizontal fray-fill-vertical"
87
+ header="Profile"
88
+ toolbar={<Toolbar label="Profile actions">
89
+ <Button label="Save" onClick={() => this.save()} />
90
+ </Toolbar>}
91
+ >
92
+ <Textbox label="Name" valueEmitter={this.name} />
169
93
  </Panel>
170
94
  }
171
95
 
172
- static dependencies = [Button, Panel, Textbox]
173
- }
174
- ```
175
-
176
- ## Application services without prop-drilling
96
+ onDestroy() {
97
+ this.name.dispose()
98
+ }
177
99
 
178
- Service classes remain ordinary application TypeScript. They commonly group
179
- immutable Glue endpoint declarations, while every `open()` call still creates
180
- a caller-owned live result:
100
+ private save() {
101
+ console.log(this.name.get())
102
+ }
181
103
 
182
- ```ts
183
- class ProjectService {
184
- readonly label = 'Projects'
185
- readonly projects = new RestEndpoint<
186
- {search: string},
187
- readonly Project[]
188
- >({url: '/api/projects', parseResult: parseProjects})
104
+ static dependencies = [Button, Panel, Textbox, Toolbar]
189
105
  }
190
106
 
191
- const projectService = defineService<ProjectService>('projects')
192
- const services = createServiceScope([
193
- provideService(projectService, () => new ProjectService()),
194
- ])
195
- const runtime = createFrayRuntime({services})
107
+ const runtime = createFrayRuntime()
108
+ runtime.registerStyles(ProfileApp).injectStyles(document)
109
+ runtime.mount(runtime.create(ProfileApp), document.querySelector('#app')!)
196
110
  ```
197
111
 
198
- The composition root chooses the implementation once. Every nested class
199
- component created through that runtime inherits the scope:
112
+ `static dependencies` is transitive and idempotent. It declares the Fray and
113
+ application components whose structural CSS the root can render. Applications
114
+ that prefer a complete static asset may import
115
+ `@sylwellsoftware/fray/styles/structural.css` instead of collecting styles.
200
116
 
201
- ```ts
202
- class ProjectList extends Component {
203
- static requiredServices = [projectService]
204
- private service!: ProjectService
117
+ The low-level `h()` vnode factory remains exported for non-JSX integrations,
118
+ but TSX is the documented authoring model for applications and Fray
119
+ components.
205
120
 
206
- initialize() {
207
- this.service = this.requireService(projectService)
208
- }
121
+ ## Components and lifecycle
209
122
 
210
- render() {
211
- return h('output', null, this.service.label)
212
- }
213
- }
214
- ```
215
-
216
- Dependencies must be declared in `static requiredServices`. Resolution is
217
- available during `initialize()` and later, after Fray has assigned the runtime;
218
- constructors cannot resolve services. A missing service fails before component
219
- initialization, and an undeclared lookup also fails clearly.
220
-
221
- Providers are fixed when `ServiceScope` is created. Factories run lazily once
222
- per scope, can explicitly require another registered service, and are checked
223
- for circular resolution. `scope.dispose()` disposes initialized services in
224
- reverse creation order. Components dispose the queries/results they open, not
225
- the shared service. Create one scope per browser application or test. Because
226
- the scope is explicit rather than global, a future non-browser adapter can
227
- preserve request/session isolation without changing service definitions.
123
+ A class component has explicit phases:
228
124
 
229
- There is intentionally no transient resolve-on-every-call lifetime. Independent
230
- query state comes from caller-owned endpoint results. Function components stay
231
- presentation-oriented and receive rendered values or emitters from a nearby
232
- lifecycle-owning class component.
125
+ 1. The constructor stores props and creates local objects, without subscribing
126
+ or rendering.
127
+ 2. `initialize()` runs once after Fray assigns the runtime. Create subscriptions
128
+ or resolve declared services here.
129
+ 3. `render()` returns TSX, a primitive, an emitter child, a component, or an
130
+ array of children.
131
+ 4. `afterMount()` runs after the first DOM commit; `afterUpdate()` runs after
132
+ later commits.
133
+ 5. `onDestroy()` releases resources owned by the component.
233
134
 
234
- ## Hierarchical browser routing
235
-
236
- Routing is optional and application-scoped. Route descriptors name immutable
237
- relative segments; the mounted component hierarchy supplies their parentage.
238
- This lets a `TabPanel` discover immediate routes from ordinary `Tab`
239
- annotations without requiring a duplicate central route tree:
135
+ `watch()` schedules a component update when an observable changes.
136
+ `read(emitter)` returns its value and tracks it only for the current render.
137
+ `snapshot(emitter)` tracks and returns `{value, fetchState, error}`.
138
+ `onCleanup()` registers listeners or other cleanup functions that Fray invokes
139
+ on destruction.
240
140
 
241
141
  ```tsx
242
- import {Emitter} from '@sylwellsoftware/glue'
243
- import {
244
- Component,
245
- RouteLink,
246
- RouteQuery,
247
- RouteUnavailableError,
248
- RouteValue,
249
- Tab,
250
- TabPanel,
251
- createBrowserRouter,
252
- createHashNavigation,
253
- createFrayRuntime,
254
- defineRoute,
255
- defineRouteParameter,
256
- routeTarget,
257
- stringRouteCodec,
258
- waitForRouteValue,
259
- } from '@sylwellsoftware/fray'
260
-
261
- const routes = {
262
- changes: defineRoute('changes'),
263
- security: defineRoute('security'),
264
- overview: defineRoute('security-overview', 'overview'),
265
- projects: defineRoute('security-projects', 'projects'),
266
- }
267
- const activeApplication = new Emitter('changes')
268
- const activeSecurityView = new Emitter('overview')
142
+ class Counter extends Component {
143
+ readonly count = new Emitter(0)
144
+ readonly label = this.count.map((value) => `Count: ${value}`)
269
145
 
270
- class SecurityApplication extends Component {
271
146
  render() {
272
- return <TabPanel valueEmitter={activeSecurityView} label="Security views">
273
- <Tab id="overview" label="Overview" route={routes.overview}>Summary</Tab>
274
- <Tab id="projects" label="Projects" route={routes.projects}>Projects</Tab>
275
- </TabPanel>
147
+ return <Button
148
+ label={this.label}
149
+ onClick={() => this.count.set(this.count.get() + 1)}
150
+ />
276
151
  }
277
152
 
278
- static dependencies = [Tab, TabPanel]
279
- }
280
-
281
- class App extends Component {
282
- render() {
283
- return <>
284
- <nav aria-label="Applications">
285
- <RouteLink to={routeTarget(routes.security, routes.projects)}>
286
- Security projects
287
- </RouteLink>
288
- </nav>
289
- <TabPanel valueEmitter={activeApplication} label="Applications">
290
- <Tab id="changes" label="Changes" route={routes.changes}>Changes</Tab>
291
- <Tab id="security" label="Security" route={routes.security}>
292
- <SecurityApplication />
293
- </Tab>
294
- </TabPanel>
295
- </>
153
+ onDestroy() {
154
+ this.label.dispose()
155
+ this.count.dispose()
296
156
  }
297
157
 
298
- static dependencies = [RouteLink, SecurityApplication, Tab, TabPanel]
158
+ static dependencies = [Button]
299
159
  }
300
-
301
- const router = createBrowserRouter({adapter: createHashNavigation(window)})
302
- const runtime = createFrayRuntime({router})
303
- const app = runtime.mount(runtime.create(App), document.querySelector('#app')!)
304
-
305
- addEventListener('pagehide', () => {
306
- app.destroy()
307
- router.dispose()
308
- }, {once: true})
309
- ```
310
-
311
- The runtime opens the root scope. A selected routed tab opens its own scope for
312
- nested components, so `/security/projects` restores the outer tab first and
313
- then discovers and restores the inner tab. Every scope declares when its
314
- immediate registrations are complete; this distinguishes an unknown child
315
- from one whose parent has not mounted yet. Duplicate IDs or literal paths and
316
- multiple parameter routes in one scope fail at completion. Literal routes take
317
- precedence over the optional parameter route.
318
-
319
- `RouteLink` is a native anchor. A descriptor resolves against the current
320
- lineage, which is convenient for sibling destinations. A link outside the
321
- destination's mounted lineage uses `routeTarget(...)` with the full chain from
322
- the root. Modified clicks, downloads, and non-`_self` targets retain native
323
- browser behavior. `router.navigate(target)` pushes by default;
324
- `router.redirect(target)` and `navigate(target, {history: 'replace'})` replace.
325
- `router.href(target)`, `router.resolve(descriptor, context)`, and
326
- `router.isActive(target, exact)` support advanced composition.
327
-
328
- Dynamic path values use a typed codec and an application-owned nullable
329
- emitter:
330
-
331
- ```tsx
332
- const projectRoute = defineRouteParameter('project', stringRouteCodec, 'project-id')
333
- const selectedProjectId = new Emitter<string | null>(null)
334
-
335
- <RouteValue
336
- route={projectRoute}
337
- valueEmitter={selectedProjectId}
338
- resolve={async (projectId, _context, signal) => {
339
- const projects = await waitForRouteValue(
340
- projectResults,
341
- (items) => items.length > 0,
342
- signal,
343
- )
344
- if (!projects.some(({id}) => id === projectId)) {
345
- throw new RouteUnavailableError(`Unknown project "${projectId}"`)
346
- }
347
- return projectId
348
- }}
349
- >
350
- <ProjectView />
351
- </RouteValue>
352
- ```
353
-
354
- Resolvers run in path order, may normalize the decoded value, and receive the
355
- settled ancestor values plus an `AbortSignal`. A newer navigation or router
356
- disposal aborts outstanding work. `waitForRouteValue` is a convenience for a
357
- Glue readable; fetching, authorization, retries, and domain validation remain
358
- application-owned. Set `scopeChildren` on `RouteValue` only when further route
359
- levels live beneath the dynamic value. Leaving it off avoids remounting an
360
- otherwise stable leaf view when selection changes.
361
-
362
- Explicit query bindings make selected shareable view state routable without
363
- coupling the originating control to the router:
364
-
365
- ```tsx
366
- <RouteQuery
367
- name="range"
368
- valueEmitter={historyRange}
369
- codec={historyRangeCodec}
370
- defaultValue="12m"
371
- >
372
- <HistoryView />
373
- </RouteQuery>
374
- ```
375
-
376
- Defaults are omitted, owned names sort deterministically, foreign query keys
377
- are preserved, and passive emitter changes replace the current URL. Invalid
378
- owned values reset to the default and produce a structured `router.issue`.
379
- Path failures similarly fall back to the deepest resolved parent and replace
380
- the failed entry. Applications observe `router.transition` for pending/idle
381
- state and render `router.issue` as localized, accessible feedback. An explicit
382
- navigation clears the issue.
383
-
384
- Choose `createHashNavigation(window)` for static hosting; it reserves the URL
385
- fragment. Choose `createHistoryNavigation(window, {basePath: '/app/'})` for
386
- ordinary paths; the deployment must serve the application entry point for
387
- direct requests below that base. `MemoryNavigationAdapter` supplies
388
- deterministic tests and does not imply server rendering. The application owns
389
- and disposes the router; destroying a runtime root removes mounted route
390
- registrations but does not dispose the caller-owned router.
391
-
392
- ## Component host elements
393
-
394
- Fray components with a wrapper render a standards-valid custom host element,
395
- not a framework identity class. The default application runtime therefore
396
- produces DOM such as:
397
-
398
- ```html
399
- <fray-panel data-fray class="island" data-fray-component="panel">
400
- <fray-textbox data-fray data-fray-component="textbox">
401
- <input data-fray type="text">
402
- </fray-textbox>
403
- <button data-fray data-fray-component="button">Save</button>
404
- </fray-panel>
405
160
  ```
406
161
 
407
- Native semantics remain native: `Button` renders `button`, `Toggle` renders
408
- `fieldset`, `Sidebar` renders `aside`, and table header components render
409
- `thead`/`th`. `Tab` is a declarative child consumed by `TabPanel` and has no
410
- independent root. The
411
- `data-fray-component` keeps diagnostics unambiguous. It is not a structural or
412
- theme selector. Fray may merge public presentation traits such as `island`
413
- with an application-supplied `class`/`className`; those traits describe a
414
- reusable capability, not component identity.
415
-
416
- Pass `island` to a fixed-host component when that surface should be visually
417
- separated from the page. Fray adds the reusable `island` class to its host;
418
- applications may use the same class on deliberate native surface boundaries.
419
- The component-owned rule consumes `--island-*` variables, so an island theme
420
- can add gutters, an edge, and elevation while a flat theme can leave the
421
- modifier visually neutral. Islands are one surface layer: nesting an `island`
422
- component below another island is rejected. Application-authored native island
423
- classes must follow the same no-nesting invariant. A surface island never
424
- creates a nested theme or palette scope.
425
-
426
- Built-in host names are fixed public DOM: Fray adds its one required custom
427
- element hyphen and removes internal word separators from the component stem.
428
- For example, `Panel`, `ListView`, and `ThemePicker` render as `<fray-panel>`,
429
- `<fray-listview>`, and `<fray-themepicker>`. They cannot be prefixed, renamed,
430
- or made prefix-free at runtime. This lets distributed stylesheets target hosts
431
- directly and predictably.
432
-
433
- Every element created by Fray's renderer also receives the boolean `data-fray`
434
- attribute. Themes can therefore target native Fray output without affecting
435
- other UI libraries: `button[data-fray]`, `input[data-fray]`, and
436
- `dialog[data-fray]`. The marker is renderer-owned and cannot be removed through
437
- component props. `data-fray-component` remains diagnostic metadata, not an
438
- ordinary styling selector.
439
-
440
- Create the runtime once at application startup, then create and mount the root
441
- through that runtime. Styles live in the document's global cascade because the
442
- hosts are deliberately unregistered light-DOM elements, not Web Components or
443
- Shadow DOM boundaries.
162
+ Fray's synchronous keyed reconciler preserves compatible DOM and component
163
+ identity, focus, cursor and native input state, and event-listener cardinality.
164
+ Use stable `key` values for reordered siblings. Never reuse one component
165
+ instance under two owners.
444
166
 
445
- ## Reactive templates
446
-
447
- TypeScript TSX is Fray's supported template syntax. Classic JSX and direct
448
- `h()` calls are equivalent frontends: both lower to the same vnode tree and
449
- use the same renderer. A future template-file syntax can target this vnode
450
- contract without changing Glue binding semantics.
451
-
452
- Fray's built-ins are authored in TSX. Direct
453
- `h()` remains supported for consumers that do not use JSX and as the renderer's
454
- canonical vnode operation, but it is not the built-in component authoring
455
- format. The workspace lint gate rejects new `h()` templates under
456
- `packages/fray/src/Components`.
167
+ ### Custom component hosts
457
168
 
458
- Custom hosts are available inside a TSX component through its protected `Host`
459
- template component:
169
+ Wrapped components declare a host stem and render `this.Host`. The runtime maps
170
+ the stem to one fixed, standards-valid name by removing internal hyphens and
171
+ prefixing `fray-`:
460
172
 
461
173
  ```tsx
462
174
  interface BadgeProps extends ComponentProps {
@@ -472,105 +184,37 @@ class Badge extends Component<BadgeProps> {
472
184
  }
473
185
 
474
186
  static override hostName = 'badge'
187
+ static override css = css`
188
+ & { display: inline-flex; }
189
+ &[data-tone="positive"] { color: var(--palette-green); }
190
+ `
475
191
  }
476
192
  ```
477
193
 
478
- At runtime that template produces `<fray-badge>`. `hostName` is a stable
479
- lowercase kebab-case component identity; Fray derives the fixed host spelling.
480
- Native-root components such as `Button` use their native tag directly in TSX
481
- instead of `Host`.
482
-
483
- Glue values have explicit behavior at each template boundary:
484
-
485
- | Template form | Meaning |
486
- | --- | --- |
487
- | `{emitter}` | Render the current value and patch only that child range on emission. |
488
- | `<Child source={emitter} />` | Pass the emitter object unchanged; the child owns how it consumes it. |
489
- | `prop={live(emitter)}` | Subscribe a DOM property or a component-declared live prop one way to the emitter's current value. |
490
- | `<input bind:value={emitter} />` | Bind a writable string emitter and native `value` two ways. |
491
- | `<input bind:checked={emitter} />` | Bind a writable boolean emitter and native `checked` two ways. |
492
- | `this.read(emitter)` | Read during `render()` and rerender the component while that dependency is used. |
493
- | `this.snapshot(emitter)` | Track and read `{value, fetchState, error}` for stateful rendering. |
494
-
495
- Direct child and `live()` subscriptions are renderer-owned and are released
496
- when their nodes disappear. Render-time `read()`/`snapshot()` dependencies are
497
- reconciled after every render, so conditional dependencies are also released.
498
- The component still owns and disposes emitters it creates. Direct rendering
499
- uses only an emitter's value; use `snapshot()` when loading and error state
500
- must affect the markup.
501
-
502
- Component props do not implicitly unwrap emitters. A raw emitter prop passes
503
- the emitter object to the component, while `live(emitter)` passes its current
504
- value and subscribes at the renderer boundary. Every class component has an
505
- explicit live-prop contract and rejects `live()` outside its allowlist in both
506
- typed templates and at runtime. `live()` is reserved for small render-time
507
- state such as availability, validation, and busy/pressed state. Identity,
508
- callbacks, initial values, structural collections, and emitter ownership are
509
- ordinary props. A data prop documented as accepting a readable emitter is a
510
- separate input-source contract, not a `live()` prop.
511
-
512
- This complete example uses mutable and derived emitters, direct emitter
513
- children, raw emitter props, native two-way bindings, a one-way live property,
514
- conditional tracked state, and a `LiveQuery` passed to a child:
194
+ The `&` selector resolves against the concrete host during style collection.
195
+ Native-root components render their native element directly. Fray-created DOM
196
+ has `data-fray` for diagnostics, but component styling uses the owning host,
197
+ native/ARIA state, fixed part elements, and meaningful traits rather than data
198
+ attributes as routine CSS hooks.
515
199
 
516
- ```tsx
517
- import {
518
- DerivedEmitter,
519
- Emitter,
520
- FetchState,
521
- LiveQuery,
522
- QueryHandler,
523
- } from '@sylwellsoftware/glue'
524
- import type {ReadableEmitter} from '@sylwellsoftware/glue'
525
- import {
526
- Button,
527
- Component,
528
- Panel,
529
- Textbox,
530
- createFrayRuntime,
531
- live,
532
- } from '@sylwellsoftware/fray'
533
- import type {ComponentProps, WritableEmitter} from '@sylwellsoftware/fray'
200
+ ## Reactive templates
534
201
 
535
- interface Change {
536
- id: number
537
- title: string
538
- }
202
+ Fray exposes four distinct reactive forms. Choose the form that matches the
203
+ ownership boundary.
539
204
 
540
- const fixtures: readonly Change[] = [
541
- {id: 101, title: 'Add release attestations'},
542
- {id: 102, title: 'Migrate the demo to TypeScript'},
543
- ]
544
-
545
- class ChangeQueryHandler extends QueryHandler<{filter: string}, readonly Change[]> {
546
- override fetch({filter}: {filter: string}): readonly Change[] {
547
- const needle = filter.trim().toLocaleLowerCase()
548
- return needle === ''
549
- ? fixtures
550
- : fixtures.filter(({title}) =>
551
- title.toLocaleLowerCase().includes(needle))
552
- }
553
- }
205
+ ### Tracked reads
554
206
 
555
- interface PreviewProps extends ComponentProps {
556
- heading: ReadableEmitter<string, unknown>
557
- approved: WritableEmitter<boolean>
558
- }
207
+ Use `read()` when control flow or an ordinary value depends on an emitter. Use
208
+ `snapshot()` when loading and error state matter:
559
209
 
560
- class Preview extends Component<PreviewProps> {
561
- render() {
562
- return <section aria-label="Live change preview">
563
- <h3>{this.props.heading}</h3>
564
- <label>
565
- <input type="checkbox" bind:checked={this.props.approved} />
566
- Approved
567
- </label>
568
- </section>
569
- }
210
+ ```tsx
211
+ interface Item {
212
+ id: string
213
+ label: string
570
214
  }
571
215
 
572
216
  interface ResultsProps extends ComponentProps {
573
- results: ReadableEmitter<readonly Change[] | undefined, unknown>
217
+ results: ReadableEmitter<readonly Item[] | undefined>
574
218
  }
575
219
 
576
220
  class Results extends Component<ResultsProps> {
@@ -580,270 +224,98 @@ class Results extends Component<ResultsProps> {
580
224
  return <p role="alert">{String(error)}</p>
581
225
  }
582
226
  return <ul aria-busy={fetchState === FetchState.Loading}>
583
- {(value ?? []).map(({id, title}) => <li key={id}>{title}</li>)}
227
+ {(value ?? []).map((item) => <li key={item.id}>{item.label}</li>)}
584
228
  </ul>
585
229
  }
586
230
  }
587
-
588
- class ChangeApp extends Component {
589
- readonly title = new Emitter('Add native Glue template bindings')
590
- readonly approved = new Emitter(false)
591
- readonly filter = new Emitter('')
592
- readonly showPreview = new Emitter(true)
593
- readonly heading = new DerivedEmitter(
594
- [this.title, this.approved] as const,
595
- ([title, approved]) => `${title} — ${approved ? 'approved' : 'draft'}`,
596
- )
597
- readonly results = new LiveQuery({
598
- handler: new ChangeQueryHandler(),
599
- args: {filter: this.filter},
600
- })
601
-
602
- render() {
603
- return <Panel header={this.heading}>
604
- <Textbox label="Title" valueEmitter={this.title} />
605
- <label>Search <input bind:value={this.filter} /></label>
606
-
607
- <output title={live(this.heading)}>Current title: {this.title}</output>
608
-
609
- <label>
610
- <input type="checkbox" bind:checked={this.showPreview} />
611
- Show preview
612
- </label>
613
- {this.read(this.showPreview)
614
- ? <Preview heading={this.heading} approved={this.approved} />
615
- : null}
616
-
617
- <Results results={this.results} />
618
- <Button
619
- label="Refresh results"
620
- onClick={() => void this.results.refresh()}
621
- />
622
- </Panel>
623
- }
624
-
625
- onDestroy(): void {
626
- this.results.dispose()
627
- this.heading.dispose()
628
- this.showPreview.dispose()
629
- this.filter.dispose()
630
- this.approved.dispose()
631
- this.title.dispose()
632
- }
633
-
634
- static dependencies = [Button, Panel, Preview, Results, Textbox]
635
- }
636
-
637
- const runtime = createFrayRuntime()
638
- runtime.registerStyles(ChangeApp).injectStyles(document)
639
- runtime.mount(runtime.create(ChangeApp), document.querySelector('#app')!)
640
231
  ```
641
232
 
642
- Emitter props are raw by default on purpose: automatically unwrapping every
643
- prop would make it impossible for controls such as `Textbox` and application
644
- components such as `Preview` to receive a stable emitter. Use `live()` only
645
- when the receiver expects a scalar prop and one-way updates are desired.
233
+ The surrounding component rerenders when a tracked source changes, and Fray
234
+ reconciles the tracked source set after every render.
646
235
 
647
- ## Glue integration and ownership
236
+ ### Fine-grained emitter children
648
237
 
649
- The smallest Fray/Glue seam is a current value plus subscription:
238
+ A readable emitter in child position updates only its owned DOM range:
650
239
 
651
- ```text
652
- readable: get() + subscribe(listener)
653
- writable: readable contract + set(next)
240
+ ```tsx
241
+ <output>Current name: {name}</output>
654
242
  ```
655
243
 
656
- Choose the integration mechanism according to what should update:
244
+ An emitter passed as a normal component prop remains the same object. Fray does
245
+ not inspect arbitrary prop values or discover dependencies implicitly.
657
246
 
658
- - render an emitter as a child or use `live(emitter)` for fine-grained scalar
659
- DOM/component updates;
660
- - use `this.read(emitter)` when the component's structure depends on its value;
661
- - use `this.snapshot(emitter)` when loading/error state affects the structure;
662
- - pass the emitter object as a normal prop when a child control owns the
663
- interaction;
664
- - use callbacks for one-way commands and emitters for values that must be read,
665
- composed, or observed elsewhere.
247
+ ### One-way live properties
666
248
 
667
- A data-aware component normally watches the downstream value it renders rather
668
- than every upstream input. Leaf controls write ordinary emitters; a coordinator
669
- owns any semantic `DerivedEmitter`; a data owner constructs or receives the
670
- `LiveQuery`; the injected handler alone owns transport serialization.
249
+ `live()` updates a DOM property or a component-declared live prop without
250
+ rerendering its parent:
671
251
 
672
- ```text
673
- control event ──► Emitter ──► DerivedEmitter ──► LiveQuery
674
- │ │ │
675
- └──────────────┴────────────────┘
676
- Fray view
252
+ ```tsx
253
+ <Button label="Submit" disabled={live(submitting)} />
254
+ <output title={live(summary)}>{summary}</output>
677
255
  ```
678
256
 
679
- Construction itself is side-effect-free with respect to DOM mounting.
680
- `initialize()` establishes component subscriptions, mounting creates/attaches
681
- the rendered tree, updates reconcile it, and `destroy()` releases renderer and
682
- component-owned resources. State created by a component should be disposed in
683
- `onDestroy()` as shown above. State supplied through props remains owned by the
684
- caller unless an API explicitly says otherwise.
685
-
686
- For asynchronous views, keep the widget's semantic structure present whenever
687
- practical and render initial loading, refresh-with-previous-data, empty, error,
688
- and ready states explicitly. Glue owns the query state transition; Fray owns
689
- how that state is presented.
690
-
691
- ## State convention
692
-
693
- Every stateful control follows one ownership rule:
694
-
695
- - pass a writable `valueEmitter` to share and externally update state;
696
- - otherwise pass `defaultValue` to initialize the control's owned emitter;
697
- - read the active emitter from the component instance's `valueEmitter`
698
- property;
699
- - use `onInput` or `onChange` to observe user actions. The callback runs after
700
- the emitter changes and does not replace the emitter contract.
701
-
702
- `value` remains an initial-value alias for imported `0.x` call-site
703
- compatibility; it is not a prop-driven controlled mode. Prefer `valueEmitter`
704
- or `defaultValue` in new code.
705
-
706
- ## Stable component reference
707
-
708
- All components also accept `children`, `className` (`class` is an alias), an
709
- `island` surface modifier, and a sibling-local `key` through the common
710
- component props. The modifier is static presentation input, not a `live()`
711
- property.
712
-
713
- | Component | Important props | User callback | State/emitter behavior |
714
- | --- | --- | --- | --- |
715
- | `Button` | `label`, `type`, `disabled`, `pressed`, `busy`, `busyLabel`, `ariaLabel`, native `id`/`name`/`value`/`title` | `onClick(event)` | Stateless native button; busy state disables activation and is presentation only. |
716
- | `Toolbar` | `label`, `orientation`, `id`, `children` | None | Stateless named toolbar; orientation is horizontal or vertical. |
717
- | `Textbox` | `label` or `ariaLabel`, value props, `disabled`, `required`, `readOnly`, `error`, native text constraints, `inputRef` | `onInput(value, event)`, `onChange(value, event)` | String `valueEmitter`; external emitter changes patch the native input without replacing it. |
718
- | `Dropdown<T>` | `options`, `label` or `ariaLabel`, value props, `disabled`, `required`, `error`, `placeholder`, `name` | `onChange(value, event)` | Typed string/number `valueEmitter`; `options` may be an array or readable emitter. |
719
- | `RadioButton` | `label`, `name`, `value`, `checked`, `disabled`, `required`, `error` | `onChange(checked, event)` | Native radio input with a labelled fixed shell; `checked`, `disabled`, `required`, and `error` support `live()`. |
720
- | `RadioGroup<T>` | Plain-array `options`, `label` or `ariaLabel`, value props, `name`, `disabled`, `required`, `error` | `onChange(value, event)` | Native radio inputs with one selected `valueEmitter`; `disabled`, `required`, and `error` support `live()`. |
721
- | `Toggle<T>` | `options`, `label` or `ariaLabel`, value props, `disabled`, `required`, `error` | `onChange(value, event)` | One selected value; `disabled`, `required`, and `error` support `live()`. |
722
- | `Checkbox<T>` | `symbols`, `label`, value props, `disabled`, `required`, `error`, `name` | `onChange(value, event)` | Two-state semantic value by default; `disabled`, `required`, and `error` support `live()`. |
723
- | `TriCheckbox` | Checkbox props except `symbols` | `onChange(value, event)` | Cycles deny → neutral → prefer using `FilterMode`. |
724
- | `QuadCheckbox` | Checkbox props except `symbols` | `onChange(value, event)` | Cycles deny → neutral → prefer → require using `FilterMode`. |
725
- | `Header` | `id`, `headingId`, `level`, `children` | None | Styled heading surface using a native `h1`–`h6`; level defaults to `2`. |
726
- | `Panel` | `header`, `toolbar`, `orientation`, `disabled`, `id`, `children` | None | Stateless labelled section that composes Header when header content exists; `disabled` describes the region but does not mutate descendant controls. |
727
- | `Sidebar` | `header`, `toolbar`, `ariaLabel`, `id`, `children` | None | Fixed `fray-sidebar` host containing a native complementary region with fixed header/toolbar parts and independently scrolling content. |
728
- | `SplitView` | `primary`, `secondary`, `direction`, `primarySize`, pane labels | None | Stateless, non-resizable two-pane flex layout with explicit overflow ownership and keyboard-focusable panes; `primarySize` is a flex-basis value. |
729
- | `DescriptionList` / `DescriptionItem` | list `label`; item `term`, `value` or children | None | Native `dl`/`dt`/`dd` record summary with responsive term/value wrapping. |
730
- | `ProgressBar` | `label`, `value` or `valueEmitter`, `max`, `valueText` | None | Native progress semantics with a clipped-label visual surface; a null value is indeterminate. |
731
- | `ThemePicker` | `label` or `ariaLabel`, theme `options`, value props, `targetDocument`, `disabled` | `onChange(value, option, event)` | String `valueEmitter`; replaces only the theme stylesheet link. |
732
- | `ColorPicker` | `label` or `ariaLabel`, color `options`, value props, `targetDocument`, `disabled` | `onChange(value, option, event)` | String `valueEmitter`; replaces only the color stylesheet link. |
733
- | `Tab` | `id`, `label`, `disabled`, optional literal `route`, `children` | None | Declarative content marker consumed by `TabPanel`; a route annotation binds tab activation to the current route scope. |
734
- | `TabLine` | `tabs`, `label`, `baseId`, value props | `onChange(id, event)` | Active-tab `valueEmitter`; arrow keys skip disabled tabs, with Home/End support. |
735
- | `TabPanel` | `tabs` or `Tab` children, `label`, `id`, value props | `onChange(id, event)` | Owns or consumes the active-tab emitter, wires the selected tabpanel, and contextually registers annotated tabs when a router is present. |
736
-
737
- ### Live binding and data-source contracts
738
-
739
- Use `live()` only for the props listed here. All other component props reject
740
- `live()` in TSX, `h()`, and at runtime. Form-control errors render an alert,
741
- set invalid state, and associate the control or group with that message.
742
-
743
- | Component | `live()` props | Dedicated reactive input/state props |
744
- | --- | --- | --- |
745
- | `Button` | `disabled`, `pressed`, `busy` | None |
746
- | `Textbox` | `disabled`, `required`, `readOnly`, `error` | `valueEmitter` |
747
- | `Dropdown` | `disabled`, `required`, `error` | `options` may be an array or readable emitter; `valueEmitter` |
748
- | `RadioButton` | `checked`, `disabled`, `required`, `error` | None |
749
- | `RadioGroup` | `disabled`, `required`, `error` | `valueEmitter`; `options` is always an ordinary array |
750
- | `Toggle` | `disabled`, `required`, `error` | `valueEmitter`; `options` is always an ordinary array |
751
- | `Checkbox`, `TriCheckbox`, `QuadCheckbox` | `disabled`, `required`, `error` | `valueEmitter` |
752
- | `ThemePicker`, `ColorPicker` | `disabled` | `valueEmitter` |
753
- | `Dialog` | `showCloseButton` | `valueEmitter` controls open state |
754
- | `Panel` | `disabled` | None |
755
- | `ProgressBar` | None | `valueEmitter` |
756
- | `ListView` | None | `items` may be an array or readable emitter; selection emitters are outputs |
757
- | `TreeView` | None | `nodes` may be an array or readable emitter; selected/expanded emitters are outputs |
758
- | `DataTable` | None | `data` may be an array or readable emitter; `dataSource`/`rest` and selection emitters are explicit source/state contracts |
759
- | `FilterPanel` | None | `options` may be an array or readable emitter |
760
- | Layout, tab, description, toolbar, placeholder, and routing components | None | Their ordinary structural/configuration props require an owner rerender when changed |
761
-
762
- Readable data sources carry their own fetch state and error through Glue's
763
- `getFetchState()` and `getError()` APIs. `ListView`, `DataTable`, `FilterPanel`,
764
- and `TreeView` surface those source failures as data-loading errors. This is
765
- separate from a control's `error` prop, which represents validation or other
766
- application-level input feedback.
767
-
768
- Invalid option arrays, duplicate tab IDs, unsupported orientations, malformed
769
- emitters, and non-function callbacks fail with descriptive errors.
770
-
771
- ## Component examples
772
-
773
- ### Actions and toolbar
774
-
775
- ```ts
776
- h(Toolbar, {label: 'Editor actions'},
777
- h(Button, {label: 'Save', onClick: save}),
778
- h(Button, {label: 'Delete', disabled: true}))
779
- ```
257
+ Built-in components allowlist their live props. TypeScript and runtime checks
258
+ reject a binding on an undeclared prop. Value/data emitters such as
259
+ `valueEmitter`, `items`, and `nodes` are raw contracts and do not use `live()`.
780
260
 
781
- ### Textbox
261
+ ### Two-way native bindings
782
262
 
783
- ```ts
784
- const query = new Emitter('')
263
+ `bind:value` accepts a writable string emitter and `bind:checked` accepts a
264
+ writable boolean emitter:
785
265
 
786
- h(Textbox, {
787
- label: 'Search',
788
- valueEmitter: query,
789
- required: true,
790
- error: query.get() === '' ? 'Enter a search term' : null,
791
- onInput: (value) => console.log('search changed', value),
792
- })
266
+ ```tsx
267
+ <input aria-label="Search" bind:value={search} />
268
+ <input type="checkbox" bind:checked={showArchived} />
793
269
  ```
794
270
 
795
- ### Dropdown
271
+ Fray keeps the property synchronized in both directions and owns the renderer
272
+ subscription. Higher-level value controls use the same explicit
273
+ `valueEmitter` convention.
796
274
 
797
- ```ts
798
- const role = new Emitter<'author' | 'reviewer'>('author')
275
+ ## Value-control convention
799
276
 
800
- h(Dropdown, {
801
- label: 'Role',
802
- valueEmitter: role,
803
- options: [
804
- {value: 'author', label: 'Author'},
805
- {value: 'reviewer', label: 'Reviewer'},
806
- ],
807
- onChange: (value) => console.log(value),
808
- })
809
- ```
277
+ Stateful controls expose a public writable `valueEmitter`. Callers can supply
278
+ one with `valueEmitter`, supply an initial uncontrolled value with
279
+ `defaultValue`, or let the control create its documented fallback. `value` is
280
+ retained as an initial-value compatibility alias; it is not a continuously
281
+ controlled prop. `onChange` reports user-driven changes.
810
282
 
811
- ### Toggle
283
+ Availability and validation can be ordinary values or supported `live()`
284
+ bindings. Labels should be visible whenever possible; `ariaLabel` is the
285
+ fallback for controls without visible label content.
812
286
 
813
- ```ts
814
- h(Toggle, {
815
- label: 'View',
816
- defaultValue: 'list',
817
- options: [['list', 'List'], ['grid', 'Grid']],
818
- onChange: (value) => console.log(value),
819
- })
820
- ```
287
+ ## Component reference
821
288
 
822
- ### RadioGroup
289
+ Every public component is listed below. Generic `className`, `class`, `island`,
290
+ `key`, and `children` come from `ComponentProps` and are omitted from the key
291
+ props column.
823
292
 
824
- `RadioGroup` deliberately has a narrow reactive contract. Its selectable
825
- `options` are ordinary structural input, its `valueEmitter` is a stable raw
826
- writable state channel, and only `disabled`, `required`, and `error` accept one-way
827
- `live()` bindings.
293
+ The generic controls `Dropdown`, `RadioGroup`, and `Toggle` preserve their
294
+ option value type through `valueEmitter` and `onChange`; the `<T>` notation in
295
+ the tables below denotes that TypeScript type parameter.
828
296
 
829
- | Prop | Role | `live()` support |
297
+ ### Actions, inputs, and choices
298
+
299
+ | Component | Purpose | Key props and state |
830
300
  | --- | --- | --- |
831
- | `options` | Selectable value/label tuples supplied by the owner | No |
832
- | `disabled` | Current availability state | Yes, with a readable boolean emitter |
833
- | `required` | Current form-requirement state | Yes, with a readable boolean emitter |
834
- | `error` | Current validation message and invalid state | Yes, with a readable error emitter |
835
- | `valueEmitter` | Stable writable selected-value channel | Pass the emitter raw; do not wrap it |
836
- | `value`, `defaultValue`, `initialValue` | Initial value when no `valueEmitter` is supplied | No |
837
- | `id`, `label`, `ariaLabel`, `name` | Ordinary identity and presentation input | No `live()` binding |
838
- | `onChange` | Action callback | No |
839
-
840
- Static and live availability state can be combined without changing the option
841
- contract:
301
+ | `Button` | Native button with optional pressed and busy state | `label`, `type`, `disabled`, `pressed`, `busy`, `busyLabel`, `onClick`; live: `disabled`, `pressed`, `busy` |
302
+ | `Toolbar` | Named action group | `label`, `orientation` |
303
+ | `Label` | Native label for rich or live text | `text`, `htmlFor`; live: `text` |
304
+ | `Textbox` | Labelled native text input with validation | `label`, `valueEmitter`, `defaultValue`, `type`, `name`, `placeholder`, `disabled`, `required`, `readOnly`, `error`, native text constraints, `inputRef`, `onInput`, `onChange`; live: availability and `error` |
305
+ | `Dropdown<T>` | Labelled native select | `options`, `label`, `valueEmitter`, `defaultValue`, `placeholder`, `disabled`, `required`, `error`, `onChange`; `options` may be static or a readable emitter |
306
+ | `RadioButton` | Standalone native radio and label | `label`, `name`, `value`, `checked`, `disabled`, `required`, `error`, `onChange`; live: state, availability, `error` |
307
+ | `RadioGroup<T>` | Named native-radio fieldset owning one value | `options` as `[value, label]` tuples, `label`, `valueEmitter`, `defaultValue`, `disabled`, `required`, `error`, `onChange`; options are ordinary render data |
308
+ | `Toggle<T>` | ARIA radio group rendered as toggle buttons | `options` as `[value, label]` tuples, `label`, `valueEmitter`, `defaultValue`, `disabled`, `required`, `error`, `onChange` |
309
+ | `Checkbox<T>` | Configurable keyboard-operable semantic state cycle | `symbols` as `[content, value]` tuples, `label`, `valueEmitter`, `defaultValue`, `disabled`, `required`, `error`, `onChange` |
310
+ | `TriCheckbox` | Neutral/prefer/deny `FilterMode` cycle | Same public props as `Checkbox`, except fixed symbols |
311
+ | `QuadCheckbox` | Neutral/prefer/require/deny `FilterMode` cycle | Same public props as `Checkbox`, except fixed symbols |
312
+
313
+ `FilterMode` exports `neutral`, `prefer`, `require`, and `deny` semantic values.
314
+ Arrow keys move backward or forward through a multi-state checkbox; Space uses
315
+ the native forward cycle.
842
316
 
843
317
  ```tsx
844
- const selectedView = new Emitter<'list' | 'grid'>('list')
845
- const unavailable = new Emitter(false)
846
- const mustChoose = new Emitter(true)
318
+ const view = new Emitter<'list' | 'grid'>('list')
847
319
 
848
320
  <RadioGroup
849
321
  label="View"
@@ -851,277 +323,267 @@ const mustChoose = new Emitter(true)
851
323
  ['list', 'List'],
852
324
  ['grid', 'Grid'],
853
325
  ]}
854
- valueEmitter={selectedView}
855
- disabled={live(unavailable)}
856
- required={live(mustChoose)}
326
+ valueEmitter={view}
857
327
  />
858
328
  ```
859
329
 
860
- `live(optionsEmitter)` and a raw options emitter are both unsupported. When an
861
- application genuinely owns a changing option vocabulary, its owning class
862
- component must make that structural rerender explicit:
863
-
864
- ```tsx
865
- interface ViewChooserProps extends ComponentProps {
866
- options: ReadableEmitter<readonly RadioOption[]>
867
- }
868
-
869
- class ViewChooser extends Component<ViewChooserProps> {
870
- render() {
871
- return <RadioGroup
872
- label="View"
873
- options={this.read(this.props.options)}
874
- />
875
- }
876
- }
877
- ```
878
-
879
- Calling `optionsEmitter.get()` directly in `render()` only reads a snapshot and
880
- does not subscribe. `this.read(optionsEmitter)` rerenders the owner when the
881
- array changes; normal vnode reconciliation then supplies the new ordinary
882
- array prop to `RadioGroup`. The owner remains responsible for deciding what an
883
- option removal means for its selected-value emitter. Fray does not silently
884
- select, clear, or otherwise rewrite that state.
885
-
886
- `label` is a `FrayChild`, so it may still contain an emitter that is rendered
887
- and subscribed as child content. That fine-grained child behavior is distinct
888
- from making the `label` property itself a `live()` binding. The same is true of
889
- an individual `RadioOption` label: its rendered content may be reactive without
890
- making the option array or its selectable values live.
891
-
892
- Because `live()` subscriptions belong to a parent render record, pass live
893
- props through JSX or `h()`. Direct `new RadioGroup(...)` construction accepts
894
- resolved booleans and ordinary arrays only.
895
-
896
- ### Checkbox variants
897
-
898
- ```ts
899
- h(Checkbox, {label: 'Include archived'})
900
- h(TriCheckbox, {label: 'Match policy', defaultValue: FilterMode.Neutral})
901
- h(QuadCheckbox, {label: 'Required tags', defaultValue: FilterMode.Require})
902
- ```
903
-
904
- `FilterMode` values are `Deny`, `Neutral`, `Prefer`, and `Require`. The basic
905
- checkbox uses neutral/prefer, while the variants expose the additional states.
330
+ ### Layout and navigation
906
331
 
907
- ### Header
332
+ | Component | Purpose | Key props and state |
333
+ | --- | --- | --- |
334
+ | `Header` | Styled native heading surface | `level` (1–6), `headingId`, content |
335
+ | `GroupPanel` | Labelled bordered group with a vertical header | required `header`, content |
336
+ | `Panel` | Optional labelled region with toolbar and content flow | `header`, `toolbar`, `orientation`, `disabled`; live: `disabled` |
337
+ | `Sidebar` | Labelled `aside` with fixed header/toolbar and scrolling content | `header`, `toolbar`, `ariaLabel`, content |
338
+ | `SplitView` | Two-pane layout | `primary`, `secondary`, `direction`, `primarySize`, region labels |
339
+ | `Tab` | Declarative tab definition consumed by `TabPanel` | `id`, `label`, `disabled`, optional literal `route`, content |
340
+ | `TabLine` | Standalone keyboard-operable tab list | `tabs`, `valueEmitter`/`activeTabEmitter`, initial value, `label`, `onChange` |
341
+ | `TabPanel` | Tab list plus owned tabpanel sections | declarative `Tab` children or `tabs` definitions; value props, `label`, `onChange` |
342
+
343
+ `TabLine` supports Home, End, and orientation-appropriate arrow navigation and
344
+ skips disabled tabs. `TabPanel` can register routed tabs when it is mounted in
345
+ a router-backed route scope.
908
346
 
909
347
  ```tsx
910
- <Header id="portfolio-header" headingId="portfolio-title" level={2}>
911
- Portfolio summary
912
- </Header>
913
- ```
914
-
915
- `level` is an integer from `1` through `6` and defaults to `2`. Header children
916
- must be valid native heading content. The custom host owns presentation while
917
- the nested native heading owns document semantics.
918
-
919
- ### Panel
920
-
921
- ```ts
922
- h(Panel, {
923
- header: 'Account',
924
- orientation: 'vertical',
925
- toolbar: h(Toolbar, {label: 'Account actions'},
926
- h(Button, {label: 'Edit'})),
927
- }, h('p', null, 'Account details'))
928
- ```
929
-
930
- ### Sidebar
931
-
932
- ```ts
933
- h(Sidebar, {
934
- id: 'project-navigation',
935
- header: 'Projects',
936
- toolbar: h(Toolbar, {label: 'Project filters'},
937
- h(Textbox, {label: 'Search projects'})),
938
- }, h('ul', null, h('li', null, 'Release automation')))
939
- ```
940
-
941
- The surrounding grid or flex layout must bound the Sidebar's height. Its native
942
- complementary region is inside the fixed Sidebar host; Header and toolbar remain
943
- fixed while the dedicated content part owns vertical scrolling and is keyboard-
944
- focusable. Supply `ariaLabel` when there is no visible `header`.
945
-
946
- ### Declarative tabs
947
-
948
- ```ts
949
- h(TabPanel, {id: 'profile', label: 'Profile sections'},
950
- h(Tab, {id: 'summary', label: 'Summary'}, 'Summary content'),
951
- h(Tab, {id: 'details', label: 'Details'}, 'Details content'))
952
- ```
953
-
954
- ### Standalone tab line
955
-
956
- ```ts
957
- h(TabLine, {
958
- baseId: 'settings',
959
- label: 'Settings sections',
960
- defaultValue: 'general',
961
- tabs: [
962
- {id: 'general', label: 'General'},
963
- {id: 'advanced', label: 'Advanced'},
964
- ],
965
- onChange: (id) => console.log('active tab', id),
966
- })
348
+ <TabPanel id="profile" label="Profile sections">
349
+ <Tab id="summary" label="Summary">Summary content</Tab>
350
+ <Tab id="details" label="Details">Details content</Tab>
351
+ </TabPanel>
967
352
  ```
968
353
 
969
- Use `TabPanel` when Fray should render content and ARIA relationships. Use a
970
- standalone `TabLine` only when the consumer owns the corresponding tabpanel
971
- content and IDs.
354
+ `SplitView` is a fixed two-pane composition primitive. It does not impose
355
+ application resizing policy or persist pane sizes.
972
356
 
973
- ## Styling and accessibility
357
+ ### Data and record views
974
358
 
975
- A Fray application loads four ordered styling inputs:
976
-
977
- | Order | Input | Responsibility |
359
+ | Component | Purpose | Key props and state |
978
360
  | --- | --- | --- |
979
- | 1 | `themes/base.css` | Default palette anchors, palette-ramp derivation, and semantic custom-property defaults only |
980
- | 2 | Runtime-collected component CSS | Rules from the `static css` declarations of the component classes the application declares |
981
- | 3 | `colors/<name>/colors.css` | Palette anchors and endpoints only |
982
- | 4 | `themes/<name>/theme.css` | Intentional custom-property overrides; `color-scheme` is the only ordinary declaration allowed |
983
-
984
- Application layout remains in application-owned CSS and is not a fifth Fray
985
- layer. Named color and theme files never import the base file; load order is
986
- explicit at the application entry point.
987
-
988
- Component classes own every selector and every declaration that consumes a
989
- Fray variable. Shared rules live in meaningful abstract component classes when
990
- those components share DOM or behavior. For example, `Dropdown` inherits the
991
- labeled-input contract and then the select-shell contract. The collector walks
992
- that class chain base-to-derived, resolves each class's own `&` selectors
993
- against the concrete host, and then traverses dependencies declared at every
994
- level. The older named `baseStyles` recipe system has been removed.
995
-
996
- A component's `static css` should contain the absolute minimum needed for its
997
- contract. Every selector, declaration, pseudo-element, and variable must have a
998
- specific purpose that cannot be expressed more simply. A base-class edit must
999
- be reviewed against all descendants because its CSS is compiled into each
1000
- concrete descendant that is used.
1001
-
1002
- The checked-in `styles/structural.css` is a complete generated artifact for
1003
- non-tree-shaken/diagnostic use. Normal applications should register their root
1004
- component and inject the resulting usage-specific stylesheet:
1005
-
1006
- ```ts
1007
- const runtime = createFrayRuntime()
1008
- runtime.registerStyles(App).injectStyles(document)
1009
- runtime.mount(runtime.create(App), root)
1010
- ```
361
+ | `DescriptionList` | Native `dl` record summary | `label`, `DescriptionItem` children |
362
+ | `DescriptionItem` | Native `dt`/`dd` pair | required `term`, `value` or content |
363
+ | `Placeholder` | Decorative loading placeholder | numeric `width`, clamped to 10–100 percent |
364
+ | `ListView<T>` | Keyed single- or multi-select ARIA listbox | `items`, `itemKey`, `label`, `renderItem`, `multiSelect`, selected emitter |
365
+ | `TreeItem<T>` | Declarative tree-node marker | `id`, `label`, `textValue`, `value`, nested `TreeItem` children |
366
+ | `TreeView<T>` | Keyed single-select ARIA tree | `nodes` or declarative items, `label`, selected/expanded emitters, `renderItem`, per-label class/style callbacks, `onSelect` |
367
+ | `FilterPanel` | Semantic filter-control fieldset | `options`, `filters`, `filterModes`, `defaultSemanticState`, `label`, `onChange` |
368
+ | `TableHeaderCell` | Sort/filter header-cell control | column key/label plus sort/filter state callbacks |
369
+ | `TableHeader` | Header row over public column definitions | `columns`, sort/filter emitters and callbacks |
370
+ | `DataTable<T>` | Accessible local, caller-query, or REST-backed table | `columns`, one data input, `rowKey`, caption/messages, semantic filter options, single/multi selection |
371
+
372
+ `ListView`, `TreeView`, and `DataTable` reconcile selection by stable keys when
373
+ fresh item objects arrive. Supply an explicit key for application data; index
374
+ fallbacks are only safe for immutable ordering. `ListView.items` and
375
+ `TreeView.nodes` accept static arrays or readable emitters and present loading,
376
+ empty, and error states from the emitter snapshot.
377
+
378
+ Advanced compositions may use `BaseSelectionHandler`,
379
+ `SingleSelectionHandler`, `MultiSelectionHandler`, and
380
+ `createSelectionHandler` directly. Ordinary applications should prefer the
381
+ selection behavior already owned by `ListView` and `DataTable`.
382
+
383
+ `TreeView` owns keyboard navigation, expansion, typeahead, and selection. Use
384
+ `itemLabelClassName` and `itemLabelStyle` when only the label beside the
385
+ expander needs a reusable presentation trait such as `colored`.
386
+
387
+ #### DataTable inputs and ownership
388
+
389
+ `DataTable` requires exactly one data mode:
390
+
391
+ - `data`: a static array or readable emitter; the table owns the local derived
392
+ data source it creates.
393
+ - `dataSource`: a caller-owned `TableDataSource`; the caller disposes it.
394
+ - `rest`: convenience options for a table-owned REST-backed source.
395
+
396
+ For reusable sources, use `createLocalTableDataSource`,
397
+ `createQueryTableDataSource`, `createHandlerTableDataSource`, or
398
+ `createRestTableDataSource`. Sources expose `query`, `sortEmitter`,
399
+ `filtersEmitter`, optional `retry`, and `dispose()`.
400
+
401
+ `TableColumn` definitions own display and local comparison/filter functions.
402
+ The pure `applyLocalTableState`, `serializeTableQuery`, and related table-query
403
+ helpers keep local behavior and remote encoding explicit. Pagination,
404
+ virtualization, and server-specific wire policy remain application concerns.
405
+
406
+ ### Dialog, status, and presentation selection
407
+
408
+ | Component | Purpose | Key props and state |
409
+ | --- | --- | --- |
410
+ | `Dialog` | Controlled native modal with focus containment and restoration | `title`, `description`, `actions`, `valueEmitter`/`defaultValue`, `closeLabel`, `showCloseButton`, `initialFocusRef`, `onClose` |
411
+ | `ProgressBar` | Labelled native progress with visual track | required `label`, `value` or `valueEmitter`, `max`, `valueText`; `null` is indeterminate |
412
+ | `ThemePicker` | Select and replace a Fray theme link | value props, `options`, `label`/`ariaLabel`, `disabled`, `targetDocument`, `onChange` |
413
+ | `ColorPicker` | Select and replace a Fray color link | same contract as `ThemePicker` |
1011
414
 
1012
- ### Custom properties
415
+ The pickers use `frayThemeOptions` and `frayColorOptions` by default. An
416
+ application still owns whether runtime selection is offered, which options are
417
+ available, and whether the selected identifier is persisted.
1013
418
 
1014
- `base.css` derives full primary, secondary, and neutral ramps from each
1015
- palette's `500` anchor plus light/dark mix endpoints. A color file therefore
1016
- sets anchors and endpoints, while a theme maps or overrides semantic families
1017
- such as `--button-*`, `--input-*`, `--panel-*`, and `--selection-*`.
1018
- `frayThemeVariableCatalog` exposes the supported vocabulary.
419
+ ## Semantic filter state
1019
420
 
1020
- `--application-background` owns the canvas behind an axis-filling application
1021
- root and its islands. Its base value is the white palette endpoint, which
1022
- Shiny and Minimal both retain.
421
+ Fray's filter helpers keep presentation symbols separate from matching policy.
422
+ A `FilterState` is plain, versionable data keyed by dimension and option. A
423
+ `FilterDimensionDefinition` supplies the application-owned matchers.
1023
424
 
1024
- The explicit `island` modifier consumes `--island-margin`,
1025
- `--island-padding`, `--island-background`, `--island-border`,
1026
- `--island-radius`, and `--island-shadow`. Base and Minimal keep its layout and
1027
- elevation neutral; Shiny uses it for Bank2-style separated surfaces. Themes do
1028
- not infer island boundaries from component type or nesting, and islands cannot
1029
- contain other islands.
425
+ Dimensions combine with AND. Within a dimension, deny wins, every required
426
+ option must match, and at least one preferred option must match when any are
427
+ active. Unknown persisted keys survive serialization without constraining
428
+ current matching.
1030
429
 
1031
- ### Runtime selection
430
+ Use `matchesFilterState` or `filterByState` for pure evaluation;
431
+ `deriveFilterPredicate` and `deriveFilteredItems` for reactive results; and
432
+ `serializeFilterState`/`parseFilterState` for deterministic versioned data.
1032
433
 
1033
- `replaceFrayStylesheet` maintains one
1034
- `link[data-fray-stylesheet="theme"]` and one
1035
- `link[data-fray-stylesheet="colors"]`. Replacing either link also sets the
1036
- corresponding root data attribute. `ThemePicker` and `ColorPicker` expose the
1037
- same operation through the normal value-control contract. The base file and
1038
- runtime-injected structural stylesheet stay loaded while those two links are
1039
- replaced.
434
+ ## Application services
1040
435
 
1041
- See [the theme contract](themes/README.md) and
1042
- [the palette contract](colors/README.md) for authoring details.
436
+ Service implementations remain ordinary application TypeScript. Fray provides
437
+ typed keys and a fixed application scope, not dependency discovery:
1043
438
 
1044
- ## Data workflows
439
+ ```tsx
440
+ class ProjectService {
441
+ readonly label = 'Projects'
442
+ }
1045
443
 
1046
- `ListView`, `DataTable`, `TreeView`, `TreeItem`, `Dialog`, `FilterPanel`, and
1047
- their model helpers are part of the package entry point. All accept
1048
- ordinary Glue emitters; they do not introduce a second state store.
444
+ const projectService = defineService<ProjectService>('projects')
445
+ const services = createServiceScope([
446
+ provideService(projectService, () => new ProjectService()),
447
+ ])
1049
448
 
1050
- List and table selection is discriminated by cardinality. Single selection is
1051
- an item or `null`; array state is reserved for explicit multi-selection:
449
+ class ProjectTitle extends Component {
450
+ static requiredServices = [projectService]
451
+ private service!: ProjectService
1052
452
 
1053
- ```tsx
1054
- const selected = new Emitter<Project | null>(null)
1055
- const selectedRows = new Emitter<Project[]>([])
1056
-
1057
- <ListView items={projects} selectedItemEmitter={selected} itemKey="id" />
1058
- <DataTable
1059
- data={projects}
1060
- columns={columns}
1061
- multiSelect
1062
- selectedItemsEmitter={selectedRows}
1063
- />
1064
- ```
453
+ initialize() {
454
+ this.service = this.requireService(projectService)
455
+ }
1065
456
 
1066
- Both modes reconcile selected keys to fresh objects when data is replaced.
1067
- Multi-selection retains Control/Command toggles, Shift and pointer-drag ranges,
1068
- and keyboard operation.
457
+ render() {
458
+ return <output>{this.service.label}</output>
459
+ }
460
+ }
1069
461
 
1070
- DataTable accepts exactly one data boundary:
462
+ const runtime = createFrayRuntime({services})
463
+ ```
1071
464
 
1072
- ```tsx
1073
- // Direct local data; Fray derives sorted/filtered rows.
1074
- <DataTable data={projects} columns={columns} />
465
+ Providers are immutable, lazy, and scope-shared. Factories can explicitly
466
+ resolve declared dependencies through their `ServiceResolver`; cycles and
467
+ missing providers fail clearly. `ServiceScope.dispose()` disposes initialized
468
+ services in reverse creation order. Components own the queries/results they
469
+ open; they do not dispose scope-shared services.
1075
470
 
1076
- // Convenient REST adapter; the table creates and disposes this source.
1077
- <DataTable
1078
- rest={{url: '/api/projects', baseUrl: location.href}}
1079
- columns={columns}
1080
- />
471
+ `FrayRuntime` carries one `ServiceScope`, optional router, and isolated
472
+ `StyleRegistry`. `createFrayRuntime()` is the normal construction entry point;
473
+ `defaultFrayRuntime` supports direct compatibility mounting.
1081
474
 
1082
- // Explicit source; the caller owns and eventually disposes it.
1083
- const source = createQueryTableDataSource({query, sortEmitter, filtersEmitter})
1084
- <DataTable dataSource={source} columns={columns} />
1085
- ```
475
+ ## Browser routing
1086
476
 
1087
- `createLocalTableDataSource`, `createQueryTableDataSource`,
1088
- `createHandlerTableDataSource`, and `createRestTableDataSource` make ownership
1089
- visible. Sources package the row query with sort/filter emitters, retry, and
1090
- disposal. The default REST serializer retains the compact existing endpoint
1091
- tokens; inject `serializeQuery` when an endpoint uses another wire contract.
477
+ Fray routing binds explicit route vocabulary to ordinary writable emitters.
478
+ The application owns descriptors, codecs, data-dependent resolvers, and the
479
+ navigation adapter.
1092
480
 
1093
- Tree node state is projected from complete immutable snapshots:
481
+ ```tsx
482
+ const portfolioRoute = defineRoute('portfolio')
483
+ const projectRoute = defineRouteParameter('project', stringRouteCodec)
484
+ const selectedProject = new Emitter<string | null>(null)
1094
485
 
1095
- ```ts
1096
- const selectedNode = deriveTreeNode(treeNodes, selectedKey)
486
+ const router = createBrowserRouter({adapter: createHashNavigation()})
487
+ const runtime = createFrayRuntime({router})
1097
488
 
1098
- // Only for an authoritative writable root:
1099
- updateWritableTreeNode(treeNodes, 'project-1', (node) => ({...node, label: 'Updated'}))
489
+ <TabPanel label="Application sections">
490
+ <Tab id="portfolio" label="Portfolio" route={portfolioRoute}>
491
+ <RouteValue
492
+ route={projectRoute}
493
+ valueEmitter={selectedProject}
494
+ scopeChildren={true}
495
+ >
496
+ <ProjectScreen selectedProject={selectedProject} />
497
+ </RouteValue>
498
+ </Tab>
499
+ </TabPanel>
1100
500
  ```
1101
501
 
1102
- For a tree derived from domain state, a callback updates that real source; the
1103
- tree derivation then rebuilds and `deriveTreeNode` resolves the fresh node.
1104
- `updateTreeNode` is the equivalent pure path-copy operation.
1105
-
1106
- Generic `FilterState` keeps `neutral`, `prefer`, `require`, and `deny`
1107
- semantic values separate from glyphs and transport. `filterByState`,
1108
- `deriveFilterPredicate`, and `deriveFilteredItems` consume caller-supplied
1109
- dimension matchers. `serializeFilterState`/`parseFilterState` round-trip
1110
- validated version-1 plain data without owning URL or storage access. Unknown
1111
- valid keys are preserved and ignored until a matching definition exists.
1112
- Dimensions combine with AND. Within each dimension, a denied match always
1113
- rejects, every required option must match, and at least one preferred option
1114
- must match when preferences are active. Neutral options do not constrain the
1115
- result.
1116
-
1117
- The stable non-virtualized performance boundary and detailed state behavior are
1118
- documented in this guide. See the migration notes in the changelog before
1119
- upgrading from 0.2.x.
1120
-
1121
- Fray is not a replacement for an SSR/hydration framework, Web Components,
1122
- React/Vue adapters, a broad design system, a virtualized production data grid,
1123
- legacy-browser support, or a stable `1.0` API. See the [workspace
1124
- overview](../../README.md), [API surface](../../docs/API_SURFACE.md),
1125
- [architecture overview](../../docs/architecture.md),
1126
- [changelog](CHANGELOG.md), [contribution guide](../../CONTRIBUTING.md),
1127
- and [security policy](../../SECURITY.md).
502
+ Core routing exports:
503
+
504
+ - `defineRoute`, `defineRouteParameter`, `routeParameter`, `routeTarget`, and
505
+ `withRouteQuery` create immutable descriptors and targets.
506
+ - `BrowserRouter`/`createBrowserRouter` progressively restore mounted scopes,
507
+ normalize locations, and expose structured issue state.
508
+ - `createHistoryNavigation`, `createHashNavigation`, and
509
+ `MemoryNavigationAdapter` decide where locations live.
510
+ - `RouteScope` establishes lineage; `RouteValue` binds dynamic path values;
511
+ `RouteQuery` binds one named query value; `RouteLink` renders a real anchor.
512
+ - `waitForRouteValue` lets a resolver await a readable application
513
+ prerequisite with cancellation.
514
+
515
+ Resolvers may return `RouteRedirect` through `redirectTo()`, or throw
516
+ `RouteUnavailableError` when the requested value cannot be represented in the
517
+ mounted application state.
518
+
519
+ Explicit navigation pushes by default. Restoration never pushes; redirects,
520
+ fallback, canonicalization, and passive bound-state changes replace. A
521
+ superseding transition aborts pending resolvers. Invalid locations settle at
522
+ the deepest valid parent and leave accessible issue presentation to the
523
+ application.
524
+
525
+ The history adapter needs server fallback for direct deep requests. The hash
526
+ adapter reserves the fragment. The memory adapter is intended for deterministic
527
+ tests. The caller owns and disposes the router.
528
+
529
+ ## Styling contract
530
+
531
+ Load presentation in this order:
532
+
533
+ 1. `@sylwellsoftware/fray/themes/base.css`
534
+ 2. Collected CSS or `@sylwellsoftware/fray/styles/structural.css`
535
+ 3. One `@sylwellsoftware/fray/colors/<name>/colors.css`
536
+ 4. One `@sylwellsoftware/fray/themes/<name>/theme.css`
537
+
538
+ `base.css` declares variables and derives palette roles but contains no
539
+ component selectors. Color files provide anchors and endpoints. Theme files
540
+ provide intentional variable overrides. Component `static css` owns selectors,
541
+ layout, pseudo-elements, native states, and interaction mechanics.
542
+
543
+ `frayThemeVariableCatalog` describes the supported palette and semantic
544
+ variable hierarchy. `findFrayStylesheetOption`, `replaceFrayStylesheet`,
545
+ `setFrayAppearance`, and `getFrayAppearance` support application-controlled
546
+ runtime selection.
547
+
548
+ ### Root sizing and typography
549
+
550
+ Fray does not force a mount root to fill its container. Put
551
+ `fray-fill-horizontal`, `fray-fill-vertical`, or both on the rendered
552
+ application root to claim `100vw`, `100vh`, or the full viewport. Each modifier
553
+ also applies `--font-family`, `--font-size`, and `--line-height` so native and
554
+ Fray descendants inherit the theme typography. An embedded root without a fill
555
+ class keeps the host page's typography and content sizing.
556
+
557
+ ### Reusable traits
558
+
559
+ `island` marks one deliberate themeable surface boundary. Pass
560
+ `island={true}` to a wrapped component or use the class on application-owned
561
+ native markup. Fray rejects nested component islands; application markup must
562
+ preserve the same one-layer invariant.
563
+
564
+ `colored` consumes an explicit `--c1`, `--c2`, `--c3` triplet for the shared
565
+ gradient and `--colored-shadow` treatment. It does not choose semantic colors
566
+ for the application.
567
+
568
+ ## Accessibility and browser support
569
+
570
+ Fray components use native controls and landmarks where possible, expose
571
+ accessible names, preserve focus during keyed updates, and render loading,
572
+ empty, and error messages outside collection semantics. The browser matrix
573
+ covers pinned Chromium, Firefox, and WebKit builds, including keyboard flows,
574
+ 200% text, forced colors, and automated accessibility checks.
575
+
576
+ Applications remain responsible for meaningful labels, heading hierarchy,
577
+ domain validation messages, color contrast introduced by application CSS,
578
+ focus order across composed screens, and manual assistive-technology testing.
579
+
580
+ Fray does not support SSR, hydration, Shadow DOM, registered Web Components,
581
+ legacy browsers, or a concurrent rendering scheduler.
582
+
583
+ ## Further reference
584
+
585
+ - [Public API surface](../../docs/API_SURFACE.md)
586
+ - [Architecture](../../docs/architecture.md)
587
+ - [Theme contract](themes/README.md)
588
+ - [Color palette contract](colors/README.md)
589
+ - [Release history](CHANGELOG.md)