Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 273 additions & 0 deletions docs/subject-keyed-zones-triage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
# Triage: the cat-factory slice-4 request for `@modular-vue/zones` (subject-keyed zones)

Downstream request: [kibertoad/cat-factory#1205](https://github.com/kibertoad/cat-factory/pull/1205)
(`docs/initiatives/modular-vue-slice4-upstream-zones.md` on their side). Status here:
**triaged — accepted in substance, redirected in shape.** This document is the upstream
half of the co-evolution artifact: what we agree to build, what we decline, and why.

## Verdict in one paragraph

The need is real and well-evidenced: a named region whose contributions are selected by a
runtime **subject** (their selected board block), gated by per-contribution predicates,
ordered, rendered **all-matching** (not pick-one), and contributable by first-party and
consumer modules alike. Nothing shipped today provides that host. But the proposed
delivery — a new Vue-only package named `@modular-vue/zones`, a new `zones` registration
seam on module descriptors, a new registry plugin, and Nuxt manifest threading — is the
wrong shape for this library on four counts, detailed below. The same semantics fall out
of a much smaller change: the requested aggregation model **is the slot model we already
ship**, and what is genuinely missing is a pure, framework-neutral _subject resolver_ over
slot entries plus a thin host per binding. That is the exact shape slice 2's pairing
helpers took ("read-side projections of an already-resolved slot"), and it should land
engine-first, on React and Vue together, under a name that is not "zones".

## What the request gets right

- **Gap A is real.** Every zones surface we ship is route-driven (`useZones` /
`useActiveZones` over route `meta`/`handle` in `@react-router-modules/runtime`,
`@tanstack-react-modules/runtime`, `@modular-vue/runtime`) or module-activation-driven
(`ModuleDescriptor.zones`, pick-one per key). None of them keys contributions off a
caller-supplied piece of application state.
- **Gap C is real, and the request's own analysis is correct.** Slice-2 pairing
(`resolveComponentRegistry` / `pairById`) is a pick-**one**-by-id lookup; the inspector
is a filtered, ordered, render-**all** concatenation. Different reduction, correctly
identified as a different primitive.
- **The compositions out-of-scope call is correct — but for a reason worth recording.**
Composition zones are state-driven, which makes them look adjacent, but they are
(a) **pick-one per zone** — a zone's `select(ctx)` returns a single resolution — and
(b) **closed**: the zone set and selectors are declared by the composition definition,
so a consumer module cannot add a panel without editing the composition. The inspector
needs an **open, render-all** contribution surface. Compositions genuinely do not fit;
the request was right not to bend them.
- **The extensibility argument is the load-bearing one.** A local `v-if` map, however
tidy, cannot be extended by a consumer deployment shipping a custom block type. A
registry-aggregated contribution surface can. That is the same seam nav items and
result views already use, and it is why this deserves a library primitive rather than a
cat-factory shim.
- **Arbitrary `when(subject)` predicates (not a keyed map) are required.** Their
visibility conditions are conjunctions over level _and_ type _and_ live run state; a
`level → panels` map cannot express that. The predicate contract is right.

## Where the request goes wrong

### 1. "Zones" is the one name this primitive must not have

The word "zone" already means **two unrelated things** in this library — the
`packages/compositions` README carries an explicit "two distinct primitives both use the
word zone; they are unrelated" apology table for exactly this reason:

| Existing primitive | Selection | Cardinality | Contributed by |
| --------------------------------------------------------------------------- | ---------------------------- | --------------------------------------- | ---------------------------------------- |
| Route/module zones (`useZones`, `useActiveZones`, `ModuleDescriptor.zones`) | active route / active module | **one** component per key, deepest-wins | route `handle`/`meta`, module descriptor |
| Composition zones (`defineComposition({ zones })`) | per-zone `select(state)` | **one** resolution per zone | the composition definition (closed) |

The request adds a third semantic — subject-keyed, predicate-gated, **render-all**,
open contribution — that differs from _both_ existing meanings on every axis. Worse, a
Vue consumer would then hold `useZones` (route-driven, from `@modular-vue/runtime`) and
`useZone` (subject-driven, from `@modular-vue/zones`) in one app: two imports one letter
apart with disjoint semantics. The request itself invites us to "align names with the
existing zones vocabulary" — the honest alignment is to conclude the vocabulary is full
and pick a new word. Recommendation: the **panels** family — `definePanelGroup`,
`usePanels`, `<PanelsOutlet>` — which matches the downstream's own language ("inspector
panels", "detail panels") and stays greppable. ("Subject slots" and "facets" were the
runners-up; final naming is a maintainer call, but "zones" is rejected.)

