Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7365981
test(web-static-serve): pin trailing-space fold on the /v1 refusal (I…
auto-wood Oct 24, 2026
4941afa
fix(nexus-service): reword --no-open comment so the entry carries no …
auto-wood Oct 24, 2026
2626020
feat(web): make /modules the sole owner of the modules surface
auto-wood Oct 24, 2026
abaccf2
Merge commit '4941afa5c' into iteration/v1.221-developer-rails
auto-wood Oct 24, 2026
6923546
refactor(web): move /modules body into the developer generation with …
auto-wood Oct 24, 2026
f2339aa
feat(studio): fixture the modules rail chrome and register its extrac…
auto-wood Oct 24, 2026
4990fb0
fix(web): drop unused RunStudioCopy formCopy/runsTableCopy props (Q3-01)
auto-wood Oct 24, 2026
885d8d5
Merge commit '4990fb0801e549e29924c3adaf66de9d7f68a451' into iteratio…
auto-wood Oct 24, 2026
5cf6b38
feat(studio): split the capabilities page chrome and fixture it
auto-wood Oct 24, 2026
cca01af
feat(studio): cardify the sessions rail and fixture it
auto-wood Oct 24, 2026
8bde104
fix(web): scope capability filter label ids per instance
auto-wood Oct 24, 2026
40703cc
Merge commit '8bde104451f1a8d7b4b387788e9cad210f346b0a' into iteratio…
auto-wood Oct 24, 2026
1959c45
chore(iteration): close v1.221-developer-rails — compound round
auto-wood Oct 24, 2026
8dcabfc
fix(web): guard scrollIntoView so jsdom test runs do not throw
auto-wood Oct 24, 2026
37af4c7
Merge commit '8dcabfc22' into iteration/v1.221-developer-rails
auto-wood Oct 24, 2026
d3bb7ad
refactor(developer): move HubCard inside the presentational boundary
auto-wood Oct 24, 2026
ba404a5
fix(developer): per-instance Run Studio ids + real theme-marker asser…
auto-wood Oct 24, 2026
1b66cec
docs(developer): fix review nits — zh label, stale comment, surfaces …
auto-wood Oct 24, 2026
a089dcb
docs(knowledge): record I-000085 legacy deep-link disposition sign-off
auto-wood Oct 24, 2026
1aaf7af
Merge branch 'fix/pr445-review-r2' into iteration/v1.221-developer-rails
auto-wood Oct 24, 2026
53950c6
fix(developer): scope Run form field ids per mount (I-000086 occ 97)
auto-wood Oct 24, 2026
f55af51
Merge branch 'fix/pr445-review-r3' into iteration/v1.221-developer-rails
auto-wood Oct 24, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Developer rail app rebuild pattern

Promoted to: `.mstar/knowledge/architecture-patterns/developer-rail-app-rebuild.md` (this file). Derived from iteration v1.221-developer-rails (plans v221-modules-rail, v221-caps-sessions-rail); precedents: v1.220 developer shell (Hub/Harness), v1.219 shell skeleton.

## When to use

Rebuilding an `apps/web` rail app to the post-2026-10-22 generation standard (single SPA, app-shell IA per `.mstar/specs/proposed/surfaces/web-rebuild-ia.md`, web-first). Verified three times in v1.221 (modules, capabilities, sessions) after v1.220 established it (Hub, Harness).

## The recipe (per rail app)

1. **Routing/ownership first.** A rail app must have exactly one entry point. If a legacy surface is Settings-modal-owned or a compatibility redirect, delete the modal machinery outright (section descriptor, canonicalization special case, entrance-registry row, modal-primary "last safe location" special case) — no redirect; the product has never shipped. Verify with a repo-wide grep for the dead path (tracked sources only; `dist/` is gitignored).
2. **Move the body into `components/developer/`**, split at the **pure-view boundary**: presentational files import only `react`, `lucide-react`, `@42ch/nexus-ui`, `@/components/ui/*`, `@/lib/utils` (plus `./hub-card`, a sibling inside the presentational root, when composing cards). No hooks, router, i18n, transport, or query imports in chrome; copy arrives via props, data via view models, behavior via callbacks. App wiring (queries, search-param seed/write-back, copy assembly) stays in the page component.
3. **Registry wiring**: repoint the `AppDef.load` dynamic import in `lib/app-registry.ts`; AppId/route/icon unchanged. Entrance rules (`entrance-registry.ts`) keep the route row; delete modal-era rows.
4. **Design Studio fixture** (repo convention: every new visual surface, both themes, all variants). Wire through the established five points — reuse, never rebuild: `@web-developer` alias in design-studio `tsconfig.json` + `vite.config.ts` **and `vitest.config.ts`** (standalone config; forgetting it breaks fixture-test resolution), `EXTRACT_IMPORT_ROOTS` in `surface-source-badge.tsx`, `SURFACES_SHELL_ENTRIES` in `gallery-index.ts`, a sibling section in `surfaces.tsx` (`SurfacesShellPage`). No new Studio route.
5. **Locale parity**: the `locale-parity.test.ts` already registers all rail namespaces — verify, never modify. Chrome copy is assembled in the page and passed down; fixture copy is literal.
6. **Fixture tests must pin instance identity**: a chrome mounted N times in a gallery needs per-instance ids — derive ids with React `useId()` inside the chrome (self-consistent `htmlFor`/`id`), and assert pairwise-distinct ids with a negative check (reverting to the hardcoded id must turn the test red). Lesson: PR #444 Greptile round (validate-preset) and v1.221 QC (caps filter) both caught hardcoded ids.

## Traps learned

- Deep links are contracts: preserve every param form verbatim (`?module=&run=`, `?world=&branch=`, `?filter=`) with seed-on-mount/read-only semantics; canvas/timeline entries navigate with those params.
- Old `pages/<app>-page.tsx` bodies sometimes are `<Navigate>` redirects — replacing them means the route finally renders a body; consumers calling `openSettings('<app>')` are forced cascades.
- Status display reuses the v1.220 `statusVariant` mapping as-is (`running`/`completed` green, `failed` red); never invent a variant per app.
- List ordering contracts survive visual rebuilds: keep the documented sort (e.g. sessions F-F1) and assert DOM order equality against a deliberately non-alphabetical fixture list.
- Killed table layouts leave orphaned locale keys (`columns.*`) — sweep both locales.

## Legacy deep-link disposition (v1.221 close-out sign-off)

I-000085 / PR #445 SF-1: legacy `/settings/modules` deep links (in-app navigation shipped in v1.147, e.g. the canvas Run Module entry pinned `?world=…`) now fall back to the default settings section (`agent`), preserving the query string. **No alias, no redirect** — product decision 2026-10-24 (D4). Rationale: the route was only ever reachable from in-app navigation in pre-release builds; the product has never shipped to end users, so no external bookmark or tab outlives the change. Explicit sign-off: PM, 2026-10-24. If the product ever ships with `/settings/modules` reachable, revisit with a real alias before the release — this disposition expires with the "never shipped" premise.
8 changes: 1 addition & 7 deletions apps/design-studio/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1131,7 +1131,7 @@ describe('Surfaces page — Settings shell chrome fixtures', () => {
).not.toBeInTheDocument();
});

