@ultimat3/testing 11.1.0 → 11.2.0

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/CLAUDE.md CHANGED
@@ -60,13 +60,23 @@ is its own entry point and not part of the barrel.
60
60
  | Factory seeds | derived from the table name unless given, so two entities never draw the same uuid stream. `reset()` cascades into associated parents — a half-reset row is worse than none |
61
61
  | Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
62
62
  | An island needs a BUILDER, not an import | `buildIslands` is `@ultimat3/cli`'s and both packages are tier 5; the one declared edge is `cli → testing`, so the reverse is a `bun run boundaries` failure. `mountIsland({ build, root, file })` takes the function as a parameter and declares only the two fields it reads — `IslandChunkLike` is `{ file, code }`, so a CSS artifact, a source map or a dev/production flag the bundler grows is invisible here. Moving the bundler down a tier was the alternative and it drags `@babel/core` and `babel-preset-solid` with it, into a package whose whole point is being importable from tier 0 |
63
- | The micro-DOM is the fixture's, once | `island-dom.ts`. `bun test` has no DOM and no DOM library may be added; `generate: 'dom'` builds every element from `_$template("<label …>")`, so a stub without a parsed `<template>` cannot run one line of a compiled island. It lived twice, ~200 lines each, in `packages/cli/src/island-bundle.test.ts` and the reference app's island test |
63
+ | The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`. **"Once" is scoped to this job, and said so only from 2026-08-23**: `packages/ui/src/fake-dom.ts` is a second micro-DOM (201 lines, test-only, off that package's barrel) for a different one — focus, `activeElement`, `contains` and a `:not()` selector grammar for keyboard code, none of which parses a `<template>`. It is not a copy to collapse: `ui` is tier 4 and this package is tier 5, so `ui -> testing` is an upward import the boundary check refuses, and the merge would need the shared half moved down to a tier neither grammar belongs in. `bun test` has no DOM and no DOM library may be added; `generate: 'dom'` builds every element from `_$template("<label …>")`, so a stub without a parsed `<template>` cannot run one line of a compiled island. It lived twice, ~200 lines each, in `packages/cli/src/island-bundle.test.ts` and the reference app's island test |
64
64
  | `style` and `classList` RECORD, `As of 2026-08` | `FakeStyle` is one declaration map behind all four spellings compiled Solid uses on one element: a STATIC entry baked into the template's `style=` attribute, a dynamic one through `setStyleProperty` → `style.setProperty`, a whole-object or string prop through `style()` → `cssText`, and a cleared one through `removeAttribute`. It was `style = {}` until 2026-08-21 — `<Form>`, `<Stack>`, `<Grid>` and `<Container>` each set a CSS custom property, so every one of them died inside `mount` with `e.style.setProperty is not a function`, and `x g resource` emitted a plain `<form>` rather than the design system's. A design-system component kept out of generated code by the limits of a TEST DOUBLE. A no-op `setProperty` would have stopped the crash and left "the component set `--form-gap`" unassertable, which is the same hole one layer down |
65
65
  | `classList` is the class attribute | not a list of its own, so `classList.toggle` — what the compiler emits INLINE for `classList={{ … }}`, with no runtime helper in front of it — and `className` can never answer one element two ways. `add` was the only method the stand-in had and is the one Solid never calls |
66
66
  | A `document` listener is the documentElement's | this DOM has no bubbling at all, so `document.addEventListener` registers on `documentElement` and `fire(mounted.documentElement, 'keydown', …)` drives it through the surface `MountedIsland` already exposes. `@ultimat3/ui`'s Menu, Popover and focus trap each close on an Escape registered on `document` and never on their own node — a no-op here made all three mountable and unclosable. One handler per type, last wins: two components listening for the same event is this design's limit, and `listeners`' value type is public surface a fix would break |
67
67
  | `querySelector` skips `this` | descendants only, as the DOM's does. Matching the element it is called on made a host `<div>` answer `find('div')` with the container the test built rather than the markup the island rendered |
68
68
  | A mount installs process globals | so `MountedIsland` is `Disposable` and a `mount` that THROWS restores before it rethrows. A fake `document` left installed reaches every later FILE in the run, and fails somewhere with no thread back |
69
69
  | `fire` answers whether a handler ran | a selector matching nothing and an island that attached no handler are the same silence otherwise — the second is a bug, the first a typo. It reads Solid's delegated `$$click` property or an `addEventListener` listener; a compiled island uses one or the other |
70
+ | A states file is PURE DATA, and it is enforced | `defineIslandStates` declares the states an island can be photographed in — error, empty, over-quota, read-only — in a sibling file (`settings.island.states.ts`) that may not import the component, JSX or `solid-js`. Three consumers read that file and only one of them has a browser: the command that takes the pictures (which must know the complete expected list BEFORE a browser exists, or "produced nothing and exited 0" reads as success), the harness page, and `island-states-guard.test.ts`. `assertIslandStatesPure` is the static rule — `X_TEST_ISLAND_STATES_NOT_PURE` — because a module that imports Solid still evaluates perfectly well under Bun, so nothing dynamic can catch it |
71
+ | The rule is the RELATIVENESS, not the extension, `As of 2026-08-23` | the scan refused `solid-js` and a specifier ENDING in `.tsx`/`.jsx`, and `import { X } from './settings.island'` resolves to `./settings.island.tsx` under Bun — so the guard answered PURE for a file that drags Solid into a browser-free process, which is worse than no guard. Every RELATIVE runtime specifier is refused now (`IslandStatesSiblingImportError`), because `./helpers` reaches the component one hop further on and this scanner reads ONE file's text. `.json` is the one exemption: a JSON module has no imports, so it is the single relative target whose graph needs no following. That rule needs no copy of `ISLAND_EXTENSION` either, which is the row below still holding |
72
+ | `import type` is not an import, and it is the one way to name the component | `verbatimModuleSyntax` erases a statement that BEGINS `import type` / `export type` — proved against Bun in `island-states-pure.test.ts`, which writes the pair to disk and asserts the sibling never evaluated, because the whole exemption rests on it. `examples/dummy`'s states file types its props that way and the rule may never refuse it. The other direction is the one that would leave the hole open: `import { type X } from './y'` is emitted as `import {} from './y'` and DOES evaluate `./y`, so an inline modifier is a runtime edge and is refused. Both directions are pinned |
73
+ | Unreadable is not pure | a computed specifier — ``import(`./${name}.island`)``, `require(SPEC)` — is refused as `IslandStatesOpaqueImportError` rather than passed. "A file it cannot read is pure" was this scanner's stated totality and it is the same optimism the extensionless case shipped with |
74
+ | What the scan does NOT follow, stated rather than silent | a BARE specifier other than `solid-js` (`@ultimat3/ui` re-exports Solid components and is not refused — a package list here would be the drift a list always is), an ABSOLUTE path specifier (`packages/cli`'s own test tree and this package's guard test both import the barrel by absolute path, which is the only way to reach it from a scratch directory with no `node_modules`), and a specifier inside a string LITERAL, which is read as an import. The first two are holes; the third is a false refusal, and the safe direction of the two |
75
+ | Props are JSON or they are refused | they ride `data-x-props`, which `@ultimat3/render`'s `emitIslandProps` `JSON.stringify`s, so anything else is a prop the component never receives. `jsonFault` is stricter than `JSON.stringify` in exactly the three places that DEGRADE rather than throw: `undefined` disappears, a non-finite number becomes `null`, and a `Date` becomes a string that no longer answers `.getTime()` |
76
+ | The clock is pinned in the vocabulary, zone included | `timeZone` defaults to `ISLAND_SHOT_TIME_ZONE` (`UTC`) and `now` to this package's own `DEFAULT_NOW`, and both ride onto every `IslandShotTarget`. A harness that freezes the instant and leaves the ZONE ambient photographs `12:00` on one machine and `14:00` on the next; the review diff then reports a component change that never happened |
77
+ | Loose in, strict out | `findIslandStates` resolves `Settings`, `settings`, `settings.island.tsx` and the full path to one manifest, and refuses a name nothing answers to by listing EVERY valid one — a typo and an island whose states were never declared are one symptom and two edits. It is the only refusal in the vocabulary: `parseIslandAddress` falls back on an unknown theme instead, because a page that renders an error over a typo turns a mistyped address into a screenshot of the framework |
78
+ | The disk check is not in `defineIslandStates` | a declaration evaluates wherever it is imported from, so a rule that reads the filesystem at import time fails on the cwd rather than on the path. `assertIslandFiles(manifests, root)` is separate and belongs to whoever knows a root — the guard test, and the command |
79
+ | The island EXTENSION is not restated here | `.island.tsx` is `@ultimat3/render`'s `ISLAND_EXTENSION` and `render` is not a dependency of this package. A manifest's `name` is therefore the island basename up to its FIRST dot, which needs no copy of that constant — and the shot directory is that name, so two islands sharing a basename are `X_TEST_ISLAND_STATES_AMBIGUOUS` rather than two sets of pictures in one folder |
70
80
  | The chunk is imported from a temp FILE, `As of 2026-08-21` | `mkdtemp`ed on the FIRST mount and named by the chunk's SHA-256, so an edited island is a different module rather than a cache hit on the same path and no test leaves a `.mjs` behind in the app it just built. It was a `data:` URL until 2026-08-21 and that read better: `bun test --coverage` panics with `range end index N out of range for slice of length 4096` on `import()` of any `data:` module past ~4 kB, and an island chunk is 12-55 kB — so every island test dumped core in the per-package CI job while the root gate stayed green. Measured on Bun 1.4.0; `fixture-island.test.ts` pins the file form as a source rule, because the failure is invisible to a `bun test` without `--coverage` |
71
81
  | The scratch directory is lazy and removed, `As of 2026-08-22` | `mkdtempSync` ran at MODULE scope and nothing removed it, so every process importing `@ultimat3/testing` at all — this module is on the `.` barrel, so `expect` alone did it — left one directory in `/tmp` forever. Created on the first `modulePathFor` and `rmSync`ed from `process.on('exit')`: the handler has to be synchronous, and it is per PROCESS while `MountedIsland`'s `Disposable` is per mount and is never reached by a mount that threw. `fixture-island-cleanup.test.ts` asserts both halves from a CHILD process, which is the only place either is observable |
72
82
  | Attaching a node MOVES it | `appendChild`, `insertBefore` and `replaceChild` detach the node from its old parent first, and `removeChild` clears `parentNode` — as the DOM does, and as `reconcileArrays` requires: a `<For>` re-order calls `parentNode.insertBefore(child, ref)` on a child ALREADY in that parent (`solid-js/web`'s `web.js:155`), so an attach that only pushed left the node in BOTH positions and a five-row list reconciled to ten. A removal that left `parentNode` set was the same defect read backwards — `indexOf` answers -1, so the orphan's `nextSibling` was its old parent's FIRST child instead of `null`. `textContent = ''` detaches too; it opens every island's `mount` |
package/README.md CHANGED
@@ -22,6 +22,12 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
22
22
  | `fixture-drivers.ts` | the five it declares but a driver must build — `page` `budget` `signIn` `deploy` `subscribe` |
23
23
  | `fixture-island.ts` | `mountIsland()` — build an island, import its chunk, run its `mount`. The BUILDER is a parameter |
24
24
  | `island-dom.ts` | the micro-DOM `mountIsland` drives: what compiled Solid touches, and nothing else |
25
+ | `island-states.ts` | the vocabulary: what a photographable island STATE is. Types and constants, importing nothing |
26
+ | `define-island-states.ts` | `defineIslandStates()` — one manifest, validated and frozen, with every default resolved |
27
+ | `island-states-check.ts` | the rules a declaration must satisfy, as pure functions answering a fault |
28
+ | `island-states-pure.ts` | the guard the design rests on: a `*.island.states.ts` file reaches no browser and no bundler |
29
+ | `island-shot-targets.ts` | the expansion — one record per PICTURE — and `islandAddress` / `parseIslandAddress`, inverses |
30
+ | `island-states-resolve.ts` | a name a reader typed → the manifest it meant; a manifest → the island file it claims |
25
31
  | `framework-fixtures.ts` | registers both sets; the app registers only what it owns |
26
32
  | `registry-leak-guard.ts` | fails the run naming the FILE that left a process-global registry dirty, and restores the ones that can be restored at the same boundary |
27
33
  | `registry-snapshot.ts` | `captureProcessRegistries()` / `restoreProcessRegistries()` — the locale config, the catalogs, the permission set and the role map, put back as a file inherited them. A module-scope declaration evaluates once per process (`bun test` without `--isolate`, `As of 2026-08`), so a neighbour's `clearPermissions()` is otherwise permanent |
@@ -278,11 +284,87 @@ attached no handler are the same silence; the second is a bug and the first is a
278
284
  `island[Symbol.dispose]()` in an `afterAll`. Left installed it hands a fake `document` to every
279
285
  later FILE in the run.
280
286
 
287
+ ## Declaring the states an island can be photographed in
288
+
289
+ A reviewer can click their way to most of a component. They cannot click their way to *the account
290
+ is read-only*, *the workspace is over quota* or *the request failed* — so those states are declared,
291
+ beside the island, in a file that is **pure data**:
292
+
293
+ ```ts
294
+ // apps/web/app/settings/settings.island.states.ts
295
+ import { defineIslandStates } from '@ultimat3/testing';
296
+
297
+ export const settingsStates = defineIslandStates({
298
+ island: 'apps/web/app/settings/settings.island.tsx',
299
+ target: '[data-settings]', // what to crop to; the island's host element otherwise
300
+ states: [
301
+ {
302
+ id: 'over-quota', // a slug: it becomes the screenshot filename stem
303
+ title: 'the workspace is over quota',
304
+ note: 'you cannot reach this by clicking — billing sets the flag, not the UI',
305
+ props: { quota: { used: 120, limit: 100 } },
306
+ routes: [{ match: 'GET /api/quota', respond: { kind: 'json', body: { used: 120 } } }],
307
+ themes: ['dark'], // both, when the key is absent
308
+ },
309
+ ],
310
+ });
311
+ ```
312
+
313
+ `islandShotTargets(manifest)` expands that to one record per picture —
314
+ `{ island, name, state, theme, viewport, target, timeZone, now, file, query }` — where `file` is
315
+ `settings/over-quota-dark.png` and `query` is the harness address that renders exactly it.
316
+ `parseIslandAddress` is that address's inverse, and it is **total**: an unknown theme falls back to
317
+ `light` rather than photographing an error page.
318
+
319
+ **Pure data is the constraint, not a preference.** The command that takes the pictures has to know
320
+ the complete expected list BEFORE a browser exists, or "produced nothing and exited 0" is
321
+ indistinguishable from success — and the harness page and this package's own guard test read the
322
+ same file. One `import './settings.island.tsx'` makes it readable by a bundler alone, so
323
+ `assertIslandStatesPure` refuses it (`X_TEST_ISLAND_STATES_NOT_PURE`).
324
+
325
+ **And no RUNTIME import of a sibling, `As of 2026-08-23`.** The rule is the relativeness, not the
326
+ extension: `./settings.island` resolves to `./settings.island.tsx` under Bun, and `./helpers` may
327
+ reach the component one hop further on — a scanner reading ONE file's text can follow neither. A
328
+ computed specifier — ``import(`./${name}.island`)`` — is refused for the same reason, because a
329
+ specifier nothing can read is not a specifier anything may call pure.
330
+
331
+ **`import type` is the one way to reach the component, and it is not an import.**
332
+ `verbatimModuleSyntax` erases a statement that BEGINS `import type` / `export type`, so
333
+ `import type { SettingsProps } from './settings.island'` costs the file nothing and types its props
334
+ against the component. An inline modifier does not: `import { type X } from './y'` is emitted as
335
+ `import {} from './y'`, which evaluates `./y`, and is refused.
336
+
337
+ | A states file writes | Verdict |
338
+ |---|---|
339
+ | `import { defineIslandStates } from '@ultimat3/testing'` | allowed — a bare specifier |
340
+ | `import type { Props } from './x.island'` | allowed — erased before anything evaluates |
341
+ | `import props from './props.json' with { type: 'json' }` | allowed — a JSON module imports nothing |
342
+ | `import { X } from './x.island'` · `./helpers` · `../shared/props` | refused — a graph this cannot follow |
343
+ | `import { type X } from './x.island'` | refused — the statement survives erasure |
344
+ | `import './x.island.tsx'` · `solid-js` | refused — JSX and a renderer |
345
+ | ``await import(`./${name}.island`)`` | refused — unreadable, and unreadable is not pure |
346
+
347
+ **Props are JSON or they are refused.** They ride the same `data-x-props` script tag hydration
348
+ already uses, so a `Date`, a function or an `undefined` is not "approximately right" in the picture
349
+ — it is a prop the component never receives. `X_TEST_ISLAND_STATE_JSON_INVALID` names the path.
350
+
351
+ **The clock is pinned in the vocabulary, zone included.** `timeZone` defaults to `UTC` and `now` to
352
+ this package's own `DEFAULT_NOW`. A harness that freezes the instant and leaves the zone ambient
353
+ renders `12:00` on one machine and `14:00` on the next, and the review diff then says the component
354
+ changed when only the reviewer moved.
355
+
356
+ **The command that takes the pictures is not here yet.** `As of 2026-08-23` this package ships the
357
+ vocabulary, the expansion and the refusals; the browser half is `x shot`'s.
358
+
281
359
  ## Errors
282
360
 
283
361
  `X_TEST_NETWORK_SEALED` `X_TEST_DB_UNAVAILABLE` `X_TEST_NONDETERMINISTIC` `X_TEST_FIXTURE_UNKNOWN`
284
362
  `X_TEST_FACTORY_TRAIT_UNKNOWN` `X_TEST_FACTORY_NOT_PERSISTED` `X_TEST_REGISTRY_LEAK`
285
- `X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT`
363
+ `X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT` `X_TEST_ISLAND_STATES_EMPTY`
364
+ `X_TEST_ISLAND_STATES_NOT_PURE` `X_TEST_ISLAND_STATES_MISSING_FILE` `X_TEST_ISLAND_STATES_UNKNOWN`
365
+ `X_TEST_ISLAND_STATES_AMBIGUOUS` `X_TEST_ISLAND_STATE_ID_INVALID` `X_TEST_ISLAND_STATE_DUPLICATE`
366
+ `X_TEST_ISLAND_STATE_JSON_INVALID` `X_TEST_ISLAND_STATE_CLOCK_INVALID`
367
+ `X_TEST_ISLAND_STATE_STUB_INVALID`
286
368
 
287
369
  ## One process, one registry
288
370
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "11.1.0",
3
+ "version": "11.2.0",
4
4
  "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,16 +33,16 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/cache": "11.1.0",
37
- "@ultimat3/core": "11.1.0",
38
- "@ultimat3/db": "11.1.0",
39
- "@ultimat3/entity": "11.1.0",
40
- "@ultimat3/i18n": "11.1.0",
41
- "@ultimat3/jobs": "11.1.0",
42
- "@ultimat3/mail": "11.1.0",
43
- "@ultimat3/policy": "11.1.0",
44
- "@ultimat3/query": "11.1.0",
45
- "@ultimat3/realtime": "11.1.0",
46
- "@ultimat3/time": "11.1.0"
36
+ "@ultimat3/cache": "11.2.0",
37
+ "@ultimat3/core": "11.2.0",
38
+ "@ultimat3/db": "11.2.0",
39
+ "@ultimat3/entity": "11.2.0",
40
+ "@ultimat3/i18n": "11.2.0",
41
+ "@ultimat3/jobs": "11.2.0",
42
+ "@ultimat3/mail": "11.2.0",
43
+ "@ultimat3/policy": "11.2.0",
44
+ "@ultimat3/query": "11.2.0",
45
+ "@ultimat3/realtime": "11.2.0",
46
+ "@ultimat3/time": "11.2.0"
47
47
  }
48
48
  }
@@ -0,0 +1,146 @@
1
+ // `defineIslandStates` — an island's photographable states, validated once, frozen, and knowable
2
+ // without a browser. The vocabulary it takes and hands back is `island-states.ts`; the rules it
3
+ // applies are `island-states-check.ts`. Every default a declaration leaves out is resolved HERE, so
4
+ // nothing downstream has to decide what an absent viewport or an absent theme list meant.
5
+
6
+ import { DEFAULT_NOW } from './determinism';
7
+ import {
8
+ IslandStateDuplicateError,
9
+ IslandStateIdInvalidError,
10
+ IslandStateInstantInvalidError,
11
+ IslandStateJsonInvalidError,
12
+ IslandStateStubInvalidError,
13
+ IslandStatesEmptyError,
14
+ IslandStateZoneInvalidError,
15
+ } from './island-state-errors';
16
+ import type {
17
+ IslandState,
18
+ IslandStateDecl,
19
+ IslandStatesDecl,
20
+ IslandStatesManifest,
21
+ IslandTheme,
22
+ IslandViewport,
23
+ } from './island-states';
24
+ import {
25
+ DEFAULT_ISLAND_VIEWPORT,
26
+ ISLAND_SHOT_TIME_ZONE,
27
+ ISLAND_STATES,
28
+ ISLAND_THEMES,
29
+ islandStatesName,
30
+ } from './island-states';
31
+ import {
32
+ isPinnedInstant,
33
+ isStateId,
34
+ isStubMatch,
35
+ isTimeZone,
36
+ jsonFault,
37
+ slugifyStateId,
38
+ } from './island-states-check';
39
+
40
+ /** A dimension a browser can be sized to. Anything else inherits, rather than photographing 0px. */
41
+ const usableViewport = (viewport: IslandViewport | undefined): IslandViewport | undefined =>
42
+ viewport !== undefined &&
43
+ Number.isInteger(viewport.width) &&
44
+ Number.isInteger(viewport.height) &&
45
+ viewport.width > 0 &&
46
+ viewport.height > 0
47
+ ? { width: viewport.width, height: viewport.height }
48
+ : undefined;
49
+
50
+ /**
51
+ * Declared themes, deduplicated, in declaration order — both when the list is absent, and both
52
+ * again when nothing in it is a theme this framework knows. Falling back rather than throwing is
53
+ * the same rule `parseIslandAddress` follows: an unreadable theme must still show the component.
54
+ */
55
+ const usableThemes = (themes: readonly IslandTheme[] | undefined): readonly IslandTheme[] => {
56
+ const known = (themes ?? []).filter((theme) => ISLAND_THEMES.includes(theme));
57
+ const unique = [...new Set(known)];
58
+ return unique.length > 0 ? unique : ISLAND_THEMES;
59
+ };
60
+
61
+ /** Frozen all the way down: a harness that mutated props would poison every later picture. */
62
+ function freezeJson<T>(value: T): T {
63
+ if (typeof value !== 'object' || value === null) return value;
64
+ for (const entry of Object.values(value)) freezeJson(entry);
65
+ return Object.freeze(value);
66
+ }
67
+
68
+ function checkClock(decl: IslandStatesDecl): void {
69
+ if (decl.timeZone !== undefined && !isTimeZone(decl.timeZone)) {
70
+ throw new IslandStateZoneInvalidError({ island: decl.island, value: decl.timeZone });
71
+ }
72
+ if (decl.now !== undefined && !isPinnedInstant(decl.now)) {
73
+ throw new IslandStateInstantInvalidError({ island: decl.island, value: decl.now });
74
+ }
75
+ }
76
+
77
+ function normalizeState(
78
+ decl: IslandStatesDecl,
79
+ state: IslandStateDecl,
80
+ seen: Set<string>,
81
+ inherited: IslandViewport,
82
+ ): IslandState {
83
+ if (!isStateId(state.id)) {
84
+ throw new IslandStateIdInvalidError({
85
+ island: decl.island,
86
+ id: state.id,
87
+ slug: slugifyStateId(state.id),
88
+ });
89
+ }
90
+ if (seen.has(state.id))
91
+ throw new IslandStateDuplicateError({ island: decl.island, id: state.id });
92
+ seen.add(state.id);
93
+
94
+ const propsFault = jsonFault(state.props, `state "${state.id}" props`);
95
+ if (propsFault !== undefined) {
96
+ throw new IslandStateJsonInvalidError({ island: decl.island, ...propsFault });
97
+ }
98
+
99
+ const routes = state.routes ?? [];
100
+ for (const [index, stub] of routes.entries()) {
101
+ if (!isStubMatch(stub.match)) {
102
+ throw new IslandStateStubInvalidError({ island: decl.island, match: stub.match });
103
+ }
104
+ if (stub.respond.kind !== 'json') continue;
105
+ const bodyFault = jsonFault(
106
+ stub.respond.body,
107
+ `state "${state.id}" routes[${index}].respond.body`,
108
+ );
109
+ if (bodyFault !== undefined) {
110
+ throw new IslandStateJsonInvalidError({ island: decl.island, ...bodyFault });
111
+ }
112
+ }
113
+
114
+ return {
115
+ id: state.id,
116
+ title: state.title,
117
+ ...(state.note === undefined ? {} : { note: state.note }),
118
+ props: state.props,
119
+ routes,
120
+ viewport: usableViewport(state.viewport) ?? inherited,
121
+ themes: usableThemes(state.themes),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Declare the states one island can be photographed in. Validated here rather than by the command
127
+ * that takes the pictures, because a manifest that is only checked at capture time is a manifest
128
+ * whose defects are found by a browser — long after the file that has to change was in view.
129
+ */
130
+ export function defineIslandStates(decl: IslandStatesDecl): IslandStatesManifest {
131
+ if (decl.states.length === 0) throw new IslandStatesEmptyError({ island: decl.island });
132
+ checkClock(decl);
133
+ const viewport = usableViewport(decl.viewport) ?? DEFAULT_ISLAND_VIEWPORT;
134
+ const seen = new Set<string>();
135
+ const states = decl.states.map((state) => normalizeState(decl, state, seen, viewport));
136
+ return freezeJson({
137
+ [ISLAND_STATES]: true,
138
+ name: islandStatesName(decl.island),
139
+ island: decl.island,
140
+ states,
141
+ viewport,
142
+ ...(decl.target === undefined ? {} : { target: decl.target }),
143
+ timeZone: decl.timeZone ?? ISLAND_SHOT_TIME_ZONE,
144
+ now: decl.now ?? DEFAULT_NOW,
145
+ });
146
+ }
package/src/errors.ts CHANGED
@@ -29,6 +29,18 @@ export const TESTING_ERROR_CODES = [
29
29
  'X_TEST_LIVE_NODE_UPGRADE_REFUSED',
30
30
  'X_TEST_ISLAND_NOT_BUILT',
31
31
  'X_TEST_ISLAND_NO_MOUNT',
32
+ // Declared here and thrown from `island-state-errors.ts`: one file has one job and this
33
+ // catalogue is at its ceiling, so the classes moved and the registration did not.
34
+ 'X_TEST_ISLAND_STATES_EMPTY',
35
+ 'X_TEST_ISLAND_STATES_NOT_PURE',
36
+ 'X_TEST_ISLAND_STATES_MISSING_FILE',
37
+ 'X_TEST_ISLAND_STATES_UNKNOWN',
38
+ 'X_TEST_ISLAND_STATES_AMBIGUOUS',
39
+ 'X_TEST_ISLAND_STATE_ID_INVALID',
40
+ 'X_TEST_ISLAND_STATE_DUPLICATE',
41
+ 'X_TEST_ISLAND_STATE_JSON_INVALID',
42
+ 'X_TEST_ISLAND_STATE_CLOCK_INVALID',
43
+ 'X_TEST_ISLAND_STATE_STUB_INVALID',
32
44
  ] as const;
33
45
 
34
46
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -51,6 +63,17 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
51
63
  X_TEST_LIVE_NODE_UPGRADE_REFUSED: 'the in-process sync node refused the connection',
52
64
  X_TEST_ISLAND_NOT_BUILT: 'the island build produced no chunk for the file the test named',
53
65
  X_TEST_ISLAND_NO_MOUNT: 'an island chunk exports no mount function',
66
+ X_TEST_ISLAND_STATES_EMPTY: 'an island state manifest declares no states',
67
+ X_TEST_ISLAND_STATES_NOT_PURE:
68
+ 'an island states file imports the component, a renderer or a sibling module',
69
+ X_TEST_ISLAND_STATES_MISSING_FILE: 'an island state manifest names an island that is not on disk',
70
+ X_TEST_ISLAND_STATES_UNKNOWN: 'no island state manifest answers to that name',
71
+ X_TEST_ISLAND_STATES_AMBIGUOUS: 'two island state manifests answer to one name',
72
+ X_TEST_ISLAND_STATE_ID_INVALID: 'an island state id is not slug-shaped',
73
+ X_TEST_ISLAND_STATE_DUPLICATE: 'two island states share one id',
74
+ X_TEST_ISLAND_STATE_JSON_INVALID: 'an island state carries a value JSON does not',
75
+ X_TEST_ISLAND_STATE_CLOCK_INVALID: 'an island state manifest pins a clock that is not pinnable',
76
+ X_TEST_ISLAND_STATE_STUB_INVALID: 'an island route stub is not "<METHOD> <pathname>"',
54
77
  };
55
78
 
56
79
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
package/src/index.ts CHANGED
@@ -23,6 +23,10 @@ export {
23
23
  // `test` is OURS (fixture-injecting); everything else passes through. Re-exported so an app
24
24
  // test has one import line, and so `expect` carries this package's matchers already installed.
25
25
  export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bun:test';
26
+ // The island-state vocabulary. Pure data by design: a `*.island.states.ts` file is read by the
27
+ // command that photographs the states, by the harness page and by a guard test — none of which has
28
+ // a bundler, and only one of which has a browser.
29
+ export { defineIslandStates } from './define-island-states';
26
30
  export type { DeterminismOptions, DeterminismSnapshot } from './determinism';
27
31
  // `captureDeterminism` + `restoreCapturedDeterminism` are the pair a NESTED install needs;
28
32
  // `restoreDeterminism` uninstalls outright and hands the real clock and the real `Math.random`
@@ -115,6 +119,68 @@ export type { AppHandle, AppOptions, BootedApp } from './harness';
115
119
  export { describeApp, testApp } from './harness';
116
120
  // Type-only: the micro-DOM is the fixture's to build, and a test only ever names what it handed back.
117
121
  export type { FakeElement, FakeNode, FakeText } from './island-dom';
122
+ export type { IslandAddress, IslandShotTarget } from './island-shot-targets';
123
+ export {
124
+ isIslandTheme,
125
+ islandAddress,
126
+ islandShotFile,
127
+ islandShotPlan,
128
+ islandShotTargets,
129
+ parseIslandAddress,
130
+ } from './island-shot-targets';
131
+ // The file an error tells the reader to edit — the island's own name with `.states.ts` where
132
+ // `.tsx` was. Exported so the command that takes the pictures names the same file the refusal does.
133
+ export { islandStatesFile } from './island-state-errors';
134
+ export type {
135
+ IslandRouteStub,
136
+ IslandState,
137
+ IslandStateDecl,
138
+ IslandStatesDecl,
139
+ IslandStatesManifest,
140
+ IslandStubResponse,
141
+ IslandTheme,
142
+ IslandViewport,
143
+ } from './island-states';
144
+ export {
145
+ DEFAULT_ISLAND_THEME,
146
+ DEFAULT_ISLAND_VIEWPORT,
147
+ ISLAND_SHOT_TIME_ZONE,
148
+ ISLAND_STATES,
149
+ ISLAND_THEMES,
150
+ isIslandStatesManifest,
151
+ islandStatesName,
152
+ } from './island-states';
153
+ export type { JsonFault } from './island-states-check';
154
+ export {
155
+ isPinnedInstant,
156
+ isStateId,
157
+ isStubMatch,
158
+ isTimeZone,
159
+ jsonFault,
160
+ slugifyStateId,
161
+ } from './island-states-check';
162
+ export type {
163
+ IslandFaultKind,
164
+ IslandStatesFault,
165
+ ModuleEdge,
166
+ } from './island-states-pure';
167
+ export {
168
+ assertIslandStatesPure,
169
+ importSpecifiers,
170
+ impureSpecifier,
171
+ islandStatesFault,
172
+ islandStatesImportFault,
173
+ moduleEdges,
174
+ } from './island-states-pure';
175
+ export {
176
+ assertIslandFiles,
177
+ assertUniqueIslandStates,
178
+ findIslandStates,
179
+ islandStatesMatching,
180
+ islandStatesNames,
181
+ missingIslandFiles,
182
+ normalizeIslandName,
183
+ } from './island-states-resolve';
118
184
  export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
119
185
  export { createLiveNode } from './live-node';
120
186
  export type { LiveReplicator, LiveReplicatorOptions } from './live-replicator';
@@ -0,0 +1,101 @@
1
+ // The expansion: one manifest in, one record per PICTURE out. Pure, and deliberately total — the
2
+ // command that photographs them knows the complete expected file list before a browser exists, so
3
+ // "produced nothing and exited 0" is a state it can refuse rather than a state it can report.
4
+
5
+ import type { IslandStatesManifest, IslandTheme, IslandViewport } from './island-states';
6
+ import { DEFAULT_ISLAND_THEME, ISLAND_THEMES } from './island-states';
7
+
8
+ export interface IslandShotTarget {
9
+ /** App-root-relative path of the `.island.tsx`, as the manifest declared it. */
10
+ readonly island: string;
11
+ /** The manifest's own name — the shot directory, and what a reader types to ask for it. */
12
+ readonly name: string;
13
+ readonly state: string;
14
+ readonly theme: IslandTheme;
15
+ readonly viewport: IslandViewport;
16
+ /** CSS selector to crop to. Absent means the island's host element. */
17
+ readonly target?: string;
18
+ readonly timeZone: string;
19
+ readonly now: string;
20
+ /** `<name>/<state>-<theme>.png` — flat and mechanical, because the reader GUESSES this path. */
21
+ readonly file: string;
22
+ /** The harness address that renders exactly this picture. `parseIslandAddress` is its inverse. */
23
+ readonly query: string;
24
+ }
25
+
26
+ export const islandShotFile = (name: string, state: string, theme: IslandTheme): string =>
27
+ `${name}/${state}-${theme}.png`;
28
+
29
+ export interface IslandAddress {
30
+ readonly island: string;
31
+ readonly state: string;
32
+ readonly theme: IslandTheme;
33
+ }
34
+
35
+ /**
36
+ * The address as a query string, `?` included, so it appends to any harness URL. Ordered
37
+ * island → state → theme and never sorted: an address is read by people as often as by code.
38
+ */
39
+ export function islandAddress(address: IslandAddress): string {
40
+ const params = new URLSearchParams([
41
+ ['island', address.island],
42
+ ['state', address.state],
43
+ ['theme', address.theme],
44
+ ]);
45
+ return `?${params.toString()}`;
46
+ }
47
+
48
+ /**
49
+ * The inverse, made TOTAL. Every failure falls back rather than throwing: a mistyped theme, a
50
+ * missing key or an empty string all still answer an address, because the harness that reads this
51
+ * is a page — and a page that renders an error instead of the component turns a typo into a
52
+ * screenshot of the framework. The one refusal in this design is `findIslandStates`, which names
53
+ * every valid island when it cannot resolve one.
54
+ */
55
+ export function parseIslandAddress(query: string): IslandAddress {
56
+ const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
57
+ const theme = params.get('theme');
58
+ return {
59
+ island: params.get('island') ?? '',
60
+ state: params.get('state') ?? '',
61
+ theme: isIslandTheme(theme) ? theme : DEFAULT_ISLAND_THEME,
62
+ };
63
+ }
64
+
65
+ export function isIslandTheme(value: unknown): value is IslandTheme {
66
+ return typeof value === 'string' && ISLAND_THEMES.includes(value as IslandTheme);
67
+ }
68
+
69
+ /**
70
+ * Every picture this manifest asks for, state by state and theme by theme in declaration order.
71
+ * `selector` is the caller's override for the manifest's own `target` — a flag beats a file — and
72
+ * absent means "whatever the manifest said", never "crop nothing".
73
+ */
74
+ export function islandShotTargets(
75
+ manifest: IslandStatesManifest,
76
+ selector?: string,
77
+ ): readonly IslandShotTarget[] {
78
+ const target = selector ?? manifest.target;
79
+ return manifest.states.flatMap((state) =>
80
+ state.themes.map((theme) => ({
81
+ island: manifest.island,
82
+ name: manifest.name,
83
+ state: state.id,
84
+ theme,
85
+ viewport: state.viewport,
86
+ ...(target === undefined ? {} : { target }),
87
+ timeZone: manifest.timeZone,
88
+ now: manifest.now,
89
+ file: islandShotFile(manifest.name, state.id, theme),
90
+ query: islandAddress({ island: manifest.island, state: state.id, theme }),
91
+ })),
92
+ );
93
+ }
94
+
95
+ /** The same expansion over a whole set, in the order the manifests arrived. */
96
+ export function islandShotPlan(
97
+ manifests: readonly IslandStatesManifest[],
98
+ selector?: string,
99
+ ): readonly IslandShotTarget[] {
100
+ return manifests.flatMap((manifest) => islandShotTargets(manifest, selector));
101
+ }
@@ -0,0 +1,236 @@
1
+ // The ten X_TEST_ISLAND_STATE* codes, apart from ./errors only because one file has one job and the
2
+ // catalogue is already at its ceiling. The codes themselves, their titles and the single
3
+ // `registerErrorCodes` call stay in ./errors — one owner, one registration, one place a duplicate
4
+ // can surface.
5
+
6
+ import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
7
+
8
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`.
9
+
10
+ /** Neither path is controlled by the framework: both arrive from an app's own declaration. */
11
+ const ISLAND_PLACEHOLDER = '<the island path the cause names>';
12
+
13
+ /** The same, for the three refusals whose subject is the states file rather than the island. */
14
+ const STATES_PLACEHOLDER = '<the states file the cause names>';
15
+
16
+ /**
17
+ * The file a `defineIslandStates` call for this island lives in, by convention: the island's own
18
+ * name with `.states.ts` where `.tsx` was. Spelled here, in the leaf module, because every error
19
+ * below has to name the file the reader must edit and an error may never import the module that
20
+ * throws it. Only `.tsx` is read — the island EXTENSION is `@ultimat3/render`'s constant and is
21
+ * deliberately not restated in this package.
22
+ */
23
+ export function islandStatesFile(island: string): string {
24
+ return island.endsWith('.tsx')
25
+ ? `${island.slice(0, -'.tsx'.length)}.states.ts`
26
+ : `${island}.states.ts`;
27
+ }
28
+
29
+ const at = (island: string): string =>
30
+ renderFixLiteral(islandStatesFile(island), ISLAND_PLACEHOLDER);
31
+
32
+ /**
33
+ * A manifest that declares no states. It parses, it registers and it expands to nothing — so the
34
+ * command that photographs it produces no file and exits 0, which is the one outcome a reader
35
+ * cannot tell from success. Refused at the declaration, where the file to edit is known.
36
+ */
37
+ export class IslandStatesEmptyError extends UltimateError {
38
+ constructor(input: { readonly island: string }) {
39
+ super({
40
+ code: 'X_TEST_ISLAND_STATES_EMPTY',
41
+ cause: `defineIslandStates(${renderCauseValue(input.island)}) declares no states, so it can never produce a picture`,
42
+ fix: `in ${at(input.island)} add: states: [{ id: 'empty', title: 'no rows yet', props: {} }]`,
43
+ });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A state id that is not a slug. The id becomes a FILENAME stem, so a space, a slash or a capital
49
+ * is either an unguessable path or a path outside the shot directory. The suggestion is the id
50
+ * slugified, so the edit is a paste rather than a decision.
51
+ */
52
+ export class IslandStateIdInvalidError extends UltimateError {
53
+ constructor(input: { readonly island: string; readonly id: string; readonly slug: string }) {
54
+ super({
55
+ code: 'X_TEST_ISLAND_STATE_ID_INVALID',
56
+ cause: `island state id ${renderCauseValue(input.id)} is not a slug — it becomes the screenshot filename stem`,
57
+ fix:
58
+ input.slug.length === 0
59
+ ? `in ${at(input.island)} give the state an id of lowercase letters, digits and single dashes: id: 'over-quota'`
60
+ : `in ${at(input.island)} write: id: '${input.slug}'`,
61
+ });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Two states with one id. The second picture overwrites the first at the same path, so the run
67
+ * reports two states and leaves one file — a loss with nothing in the output pointing at it.
68
+ */
69
+ export class IslandStateDuplicateError extends UltimateError {
70
+ constructor(input: { readonly island: string; readonly id: string }) {
71
+ super({
72
+ code: 'X_TEST_ISLAND_STATE_DUPLICATE',
73
+ cause: `two island states share the id ${renderCauseValue(input.id)}; the second picture would overwrite the first`,
74
+ fix: `in ${at(input.island)} rename one of them — id: '${input.id}-2', or the state it really is: id: 'over-quota'`,
75
+ });
76
+ }
77
+ }
78
+
79
+ /**
80
+ * A value that `JSON.stringify` does not carry. Island props ride the `data-x-props` script tag,
81
+ * which is JSON — so a `Date`, a function, a `Map` or an `undefined` is not "slightly wrong" in the
82
+ * picture, it is a prop the component never receives. Refused where the path is still known.
83
+ */
84
+ export class IslandStateJsonInvalidError extends UltimateError {
85
+ constructor(input: { readonly island: string; readonly path: string; readonly reason: string }) {
86
+ super({
87
+ code: 'X_TEST_ISLAND_STATE_JSON_INVALID',
88
+ cause: `${input.path} is ${input.reason}, and island props travel as JSON in data-x-props`,
89
+ fix: `in ${at(input.island)} write ${input.path} as JSON — a string, a number, a boolean, null, an array or a plain object`,
90
+ });
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The zone a picture is rendered in. A harness that freezes the INSTANT and leaves the zone ambient
96
+ * renders every date in the host machine's, so the same state photographs differently on two
97
+ * machines and the review diff reports a component change that never happened.
98
+ *
99
+ * Its own class beside the one below, sharing one code: two conditions, two instructions, and a
100
+ * `fix:` assembled by a ternary is a `fix:` the gate's own scanner reads only half of.
101
+ */
102
+ export class IslandStateZoneInvalidError extends UltimateError {
103
+ constructor(input: { readonly island: string; readonly value: unknown }) {
104
+ super({
105
+ code: 'X_TEST_ISLAND_STATE_CLOCK_INVALID',
106
+ cause: `timeZone ${renderCauseValue(input.value)} is not an IANA zone, so a rendered date would fall back to the host's`,
107
+ fix: `in ${at(input.island)} write: timeZone: 'UTC' — or one of Intl.supportedValuesOf('timeZone')`,
108
+ });
109
+ }
110
+ }
111
+
112
+ /** The frozen instant, with no offset on it — a different moment on every machine that reads it. */
113
+ export class IslandStateInstantInvalidError extends UltimateError {
114
+ constructor(input: { readonly island: string; readonly value: unknown }) {
115
+ super({
116
+ code: 'X_TEST_ISLAND_STATE_CLOCK_INVALID',
117
+ cause: `now ${renderCauseValue(input.value)} carries no explicit offset, so it means a different moment on every machine`,
118
+ fix: `in ${at(input.island)} write: now: '2026-01-01T00:00:00.000Z' — an ISO instant ending in Z or an offset`,
119
+ });
120
+ }
121
+ }
122
+
123
+ /**
124
+ * A route stub whose `match` cannot match. It is a `"<METHOD> <pathname>"` prefix, so a bare path
125
+ * or a lowercase verb silently stubs nothing: the component fetches for real, the request is
126
+ * refused by the sealed network, and the picture shows an error state nobody declared.
127
+ */
128
+ export class IslandStateStubInvalidError extends UltimateError {
129
+ constructor(input: { readonly island: string; readonly match: string }) {
130
+ super({
131
+ code: 'X_TEST_ISLAND_STATE_STUB_INVALID',
132
+ cause: `route stub ${renderCauseValue(input.match)} is not "<METHOD> <pathname>", so it would match no request`,
133
+ fix: `in ${at(input.island)} write: { match: 'GET /api/settings', respond: { kind: 'json', body: {} } }`,
134
+ });
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A states file that imports the component it describes. The whole design rests on this file being
140
+ * readable from Bun with no browser and no bundle — the command must know the complete expected
141
+ * screenshot list BEFORE a browser exists, or "produced nothing and exited 0" is indistinguishable
142
+ * from success. One JSX import makes the file unreadable by every consumer but the browser.
143
+ */
144
+ export class IslandStatesNotPureError extends UltimateError {
145
+ constructor(input: { readonly file: string; readonly specifier: string }) {
146
+ super({
147
+ code: 'X_TEST_ISLAND_STATES_NOT_PURE',
148
+ cause: `${renderCauseValue(input.file)} imports ${renderCauseValue(input.specifier)} — a states file is pure data and is read without a browser`,
149
+ fix: `in ${renderFixLiteral(input.file, STATES_PLACEHOLDER)} delete the import of ${renderCauseValue(input.specifier)} and declare the props as JSON instead`,
150
+ });
151
+ }
152
+ }
153
+
154
+ /**
155
+ * A states file that value-imports a SIBLING module. Its own class beside the one above, sharing
156
+ * one code, because the edit is a different one: `./settings.island` resolves to
157
+ * `./settings.island.tsx` under Bun, so the import the reader must repair is usually a missing
158
+ * `type` keyword rather than an import to delete — and `./helpers` may reach the component one hop
159
+ * further on, which a rule reading ONE file's text can never follow.
160
+ */
161
+ export class IslandStatesSiblingImportError extends UltimateError {
162
+ constructor(input: { readonly file: string; readonly specifier: string }) {
163
+ super({
164
+ code: 'X_TEST_ISLAND_STATES_NOT_PURE',
165
+ cause: `${renderCauseValue(input.file)} imports ${renderCauseValue(input.specifier)} at runtime, and a sibling module can reach the component the states file may not`,
166
+ fix: `in ${renderFixLiteral(input.file, STATES_PLACEHOLDER)} write: import type { Props } from ${renderFixLiteral(input.specifier, '<the specifier the cause names>')} — a type-only import is erased, and a value one must be inlined as JSON`,
167
+ });
168
+ }
169
+ }
170
+
171
+ /**
172
+ * A specifier this scanner cannot read: `import(`./${name}.island`)`, `require(SPEC)`. Its own
173
+ * class for the same reason as the one above — the edit is to write the specifier as a literal, so
174
+ * a static reader can judge it. Answering PURE over an unreadable import is the optimism the
175
+ * extensionless case already shipped once.
176
+ */
177
+ export class IslandStatesOpaqueImportError extends UltimateError {
178
+ constructor(input: { readonly file: string; readonly expression: string }) {
179
+ super({
180
+ code: 'X_TEST_ISLAND_STATES_NOT_PURE',
181
+ cause: `${renderCauseValue(input.file)} computes the import ${renderCauseValue(input.expression)}, so no reader can say where it goes without running it`,
182
+ fix: `in ${renderFixLiteral(input.file, STATES_PLACEHOLDER)} write the specifier as a string literal, or delete the import and inline what it exports as JSON`,
183
+ });
184
+ }
185
+ }
186
+
187
+ /**
188
+ * A declared island that is not on disk. Always a real defect and never a warning: the state list
189
+ * is the expected screenshot set, so a manifest pointing at a moved file expands to pictures that
190
+ * can never be taken.
191
+ */
192
+ export class IslandStatesMissingFileError extends UltimateError {
193
+ constructor(input: { readonly island: string; readonly root: string }) {
194
+ super({
195
+ code: 'X_TEST_ISLAND_STATES_MISSING_FILE',
196
+ cause: `no file at ${renderCauseValue(input.island)} under ${renderCauseValue(input.root)}, so its declared states can never be photographed`,
197
+ fix: `in ${at(input.island)} set island to the path that exists — it is relative to the app root, not to the states file`,
198
+ });
199
+ }
200
+ }
201
+
202
+ /**
203
+ * A name nothing answers to. Listing every valid name is the whole value: a typo and an island
204
+ * whose states were never declared are one symptom and two different edits, and only the list tells
205
+ * them apart without opening a directory.
206
+ */
207
+ export class IslandStatesUnknownError extends UltimateError {
208
+ constructor(input: { readonly name: string; readonly known: readonly string[] }) {
209
+ super({
210
+ code: 'X_TEST_ISLAND_STATES_UNKNOWN',
211
+ cause:
212
+ input.known.length === 0
213
+ ? `no island states are declared in this process, so ${renderCauseValue(input.name)} resolves to nothing`
214
+ : `no island states answer to ${renderCauseValue(input.name)}; declared: ${input.known.join(', ')}`,
215
+ fix:
216
+ input.known.length === 0
217
+ ? "declare one beside the island: export const states = defineIslandStates({ island: 'apps/web/app/settings/settings.island.tsx', states: [...] })"
218
+ : `name one of them instead: ${input.known[0] ?? ''}`,
219
+ });
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Two manifests answering to one name. Resolution is loose on purpose — `Settings`, `settings` and
225
+ * `settings.island.tsx` are one name — and that looseness is exactly what makes two islands with
226
+ * the same basename ambiguous. Refused when the set is loaded, not when a picture is missing.
227
+ */
228
+ export class IslandStatesAmbiguousError extends UltimateError {
229
+ constructor(input: { readonly name: string; readonly islands: readonly string[] }) {
230
+ super({
231
+ code: 'X_TEST_ISLAND_STATES_AMBIGUOUS',
232
+ cause: `${input.islands.length} islands answer to ${renderCauseValue(input.name)}: ${input.islands.join(', ')}`,
233
+ fix: `rename one island file so the two basenames differ — the basename is the shot directory, so today they would share one`,
234
+ });
235
+ }
236
+ }
@@ -0,0 +1,114 @@
1
+ // The rules a declared island state must satisfy, as pure functions over values. Separate from
2
+ // `island-states.ts` so the vocabulary can be read without the rules and the rules can be tested
3
+ // without building a manifest; each answers a FAULT rather than throwing, so the caller is the one
4
+ // place that decides which code the failure carries.
5
+
6
+ /** A slug: lowercase letters and digits, single dashes between them. It becomes a filename stem. */
7
+ const STATE_ID = /^[a-z\d]+(?:-[a-z\d]+)*$/;
8
+
9
+ export const isStateId = (id: string): boolean => STATE_ID.test(id);
10
+
11
+ /** The id the author probably meant — `''` when nothing survives, which the error reads as "no suggestion". */
12
+ export function slugifyStateId(id: string): string {
13
+ return id
14
+ .toLowerCase()
15
+ .replace(/[^a-z\d]+/g, '-')
16
+ .replace(/^-+|-+$/g, '');
17
+ }
18
+
19
+ /**
20
+ * An instant with an EXPLICIT offset. `2026-01-01T00:00` parses on every runtime and means a
21
+ * different moment in every zone, which is the defect this vocabulary exists to close: a harness
22
+ * that pins the instant and leaves the zone ambient photographs two different pictures on two
23
+ * machines and neither is wrong.
24
+ */
25
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
26
+
27
+ export function isPinnedInstant(value: string): boolean {
28
+ return INSTANT.test(value) && Number.isFinite(Date.parse(value));
29
+ }
30
+
31
+ /**
32
+ * An IANA zone, asked of the runtime rather than of a list: `Intl` is the thing that will format
33
+ * the date, so its answer is the only one that matters. It throws a `RangeError` on a name it does
34
+ * not know — the one case where a `catch` is the check.
35
+ */
36
+ export function isTimeZone(zone: string): boolean {
37
+ try {
38
+ new Intl.DateTimeFormat('en-US', { timeZone: zone });
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** `"<METHOD> <pathname>"`, matched as a prefix by the harness. A lowercase verb matches nothing. */
46
+ const STUB_MATCH = /^[A-Z]+ \/\S*$/;
47
+
48
+ export const isStubMatch = (match: string): boolean => STUB_MATCH.test(match);
49
+
50
+ export interface JsonFault {
51
+ /** Where in the declaration the value sits — `props.user.createdAt`, `routes[0].respond.body`. */
52
+ readonly path: string;
53
+ /** What it is, phrased to finish "…is <reason>". */
54
+ readonly reason: string;
55
+ }
56
+
57
+ /** `[object Date]` → `Date`. Read this way because a `constructor.name` getter can throw. */
58
+ const tagOf = (value: object): string =>
59
+ Object.prototype.toString.call(value).slice('[object '.length, -1);
60
+
61
+ /**
62
+ * The first value in `value` that `JSON.stringify` would not carry, or `undefined` when the whole
63
+ * structure survives the trip. Island props ride `data-x-props`, which is JSON by construction
64
+ * (`@ultimat3/render`'s `emitIslandProps`), so anything else is not "approximately right" in the
65
+ * picture — it is a prop the component never receives, silently.
66
+ *
67
+ * Stricter than `JSON.stringify` on purpose, in the three places it degrades instead of failing:
68
+ * `undefined` disappears, a non-finite number becomes `null`, and a `Date` becomes a string that no
69
+ * longer answers `.getTime()`. Each is a state that photographs as a crash with nothing pointing
70
+ * back at the declaration.
71
+ */
72
+ export function jsonFault(
73
+ value: unknown,
74
+ path: string,
75
+ seen: readonly object[] = [],
76
+ ): JsonFault | undefined {
77
+ if (value === null) return undefined;
78
+ switch (typeof value) {
79
+ case 'string':
80
+ case 'boolean':
81
+ return undefined;
82
+ case 'number':
83
+ return Number.isFinite(value) ? undefined : { path, reason: 'not a finite number' };
84
+ case 'undefined':
85
+ return { path, reason: 'undefined, which JSON drops without a trace' };
86
+ case 'bigint':
87
+ return { path, reason: 'a bigint, which JSON cannot carry' };
88
+ case 'symbol':
89
+ return { path, reason: 'a symbol' };
90
+ case 'function':
91
+ return { path, reason: 'a function — a picture takes no callbacks' };
92
+ default:
93
+ break;
94
+ }
95
+ const object = value as object;
96
+ if (seen.includes(object)) return { path, reason: 'a cycle' };
97
+ const next = [...seen, object];
98
+ if (Array.isArray(object)) {
99
+ for (const [index, item] of object.entries()) {
100
+ const fault = jsonFault(item, `${path}[${index}]`, next);
101
+ if (fault !== undefined) return fault;
102
+ }
103
+ return undefined;
104
+ }
105
+ const proto: unknown = Object.getPrototypeOf(object);
106
+ if (proto !== Object.prototype && proto !== null) {
107
+ return { path, reason: `a ${tagOf(object)}, and only a plain object survives JSON` };
108
+ }
109
+ for (const [key, entry] of Object.entries(object)) {
110
+ const fault = jsonFault(entry, `${path}.${key}`, next);
111
+ if (fault !== undefined) return fault;
112
+ }
113
+ return undefined;
114
+ }
@@ -0,0 +1,147 @@
1
+ // The guard the whole design rests on: a `*.island.states.ts` file is PURE DATA, so the command
2
+ // that photographs the states, the harness page and a test can all read it — and one import of a
3
+ // sibling module leaves it readable by a bundler alone. Static, because a module importing Solid
4
+ // evaluates fine under Bun, so no amount of loading it can notice.
5
+
6
+ import {
7
+ IslandStatesNotPureError,
8
+ IslandStatesOpaqueImportError,
9
+ IslandStatesSiblingImportError,
10
+ } from './island-state-errors';
11
+
12
+ /** Line and block comments blanked, so a specifier written in prose is never read as an import. */
13
+ const stripComments = (source: string): string =>
14
+ source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
15
+
16
+ /**
17
+ * One edge of the file's module graph, carrying the single distinction that decides whether the
18
+ * edge EXISTS at runtime: `verbatimModuleSyntax` erases a statement that begins `import type` /
19
+ * `export type` and keeps every other one — including `import { type X } from './y'`, which it
20
+ * emits as `import {} from './y'` and which therefore evaluates `./y`.
21
+ */
22
+ export interface ModuleEdge {
23
+ readonly specifier: string;
24
+ readonly typeOnly: boolean;
25
+ }
26
+
27
+ /**
28
+ * `from '<spec>'`, `import '<spec>'`, `import('<spec>')`, `require('<spec>')` — every static and
29
+ * dynamic edge a Bun module graph can have, read off the text rather than by loading it. The
30
+ * leading `type` is captured with the statement rather than guessed at afterwards; the lookahead
31
+ * is what keeps `import type from './y'` (a default import NAMED type) a value import.
32
+ */
33
+ const EDGE =
34
+ /\b(?:import|export)\s+(?<only>type\s+(?!from\s*['"]))?[^'"();]*?\bfrom\s*['"](?<from>[^'"]+)['"]|\bimport\s*\(\s*['"](?<dynamic>[^'"]+)['"]|\brequire\s*\(\s*['"](?<required>[^'"]+)['"]|\bimport\s+['"](?<bare>[^'"]+)['"]/g;
35
+
36
+ /** Every module edge the text declares, in source order, type-only ones included. */
37
+ export function moduleEdges(source: string): readonly ModuleEdge[] {
38
+ const edges: ModuleEdge[] = [];
39
+ for (const match of stripComments(source).matchAll(EDGE)) {
40
+ const groups = match.groups;
41
+ if (groups === undefined) continue;
42
+ const from = groups['from'];
43
+ const specifier = from ?? groups['dynamic'] ?? groups['required'] ?? groups['bare'];
44
+ if (specifier === undefined) continue;
45
+ edges.push({ specifier, typeOnly: from !== undefined && groups['only'] !== undefined });
46
+ }
47
+ return edges;
48
+ }
49
+
50
+ /** Backwards-compatible view: the specifiers alone, which cannot say whether one is erased. */
51
+ export function importSpecifiers(source: string): readonly string[] {
52
+ return moduleEdges(source).map((edge) => edge.specifier);
53
+ }
54
+
55
+ /**
56
+ * An `import(…)` / `require(…)` whose argument is not a string literal. Captured up to the closing
57
+ * paren or the end of the line, so the refusal can quote the expression the reader must rewrite.
58
+ */
59
+ const COMPUTED = /\b(?:import|require)\s*\(\s*(?!['")])(?<expression>[^)\n]{1,60})/g;
60
+
61
+ /** A specifier resolved against this file's own directory, `.json` excluded — see below. */
62
+ const RELATIVE = /^\.\.?\//;
63
+
64
+ /**
65
+ * A specifier that is PROVABLY a browser module: JSX, or the renderer one import earlier. Its own
66
+ * predicate because it is what the older refusal — delete this import — is the right edit for.
67
+ */
68
+ export function browserSpecifier(specifier: string): boolean {
69
+ return (
70
+ specifier === 'solid-js' ||
71
+ specifier.startsWith('solid-js/') ||
72
+ specifier.endsWith('.tsx') ||
73
+ specifier.endsWith('.jsx')
74
+ );
75
+ }
76
+
77
+ /**
78
+ * What a states file may not reach for at RUNTIME, which is a wider set than the one above.
79
+ *
80
+ * Any RELATIVE specifier counts, and that is the rule with teeth: it names a module whose own
81
+ * imports this scanner never sees. `./settings.island` resolves to `./settings.island.tsx` under
82
+ * Bun and read as pure until 2026-08-23 — an extension test answers about the spelling and not
83
+ * about the graph — and `./helpers` is the same edge one hop longer. The relativeness is the rule
84
+ * rather than the `.island` stem, which is also why nothing here restates `@ultimat3/render`'s
85
+ * `ISLAND_EXTENSION`.
86
+ *
87
+ * `.json` is the one exemption, and it is the only one that can be safe: a JSON module has no
88
+ * imports, so it is the single relative target whose graph needs no following.
89
+ *
90
+ * A BARE specifier other than `solid-js` is not followed either and is deliberately not refused —
91
+ * a package list here would be the drift a list always is. `@ultimat3/ui` in a states file is
92
+ * therefore an open hole, named in this package's CLAUDE.md rather than left silent.
93
+ */
94
+ export function impureSpecifier(specifier: string): boolean {
95
+ return browserSpecifier(specifier) || (RELATIVE.test(specifier) && !specifier.endsWith('.json'));
96
+ }
97
+
98
+ /** Which refusal the fault carries — the reader's edit differs by kind, so the class does too. */
99
+ export type IslandFaultKind = 'browser' | 'sibling' | 'opaque';
100
+
101
+ export interface IslandStatesFault {
102
+ readonly kind: IslandFaultKind;
103
+ /** The specifier, or — for `opaque` — the expression text that stands where one should be. */
104
+ readonly specifier: string;
105
+ }
106
+
107
+ /**
108
+ * The first thing that breaks the rule, or `undefined`. NOT total: a specifier this cannot read is
109
+ * reported as `opaque` rather than passed, because "unreadable, therefore pure" is exactly the
110
+ * optimism that let an extensionless import of the component through.
111
+ */
112
+ export function islandStatesFault(source: string): IslandStatesFault | undefined {
113
+ for (const edge of moduleEdges(source)) {
114
+ // A type-only edge is erased before the module is ever evaluated — proved against Bun in
115
+ // `island-states-pure.test.ts`, because the whole exemption rests on it. It is also the one
116
+ // way a states file may name its component, and the reference app types its props that way.
117
+ if (edge.typeOnly) continue;
118
+ if (!impureSpecifier(edge.specifier)) continue;
119
+ // A specifier that is PROVABLY a browser module keeps the older refusal and its older edit —
120
+ // deleting `import './x.island.tsx'` is the instruction, not writing it as a type import.
121
+ return {
122
+ kind: browserSpecifier(edge.specifier) ? 'browser' : 'sibling',
123
+ specifier: edge.specifier,
124
+ };
125
+ }
126
+ const computed = stripComments(source).match(COMPUTED);
127
+ const expression = computed?.[0]?.replace(/^\s*(?:import|require)\s*\(\s*/, '').trim();
128
+ return expression === undefined ? undefined : { kind: 'opaque', specifier: expression };
129
+ }
130
+
131
+ /** The first specifier that breaks the rule, or `undefined`. */
132
+ export function islandStatesImportFault(source: string): string | undefined {
133
+ return islandStatesFault(source)?.specifier;
134
+ }
135
+
136
+ /** The same rule, as a refusal. `file` is only ever named back to the reader, never resolved here. */
137
+ export function assertIslandStatesPure(file: string, source: string): void {
138
+ const fault = islandStatesFault(source);
139
+ if (fault === undefined) return;
140
+ if (fault.kind === 'opaque') {
141
+ throw new IslandStatesOpaqueImportError({ file, expression: fault.specifier });
142
+ }
143
+ const input = { file, specifier: fault.specifier };
144
+ throw fault.kind === 'sibling'
145
+ ? new IslandStatesSiblingImportError(input)
146
+ : new IslandStatesNotPureError(input);
147
+ }
@@ -0,0 +1,106 @@
1
+ // Resolution, in the two directions a caller needs it: a NAME a reader typed → the manifest it
2
+ // meant, and a manifest → the island file it claims on disk. Loose on the way in and strict on the
3
+ // way out — a typo must never be a silent miss, so an unresolved name lists every valid one.
4
+
5
+ import { join } from 'node:path'; // why: no Bun native joins a path; `Bun.file` takes one already joined.
6
+ import {
7
+ IslandStatesAmbiguousError,
8
+ IslandStatesMissingFileError,
9
+ IslandStatesUnknownError,
10
+ } from './island-state-errors';
11
+ import type { IslandStatesManifest } from './island-states';
12
+
13
+ /**
14
+ * `Settings`, `settings`, `settings.island.tsx` and `apps/web/app/settings/settings.island.tsx` are
15
+ * one name. Case and separators are dropped because a reader types the component the way it is
16
+ * spelled in JSX and the file the way it is spelled on disk, and neither is wrong.
17
+ */
18
+ export function normalizeIslandName(value: string): string {
19
+ const base = value.split('/').pop() ?? value;
20
+ const stem = base.split('.')[0] ?? base;
21
+ return stem.toLowerCase().replace(/[^a-z\d]+/g, '');
22
+ }
23
+
24
+ /** Every name a reader may type, in manifest order — what an unresolved lookup reports back. */
25
+ export const islandStatesNames = (all: readonly IslandStatesManifest[]): readonly string[] =>
26
+ all.map((manifest) => manifest.name);
27
+
28
+ /** Manifests answering to `name`. Zero and two are both failures, and different ones. */
29
+ export function islandStatesMatching(
30
+ all: readonly IslandStatesManifest[],
31
+ name: string,
32
+ ): readonly IslandStatesManifest[] {
33
+ const wanted = normalizeIslandName(name);
34
+ return all.filter((manifest) => normalizeIslandName(manifest.name) === wanted);
35
+ }
36
+
37
+ /**
38
+ * The one refusal in this vocabulary. Everything downstream falls back — a mistyped THEME still
39
+ * shows the component — but a name nothing answers to has no defensible fallback: photographing
40
+ * some other island would be a picture that reads as an answer.
41
+ */
42
+ export function findIslandStates(
43
+ all: readonly IslandStatesManifest[],
44
+ name: string,
45
+ ): IslandStatesManifest {
46
+ const matches = islandStatesMatching(all, name);
47
+ const first = matches[0];
48
+ if (first === undefined) {
49
+ throw new IslandStatesUnknownError({ name, known: islandStatesNames(all) });
50
+ }
51
+ if (matches.length > 1) {
52
+ throw new IslandStatesAmbiguousError({
53
+ name,
54
+ islands: matches.map((manifest) => manifest.island),
55
+ });
56
+ }
57
+ return first;
58
+ }
59
+
60
+ /**
61
+ * Two manifests one name would resolve to. Asked of the whole SET, once, rather than discovered by
62
+ * a lookup that happens to be made: two islands sharing a basename also share a shot directory, so
63
+ * the collision loses pictures whether or not anyone ever types the ambiguous name.
64
+ */
65
+ export function assertUniqueIslandStates(all: readonly IslandStatesManifest[]): void {
66
+ const byName = new Map<string, IslandStatesManifest[]>();
67
+ for (const manifest of all) {
68
+ const key = normalizeIslandName(manifest.name);
69
+ byName.set(key, [...(byName.get(key) ?? []), manifest]);
70
+ }
71
+ for (const [name, group] of byName) {
72
+ if (group.length > 1) {
73
+ throw new IslandStatesAmbiguousError({
74
+ name,
75
+ islands: group.map((manifest) => manifest.island),
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Declared islands with no file under `root`. Not part of `defineIslandStates`: a declaration is
83
+ * evaluated wherever the module is imported from, and a rule that reads the filesystem at import
84
+ * time fails on the cwd rather than on the path. The check belongs where a root is known — the
85
+ * guard test, and the command that takes the pictures.
86
+ */
87
+ export async function missingIslandFiles(
88
+ all: readonly IslandStatesManifest[],
89
+ root: string,
90
+ ): Promise<readonly string[]> {
91
+ const missing: string[] = [];
92
+ for (const manifest of all) {
93
+ if (!(await Bun.file(join(root, manifest.island)).exists())) missing.push(manifest.island);
94
+ }
95
+ return missing;
96
+ }
97
+
98
+ /** The same check as a refusal, naming the first island that is not there. */
99
+ export async function assertIslandFiles(
100
+ all: readonly IslandStatesManifest[],
101
+ root: string,
102
+ ): Promise<void> {
103
+ const missing = await missingIslandFiles(all, root);
104
+ const first = missing[0];
105
+ if (first !== undefined) throw new IslandStatesMissingFileError({ island: first, root });
106
+ }
@@ -0,0 +1,110 @@
1
+ // The vocabulary for declaring the STATES one island can be photographed in — error, empty,
2
+ // over-quota, read-only: the ones a reviewer cannot reach by clicking. Types and constants only, so
3
+ // a `settings.island.states.ts` beside the island is readable without a browser or a bundle;
4
+ // `define-island-states.ts` is what validates one, and nothing here imports anything at all.
5
+
6
+ export const ISLAND_THEMES = ['light', 'dark'] as const;
7
+ export type IslandTheme = (typeof ISLAND_THEMES)[number];
8
+
9
+ /** What an address falls back to. A mistyped theme shows the component, never an error page. */
10
+ export const DEFAULT_ISLAND_THEME: IslandTheme = 'light';
11
+
12
+ export interface IslandViewport {
13
+ readonly width: number;
14
+ readonly height: number;
15
+ }
16
+
17
+ /** A laptop, not a phone: the state under review is the component's, not the breakpoint's. */
18
+ export const DEFAULT_ISLAND_VIEWPORT: IslandViewport = { width: 1280, height: 800 };
19
+
20
+ /**
21
+ * The zone every picture is rendered in unless a manifest says otherwise. Pinned in the VOCABULARY
22
+ * rather than left to the harness: a run that freezes the instant and leaves the zone ambient
23
+ * renders `12:00` on one machine and `14:00` on the next, and the diff between two reviews then
24
+ * says the component changed when only the reviewer moved.
25
+ */
26
+ export const ISLAND_SHOT_TIME_ZONE = 'UTC';
27
+
28
+ export type IslandStubResponse =
29
+ | { readonly kind: 'json'; readonly status?: number; readonly body: unknown }
30
+ | { readonly kind: 'pending' }
31
+ | { readonly kind: 'offline' };
32
+
33
+ export interface IslandRouteStub {
34
+ /** `"<METHOD> <pathname>"`, matched as a PREFIX: `'GET /api/quota'` catches its query string. */
35
+ readonly match: string;
36
+ readonly respond: IslandStubResponse;
37
+ }
38
+
39
+ export interface IslandStateDecl {
40
+ /** A slug. It becomes the screenshot filename stem, so it is guessable or it is nothing. */
41
+ readonly id: string;
42
+ /** One line: what this state IS. */
43
+ readonly title: string;
44
+ /** WHY it deserves a picture — usually "you cannot reach this by clicking, because …". */
45
+ readonly note?: string;
46
+ /** JSON, by construction: these ride the same `data-x-props` seam hydration already uses. */
47
+ readonly props: Readonly<Record<string, unknown>>;
48
+ /** Fixtures for whatever the component fetches on its own. */
49
+ readonly routes?: readonly IslandRouteStub[];
50
+ readonly viewport?: IslandViewport;
51
+ /** Both, unless the state is only meaningful in one of them. */
52
+ readonly themes?: readonly IslandTheme[];
53
+ }
54
+
55
+ export interface IslandStatesDecl {
56
+ /** App-root-relative path of the `.island.tsx` these states belong to. */
57
+ readonly island: string;
58
+ readonly states: readonly IslandStateDecl[];
59
+ readonly viewport?: IslandViewport;
60
+ /** CSS selector to crop to. The island's host element when absent. */
61
+ readonly target?: string;
62
+ /** IANA zone. `UTC` unless the state under review is about a zone. */
63
+ readonly timeZone?: string;
64
+ /** The frozen instant, with an explicit offset. The suite's own `DEFAULT_NOW` when absent. */
65
+ readonly now?: string;
66
+ }
67
+
68
+ /** A declared state with every default resolved — what a target is expanded from. */
69
+ export interface IslandState {
70
+ readonly id: string;
71
+ readonly title: string;
72
+ readonly note?: string;
73
+ readonly props: Readonly<Record<string, unknown>>;
74
+ readonly routes: readonly IslandRouteStub[];
75
+ readonly viewport: IslandViewport;
76
+ readonly themes: readonly IslandTheme[];
77
+ }
78
+
79
+ /**
80
+ * Registered globally so two copies of this module agree on what a manifest is — the same reason
81
+ * `@ultimat3/render`'s island node carries one. It is what lets a loader read an app's states file
82
+ * and tell a manifest from every other export in it without trusting a name.
83
+ */
84
+ export const ISLAND_STATES: unique symbol = Symbol.for('ultimate.testing.island-states') as never;
85
+
86
+ export interface IslandStatesManifest {
87
+ readonly [ISLAND_STATES]: true;
88
+ /** Derived from the island's own filename: the shot directory, and the name a reader types. */
89
+ readonly name: string;
90
+ readonly island: string;
91
+ readonly states: readonly IslandState[];
92
+ readonly viewport: IslandViewport;
93
+ readonly target?: string;
94
+ readonly timeZone: string;
95
+ readonly now: string;
96
+ }
97
+
98
+ export function isIslandStatesManifest(value: unknown): value is IslandStatesManifest {
99
+ return typeof value === 'object' && value !== null && ISLAND_STATES in value;
100
+ }
101
+
102
+ /**
103
+ * `apps/web/app/settings/settings.island.tsx` → `settings`. The basename up to its FIRST dot, so
104
+ * the island extension is never restated in this package — it is `@ultimat3/render`'s constant and
105
+ * a second copy of it here is a second thing to keep in step.
106
+ */
107
+ export function islandStatesName(island: string): string {
108
+ const base = island.split('/').pop() ?? island;
109
+ return (base.split('.')[0] ?? base).toLowerCase();
110
+ }