### 2. The proposed registration seam collides with a shipped field

`defineModule({ zones: { inspector: [...] } })` cannot ship as written:
`ModuleDescriptor.zones` already exists (`readonly zones?: Readonly<Record<string,
UiComponent>>` — one component per key, read by `useActiveZones` in workspace-style
shells). Redefining it as `Record<string, ZoneContribution[]>` is a breaking change to a
published contract, and unioning the two shapes on one field would poison both. The
"additive; no breaking changes" framing of the request is false at precisely its main
seam. This alone forces a different registration path — and we already have the right
one (next point).

### 3. This is a slot, not a new registration concept

Strip the framing and look at the required aggregation semantics: contributions declared
per-module, aggregated by the registry across first-party **and** consumer modules,
array-concatenated per key, registration order preserved, deterministically ordered,
consumed by a host. That is `module.slots` + `buildSlotsManifest`, verbatim — the seam
slice 1 (nav) and slice 2 (result views) already ride, reactive in Vue via
`useReactiveSlots` / the slots signal. The request concedes this ("plain slots can be
bent into this") but frames a slot-based build as a shim. It has it backwards: slot
entries are **opaque by design** (`SlotMap = Record<string, readonly unknown[]>`) —
apps already put typed entry objects in them. Contributing
`{ id, component, when, order }` entries to a slot is not bending slots; it is what
slots are for. What is _actually_ missing is small and pure:

1. a **typed entry shape** — `{ id, component, when?(subject), order?, props? }`;
2. a **pure resolver** — `(entries, subject) → visible, ordered entries` (null subject
→ empty; stable sort on `order`, ties by contribution order; fail-loud on duplicate
ids, consistent with `resolveComponentRegistry`'s stance);
3. a **thin host per binding** that renders each resolved entry with the subject
injected, keyed per `(entry.id, subjectKey)`, each wrapped in the binding's module
error boundary.

This is exactly the shape slice 2 shipped as: pure read-side projections in
`@modular-frontend/core`, "no module type and no new ingress", re-exported by the
bindings. Following the same shape here dissolves most of the request's surface area:

- **§4A's registry plumbing** — not needed; `registerAppModule`-style consumer
extension falls out of the existing module → slot path for free.
- **§4E (Nuxt / `installModularApp`)** — collapses to ~zero code. The "resolved zone
manifest" _is_ the slots manifest, which `installModularApp` already provides;
`<PanelsOutlet>` reads it from the existing slots context. No `manifest.zones`, no
plugin-extension type threading, no cast to eliminate.
- **§4A's `/testing` subpath** — not needed. The resolver is a pure named export of the
engine; cat-factory unit-tests its predicate/order table by calling it, no DOM, today.
- **§4B's opt-in route-sync helper** — cut entirely (YAGNI). The host is route-free _by
construction_ because nothing in the slot+subject path can see a router; there is
nothing to keep off the "baseline import path". Nobody has asked for the routed case.

### 4. A new Vue-only package inverts the family's architecture

The operating rule of this repo — restated as recently as the journey-sync and
`JourneyHost` work — is _engine-first, both bindings together_: neutral logic in
`@modular-frontend/*`, React and Vue landing the binding surface in the same train, with
the Angular tracker staged for the same parity. `@modular-vue/zones` as the definition
point of a framework-neutral concept inverts that: React (whose consumers have the same
single-route, state-driven detail-panel need — the primitive is not Vue-shaped in any
way) would later have to port _from the Vue binding_, and Angular from that. New
packages in this family are justified by runtimes with instance lifecycles (journeys,
compositions), not by one pure function plus one small component — pairing, the closest
precedent in both size and kind, went into `@modular-frontend/core` + binding
re-exports, not a package. Publishing an essentially-empty `@modular-vue/zones` would
also permanently commit the npm namespace to the name rejected in point 1.

## What we will build instead (proposed)

Engine (`@modular-frontend/core`; components stay opaque `UiComponent`s, per PR-01):

```ts
export interface PanelEntry<TSubject> {
readonly id: string;
readonly component: UiComponent;
readonly when?: (subject: TSubject) => boolean; // absent = always
readonly order?: number; // ascending; ties keep contribution order
readonly props?: Record<string, unknown>; // merged with { subject }
}

/** Phantom-typed handle, same convention as defineJourneyHandle /
* defineCompositionHandle: carries TSubject + the slot key. */
export function definePanelGroup<TSubject>(slotKey: string): PanelGroupHandle<TSubject>;

/** Pure: filter by when(subject), stable-sort by order; null/undefined
* subject → empty; duplicate ids throw (align with resolveComponentRegistry). */
export function resolvePanels<TSubject>(
entries: readonly PanelEntry<TSubject>[],
subject: TSubject | null | undefined,
): readonly PanelEntry<TSubject>[];
Comment on lines +146 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Specify deterministic ordering and duplicate-ID semantics.

The contract says entries are sorted by optional order, but does not define where missing orders go or whether duplicate IDs are checked before or after predicate filtering. State these rules explicitly so both bindings produce identical results.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/subject-keyed-zones-triage.md` around lines 146 - 160, Clarify the
resolvePanels contract around ordering and duplicate validation: define where
entries with missing order appear relative to ordered entries, and specify
whether duplicate IDs are detected across all entries or only after
when(subject) filtering. Ensure both bindings implement the same deterministic
rules.

```

Vue (`@modular-vue/vue`, re-exported from `@modular-vue/core` like the pairing surface):

- `usePanels(group, subject: MaybeRefOrGetter<TSubject | null>)` — `computed` over the
slots context + the subject; subject-reactive by the same source-boundary rules
`useReactiveSlots` already documents (see caveat below).
- `<PanelsOutlet :group :subject :subject-key>` — renders every resolved entry with
`subject` as a prop **and** via `provide` (`usePanelSubject()` for nested content),
keyed by `entry.id + subjectKey(subject)`, each entry wrapped in
`ModuleErrorBoundary`; `#empty` slot for the no-match case. (`#wrap` per-entry chrome:
accepted, it is cheap and genuinely useful for collapsible shells.)
Comment on lines +168 to +172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the subject-key contract explicit.

Define its type, uniqueness requirements, and whether <PanelsOutlet> provides a default. Unstable or non-unique keys can reuse panel state across different subjects or cause unnecessary remounts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/subject-keyed-zones-triage.md` around lines 168 - 172, Update the
PanelsOutlet documentation to explicitly define the subject-key contract: state
its accepted type, require a stable unique value per subject, and specify
whether the component supplies a default when subject-key is omitted. Explain
that keys must not be unstable or reused across different subjects.


React (`@modular-react/react`, same train): `usePanels` + `<PanelsOutlet>` — the same
~hundred lines with `useMemo`/context instead of `computed`/`provide`. Angular gets
`injectPanels` whenever its gate opens; the engine part is already done for it.

Module authors contribute through the existing field — no descriptor change at all:

```ts
defineModule({
id: "inspector-frames",
slots: {
inspectorPanels: [
{
id: "frontend-config",
component: FrontendConfig,
order: 20,
when: (b: BoardBlock) => b.level === "frame" && b.type === "frontend",
},
],
},
});
```

Dropped from the proposed API, deliberately:

- **`section`** — one group with three sections is isomorphic to three group ids
(`inspector.body`, `inspector.banners`, `inspector.actions`), which are already typed,
already bucketed by the slot map, and need no new concept, no `options.section`
parameter, and no per-section ordering rules. If a real interleaving need appears
later, `section` can be added compatibly; starting without it is the smaller contract.
- **route-sync helper** — see point 3 above.
- **`/testing` entry, registry plugin, Nuxt manifest threading** — subsumed, see point 3.

One honest caveat to document (not to engineer around): subject-reactivity of
_mutable-state_ predicates (their "run flips to failed" case) requires the subject to be
Vue-reactive state — a Pinia/`reactive` object, which cat-factory's
`ui.selectedBlockId → board.getBlock` computed already is. A predicate reading a
non-reactive snapshot tracks nothing. This is the exact source-boundary
`reactive-slots-vue.md` already teaches; the panels guide will cross-link it rather than
invent a second reactivity story.

## The version-alignment carry-over (their Gap D) — stale, but a real residual exists

The request's picture is out of date in both directions:

- The widen it asks for **already shipped**: `@modular-vue/{vue,core,runtime,nuxt,journeys}`
peer `@modular-frontend/core@^0.1.0 || ^0.2.0` today.
- Meanwhile `@modular-frontend/core@0.3.0` published on 2026-07-19, so those same ranges
now exclude the _current_ engine again, and `@modular-vue/compositions` +
`@modular-vue/testing` were never widened past `^0.1.0`.

Accepted as housekeeping, generalized: widen the whole Vue family (including
compositions and testing this time) to include `^0.3.0` in the next release train, and
have the new panels surface peer against the range that includes the engine version that
ships `resolvePanels`. A follow-up worth considering separately: this drift has now
recurred three times (React family in the changelog's peer-range entries, Vue slice 2,
now 0.3.0) — a `check:publish`-style script asserting every workspace peer range admits
every workspace sibling's current version would end the category.
Comment on lines +218 to +230

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -t f 'package.json' packages -x rg -n -C2 \
  '"`@modular-frontend/core`"|"`@modular-vue/`(compositions|testing)"' {}

pnpm view `@modular-frontend/core`@0.3.0 time --json

Repository: kibertoad/modular-react

Length of output: 4490


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pnpm view time =="
pnpm view `@modular-frontend/core`@0.3.0 time --json || true

echo
echo "== npm view time =="
npm view `@modular-frontend/core`@0.3.0 time --json || true

echo
echo "== package manifests for relevant workspaces =="
for f in $(git ls-files 'packages/**/package.json'); do
  if rg -q '"`@modular-frontend/core`"|"\`@modular-vue/`(compositions|testing|vue|core|runtime|nuxt|journeys)"' "$f"; then
    echo "--- $f"
    rg -n -C1 '"`@modular-frontend/core`"|"`@modular-vue/`(compositions|testing|vue|core|runtime|nuxt|journeys)"' "$f"
  fi
done

Repository: kibertoad/modular-react

Length of output: 7241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' /tmp/coderabbit-shell-logs/shell-output-vTGnjG

Repository: kibertoad/modular-react

Length of output: 4490


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pnpm view time =="
pnpm view `@modular-frontend/core`@0.3.0 time --json || true

echo
echo "== npm view time =="
npm view `@modular-frontend/core`@0.3.0 time --json || true

echo
echo "== relevant package manifests =="
for f in $(git ls-files 'packages/**/package.json'); do
  if rg -q '"`@modular-frontend/core`"|"\`@modular-vue/`(compositions|testing|vue|core|runtime|nuxt|journeys)"' "$f"; then
    echo "--- $f"
    rg -n -C1 '"`@modular-frontend/core`"|"`@modular-vue/`(compositions|testing|vue|core|runtime|nuxt|journeys)"' "$f"
  fi
done

Repository: kibertoad/modular-react

Length of output: 7226


🌐 Web query:

npm registry publication time for @modular-frontend/core 0.3.0 July 2026

💡 Result:

There is no publicly available npm package named @modular-frontend/core, nor is there evidence of a 0.3.0 version for such a package being published in July 2026. While the name @modular-frontend/core appears in technical discussions as a router-neutral core library within the context of the kibertoad/modular-react repository, documentation and commit history indicate that these packages are maintained within a monorepo and are not published to the public npm registry [1][2]. Specifically, recent development activity within that repository has focused on adding support for Vue and Angular, and contributors have noted that these modular-frontend packages remain unpublished [2]. Other packages with similar names exist on the npm registry, such as mod-arch-core (published June 3, 2026) [3] and @modjs/core (published August 25, 2024) [4], but these are distinct projects unrelated to the @modular-frontend namespace.

Citations:


Widen the Vue peer ranges to include @modular-frontend/core@0.3.0
@modular-frontend/core@0.3.0 was published on 2026-07-19, but @modular-vue/compositions and @modular-vue/testing still cap at ^0.1.0, and the rest of the Vue family still stops at ^0.2.0, so the current engine is excluded again.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/subject-keyed-zones-triage.md` around lines 218 - 230, Update peer
dependency ranges across the entire `@modular-vue` package family, including
`@modular-vue/compositions` and `@modular-vue/testing`, to admit
`@modular-frontend/core` versions ^0.1.0, ^0.2.0, and ^0.3.0. Ensure the new
panels also peer against a range containing the engine version that provides
resolvePanels.


## Docs plan

- New guide (`docs/subject-panels.md` or similar, name pending the naming decision):
the entry shape, the resolver, both bindings' hosts, the detail-panel recipe (host
shell + `<PanelsOutlet>` + consumer contributing a panel for a custom subject type),
and the reactivity caveat above.
- Extend the compositions README's "pick by problem shape" table from three primitives
to four: route/module zones (pick-one, route-keyed) · **panels (render-all,
subject-keyed, open contribution)** · compositions (pick-one per zone, closed,
state-coordinated) · journeys (one step at a time, flow). This table is where the
naming decision pays for itself — with a third "zones" it would be unreadable.
- Contrast with `remote-capability-manifests.md` pairing (pick-one by id vs render-all
by predicate), as the request suggests — that part of §4F is accepted as-is.

## What cat-factory's §6 acceptance criteria become

| Their criterion | Disposition |
| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@modular-vue/zones` published; `defineZone`/`ZoneOutlet`/`useZone`/`useZoneSubject` importable | **Changed**: import `definePanelGroup`/`resolvePanels` (engine, via `@modular-vue/core`) and `usePanels`/`<PanelsOutlet>`/`usePanelSubject` (`@modular-vue/vue`). No new package. |
| Render-all, ordered, sectioned, no router | **Met**, with sections replaced by per-region group ids. |
| Subject-reactive, incl. mutable run-state predicates | **Met**, with the documented reactive-source boundary (their subject already qualifies). |
| Render-all distinct from `pairById` | **Met**; documented side by side. |
| Consumer-extensible via module registration, no host edit | **Met** — it is the existing module → slot path. |
| Headless `/testing` resolver | **Met** by `resolvePanels` being a pure engine export; no subpath needed. |
| `installModularApp` provides the zone manifest, typed, opt-in | **Obsolete** — the slots context Nuxt already provides is the manifest; nothing to thread. |
| Peer alignment | **Superseded** by the corrected picture above (family-wide widen to `^0.3.0`, incl. compositions/testing). |
| Docs | **Met** per the docs plan. |
| Additive, no breaking changes | **Met** — and only met _because_ the `defineModule({ zones })` seam was rejected; as requested it was breaking. |
Comment on lines +246 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not label future work as “Met.”

This PR documents a proposed design and a future documentation plan, but the table marks implementation-dependent criteria as met. Use “Proposed,” “Planned,” or “Not yet implemented” unless the corresponding engine, Vue, React, and documentation changes are already shipped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/subject-keyed-zones-triage.md` around lines 246 - 259, The
acceptance-criteria table in “What cat-factory's §6 acceptance criteria become”
incorrectly marks unshipped implementation-dependent work as “Met.” Update those
dispositions to use “Proposed,” “Planned,” or “Not yet implemented” unless the
referenced engine, Vue, React, and documentation changes are already shipped,
while preserving “Met” only for verified existing behavior.


Slice 5's reuse claim survives intact: the agent-run result windows become a second
panel group whose subject is the selected step — same handle, resolver, and outlet.

## Decisions for the maintainer before implementation

1. **Name** — "panels" recommended; "zones" rejected with prejudice. (Blocks everything
downstream of it, including the guide filename and the downstream spec's vocabulary.)
2. **React host in the same train** — recommended yes (it is small and keeps the
both-bindings rule intact); shipping engine + Vue first and React one train later is
defensible if the train needs to be lean.
3. **Duplicate-id stance in `resolvePanels`** — throw (recommended, matches
`resolveComponentRegistry`) vs dev-warn; an `onDuplicate` escape hatch can mirror the
pairing API if a deployment needs intentional shadowing.
Loading