it('renders section nav with Agent, Workspace, Appearance, Modules, Advanced', async () => {
it('renders section nav with Agent, Workspace, Appearance, Advanced', async () => {
const hostRoot = screen.getByTestId('settings-host-fixtures');
const sectionNav = within(hostRoot).getByTestId('settings-section-nav');
expect(
Expand All @@ -1143,9 +1143,6 @@ describe('Surfaces page — Settings shell chrome fixtures', () => {
expect(
within(sectionNav).getByTestId('settings-section-nav-appearance'),
).toHaveTextContent('Appearance');
expect(
within(sectionNav).getByTestId('settings-section-nav-modules'),
).toHaveTextContent('Modules');
expect(
within(sectionNav).getByTestId('settings-section-nav-advanced'),
).toHaveTextContent('Advanced');
Expand Down Expand Up @@ -1231,9 +1228,6 @@ describe('Surfaces page — Settings shell chrome fixtures', () => {
expect(
within(framesRoot).getByTestId('settings-section-frame-appearance'),
).toBeInTheDocument();
expect(
within(framesRoot).getByTestId('settings-section-frame-modules'),
).toBeInTheDocument();
});

it('renders Agent section fixture with locked helper and preselected Codex (instant-apply)', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ describe('classifySurfaceImport', () => {
expect(classifySurfaceImport('@web-shell/selection-submenu')).toBe(
'extract',
);
expect(
classifySurfaceImport('@web-developer/modules-page-chrome'),
).toBe('extract');
expect(classifySurfaceImport('@web-developer/run-studio-chrome')).toBe(
'extract',
);
});

it('rejects lookalike package prefixes at root/subpath boundaries', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/design-studio/src/components/surface-source-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const EXTRACT_IMPORT_ROOTS: readonly string[] = [
'@web-settings',
'@web-global-timeline',
'@web-shell',
'@web-developer',
];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { CapabilitiesRailFixtures } from '@/fixtures/capabilities-rail-fixtures';
import { ThemeProvider } from '@/components/theme-provider';

/**
* The `/capabilities` chrome has four content states worth eyeballing: the
* registry with a filter that hits, a filter that misses (its own copy plus a
* Clear filter action), an empty registry (which wins over the miss), and the
* read error. Each frame owns one `data-testid`; the assertions below pin the
* state that makes the frame different from its siblings.
*/
const VARIANTS = [
'capabilities-rail-fixture-empty',
'capabilities-rail-fixture-hit',
'capabilities-rail-fixture-miss',
'capabilities-rail-fixture-error',
] as const;

afterEach(() => {
cleanup();
document.documentElement.classList.remove('dark');
});

describe('CapabilitiesRailFixtures', () => {
it('mounts every variant frame', () => {
render(<CapabilitiesRailFixtures />);

expect(screen.getByTestId('capabilities-rail-fixtures')).toBeInTheDocument();
for (const testId of VARIANTS) {
expect(screen.getByTestId(testId)).toBeInTheDocument();
}
});

it('gives every mounted body its own label/input id pair', () => {
render(<CapabilitiesRailFixtures />);

const ids = VARIANTS.map((testId) => {
const frame = screen.getByTestId(testId);
// getByLabelText resolves through htmlFor → id: each label reaches the
// filter rendered beside it, not the first frame in the document.
return within(frame).getByLabelText('Filter capabilities').id;
});

expect(new Set(ids).size).toBe(VARIANTS.length);
for (const id of ids) {
expect(id.length).toBeGreaterThan(0);
expect(document.querySelectorAll(`[id="${id}"]`)).toHaveLength(1);
}
});

it('renders the registry-empty copy even though a filter is typed', () => {
render(<CapabilitiesRailFixtures />);
const frame = screen.getByTestId('capabilities-rail-fixture-empty');

expect(within(frame).getByText('No capabilities')).toBeInTheDocument();
expect(
within(frame).getByText('Capabilities will appear here once the runtime registers them.'),
).toBeInTheDocument();
expect(within(frame).queryByRole('button', { name: 'Clear filter' })).not.toBeInTheDocument();
});

it('narrows the list to the matching capability on a filtered hit', () => {
render(<CapabilitiesRailFixtures />);
const frame = screen.getByTestId('capabilities-rail-fixture-hit');

expect(within(frame).getByText('nexus.world.describe')).toBeInTheDocument();
expect(within(frame).getByText('nexus.world.edit')).toBeInTheDocument();
expect(within(frame).queryByText('sync.pull')).not.toBeInTheDocument();
expect(within(frame).getByDisplayValue('world')).toBeInTheDocument();
});

it('renders the filtered-miss copy quoting the query, and Clear filter restores the list', () => {
render(<CapabilitiesRailFixtures />);
const frame = screen.getByTestId('capabilities-rail-fixture-miss');

expect(
within(frame).getByText('No capability matches "no.such.cap"'),
).toBeInTheDocument();
expect(
within(frame).getByText('Clear the filter to see all capabilities.'),
).toBeInTheDocument();

fireEvent.click(within(frame).getByRole('button', { name: 'Clear filter' }));

expect(within(frame).getByText('nexus.world.describe')).toBeInTheDocument();
expect(within(frame).getByText('sync.pull')).toBeInTheDocument();
expect(within(frame).getByDisplayValue('')).toBeInTheDocument();
expect(within(frame).queryByText(/No capability matches/)).not.toBeInTheDocument();
});

it('renders the read error as an alert with retry', () => {
render(<CapabilitiesRailFixtures />);
const frame = screen.getByTestId('capabilities-rail-fixture-error');

expect(within(frame).getByText('Could not load capabilities.')).toBeInTheDocument();
expect(within(frame).getByRole('alert')).toBeInTheDocument();
expect(within(frame).queryByText('No capabilities')).not.toBeInTheDocument();
});

it('renders every variant under both themes', () => {
for (const forced of ['light', 'dark'] as const) {
render(
<ThemeProvider forcedTheme={forced}>
<CapabilitiesRailFixtures />
</ThemeProvider>,
);

// The Studio theme system is the authority for the marker (`<html>.dark`);
// asserting it proves the fixture renders under the resolved theme, not
// just that a fixture-owned class survived.
expect(document.documentElement.classList.contains('dark')).toBe(forced === 'dark');
for (const testId of VARIANTS) {
expect(screen.getByTestId(testId)).toBeInTheDocument();
}

cleanup();
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { ModulesRailFixtures } from '@/fixtures/modules-rail-fixtures';
import { ThemeProvider } from '@/components/theme-provider';

/**
* The `/modules` rail has five states worth eyeballing: the ready list with a
* selection, the manifest detail beside the no-selection prompt, and the three
* Run Studio reads (pending / success / deep-linked detail error). Each frame
* owns one `data-testid`; the assertions below pin the state that makes the
* frame different from its siblings.
*/
const VARIANTS = [
'modules-rail-fixture-list',
'modules-rail-fixture-detail',
'modules-rail-fixture-run-pending',
'modules-rail-fixture-run-success',
'modules-rail-fixture-run-error',
] as const;

afterEach(() => {
cleanup();
document.documentElement.classList.remove('dark');
});

describe('ModulesRailFixtures', () => {
it('mounts every variant frame', () => {
render(<ModulesRailFixtures />);

expect(screen.getByTestId('modules-rail-fixtures')).toBeInTheDocument();
for (const testId of VARIANTS) {
expect(screen.getByTestId(testId)).toBeInTheDocument();
}
});

it('gives each mounted Run Studio its own control ids', () => {
render(<ModulesRailFixtures />);
const runFrames = [
'modules-rail-fixture-run-pending',
'modules-rail-fixture-run-success',
'modules-rail-fixture-run-error',
] as const;

// getByLabelText resolves through htmlFor → id: each label reaches the
// control rendered beside it, not the first Run Studio in the document.
// The schema-driven Run form derives its field ids as
// `${idPrefix}-${name}`; a literal prefix re-duplicated
// run-form-attacker_id across the three frames (I-000086 occ 97).
for (const label of [
'World',
'Filter by status',
'Filter by World',
'Attacker',
'Defender',
]) {
const ids = runFrames.map((testId) => {
const frame = screen.getByTestId(testId);
return within(frame).getByLabelText(label).id;
});

expect(new Set(ids).size).toBe(runFrames.length);
for (const id of ids) {
expect(id.length).toBeGreaterThan(0);
expect(document.querySelectorAll(`[id="${id}"]`)).toHaveLength(1);
}
}
});

it('renders the ready list with one selected module', () => {
render(<ModulesRailFixtures />);
const frame = screen.getByTestId('modules-rail-fixture-list');

expect(within(frame).getByTestId('modules-page-body')).toBeInTheDocument();
expect(within(frame).getByRole('button', { name: 'Basic Combat', pressed: true })).toBeInTheDocument();
expect(
within(frame).getByRole('button', { name: 'Scouting Party', pressed: false }),
).toBeInTheDocument();
});

it('renders manifest fields beside the select prompt', () => {
render(<ModulesRailFixtures />);
const frame = screen.getByTestId('modules-rail-fixture-detail');

expect(within(frame).getByText('Select a module')).toBeInTheDocument();
expect(within(frame).getByRole('heading', { name: 'Basic Combat' })).toBeInTheDocument();
expect(within(frame).getByText('ABI version')).toBeInTheDocument();
expect(within(frame).getByText('3')).toBeInTheDocument();
expect(within(frame).getByText('Host functions')).toBeInTheDocument();
expect(within(frame).getByText('Schemas')).toBeInTheDocument();
});

it('renders the pending Run state', () => {
render(<ModulesRailFixtures />);
const frame = screen.getByTestId('modules-rail-fixture-run-pending');

expect(within(frame).getByTestId('run-studio')).toBeInTheDocument();
expect(within(frame).getByRole('button', { name: 'Running…' })).toBeDisabled();
expect(within(frame).getByText('This Run is still executing.')).toBeInTheDocument();
});

it('renders the proposal inspector for a Run awaiting review', () => {
render(<ModulesRailFixtures />);
const frame = screen.getByTestId('modules-rail-fixture-run-success');

expect(within(frame).getByTestId('run-inspector')).toBeInTheDocument();
expect(within(frame).getByText('Aria strikes Brann')).toBeInTheDocument();
expect(within(frame).getByTestId('run-inspector-accept')).toBeEnabled();
expect(within(frame).getByTestId('run-inspector-discard')).toBeEnabled();
});

it('renders the deep-linked Run detail read error', () => {
render(<ModulesRailFixtures />);
const frame = screen.getByTestId('modules-rail-fixture-run-error');

expect(within(frame).getByText('Could not load this Run')).toBeInTheDocument();
expect(within(frame).queryByTestId('run-inspector')).not.toBeInTheDocument();
});

it('renders every variant under both themes', () => {
for (const forced of ['light', 'dark'] as const) {
render(
<ThemeProvider forcedTheme={forced}>
<ModulesRailFixtures />
</ThemeProvider>,
);

// The Studio theme system is the authority for the marker (`<html>.dark`);
// asserting it proves the fixture renders under the resolved theme, not
// just that a fixture-owned class survived.
expect(document.documentElement.classList.contains('dark')).toBe(forced === 'dark');
for (const testId of VARIANTS) {
expect(screen.getByTestId(testId)).toBeInTheDocument();
}

cleanup();
}
});
});
Loading
Loading