Skip to content
Merged
Show file tree
Hide file tree
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
87 changes: 87 additions & 0 deletions docs/design/FRONTEND_LAYER_POLICY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Frontend Layer Policy

Captured: 2026-07-16

## NodeRoom Standard

```text
Convex Auth
identity and production session enforcement

shadcn + Radix
accessible interaction primitives

AI Elements
agent interaction primitives

CSS
ordinary state transitions

Reviewed visual recipes
bounded public-page and empty-state treatments

Optional centralized motion engine
only meaningful multi-step choreography

Convex + NodeAgent
authoritative state, tools, jobs, policy, and proof
```

Radix owns control behavior. Product code composes those controls through
shadcn-generated wrappers. Visual and motion layers may change appearance and
timing but may not reimplement focus, keyboard, dismissal, selection, or ARIA
behavior.

## Adaptations From The Proposal

- NodeRoom uses Convex Auth today. WorkOS is not installed and is not an
assumed dependency. A future identity-provider change must preserve the same
fail-closed production boundary.
- The `motion` package is installed and currently owned by the AI Elements
thinking-state shimmer. Keep CSS as the default elsewhere and do not add GSAP
until a real sequence requires a timeline.
- React Bits, GSAP, Lenis, Vanta, and Three.js are not installed for this work.
They remain approved categories with explicit boundaries, not default
dependencies.
- The authenticated NodeRoom workspace, spreadsheet grid, notebook editor,
graph inspectors, chat, and trace surfaces retain native scrolling. Lenis is
limited to future public storytelling routes.
- Vanta is not approved for the application shell. Any future experiment must
be lazy, isolated, reduced-motion aware, pointer inert, and backed by a static
fallback.

## Import Ownership

| Dependency | Allowed owner |
|---|---|
| `radix-ui`, `@radix-ui/*` | `src/components/ui/**`; recorded AI Elements controllable-state exceptions only |
| AI Elements | `src/components/ai-elements/**`, consumed by product compositions |
| React Bits recipes | `src/components/effects/react-bits/**` |
| `motion` / future GSAP | `src/motion/**`; AI Elements and reviewed React Bits source may use `motion` |
| Lenis | route-scoped provider under `src/motion/**` |
| Vanta and Three.js | lazy wrapper under `src/components/backgrounds/vanta/**` |

Vendor UI and motion files may not import Convex, NodeAgent, or the Room store.
They receive consequential state and callbacks through props.

## Motion Ladder

1. No animation for deterministic capture or when motion adds no meaning.
2. CSS for hover, focus, open/close, opacity, and simple transforms.
3. A reviewed visual recipe for one bounded decorative treatment.
4. A centralized timeline engine for causal multi-element sequences.
5. Route-scoped smooth scrolling for public editorial pages only.
6. An isolated WebGL background only after performance and accessibility proof.

Do not stack multiple owners on the same transform or scroll behavior.

## Enforcement

- `npm run ui:primitive-audit` checks Radix ownership.
- `npm run ui:motion-audit` checks motion/effect ownership.
- `npm run ui:layer-audit` checks both plus the business-logic boundary.
- `npm run design:audit` includes the complete layer audit.

The raw-control migration remains tracked in
`docs/design/RADIX_PRIMITIVE_AUDIT.md`; the import gate does not pretend that
the region-by-region migration is already complete.
86 changes: 86 additions & 0 deletions docs/design/RADIX_PRIMITIVE_AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Radix Primitive Audit

Captured: 2026-07-16

Layering and motion ownership are defined in
`docs/design/FRONTEND_LAYER_POLICY.md`. Radix remains the behavioral base;
visual recipes and motion systems may decorate a control but may not replace
its accessibility mechanics.

Scope: interactive controls under `src/ui`, `src/app`, and shared component
layers. The rule is behavioral equivalence first: keep data flow, callbacks,
keyboard contracts, proof selectors, and visual tokens while moving generic
overlay/focus/selection mechanics to shadcn-generated Radix primitives.

## Migrated

| Surface | Handwritten behavior removed | Shared primitive | Files |
|---|---|---|---|
| Chat model route picker | Absolute positioning, outside-click listener, Escape listener, custom listbox navigation, ancestor-clipped scrolling | `Popover`, `Command`, `CommandInput`, `CommandList`, `CommandItem`, `Button` | `src/ui/Chat.tsx`, `src/components/ui/popover.tsx`, `src/app/styles.css` |
| Chat work-context picker | Absolute positioning, custom search field/listbox, manual dismissal | `Popover`, `Command`, `CommandInput`, `CommandList`, `CommandItem` | `src/ui/Chat.tsx`, `src/app/styles.css` |
| Chat `@` typeahead | Absolute positioning and handwritten option elements | controlled `Popover` + `Command` anchored to the composer | `src/ui/Chat.tsx`, `src/app/styles.css` |
| Notifications and passive intelligence | Outside-click and Escape listeners, clipped scrolling, duplicated dialog semantics | `Popover` + `ScrollArea` | `src/ui/NotificationsInbox.tsx`, `src/ui/insights/PassiveAgentChip.tsx`, `src/ui/insights/NoteworthyInbox.tsx` |
| People panel | Handwritten dismissal and viewport scrolling | non-modal `Dialog` + `ScrollArea` | `src/ui/PeoplePanel.tsx`, `src/ui/people-panel.css` |
| Landing create/join dialogs | Scrim dismissal, focus trap, Escape handling, focus restoration | shared `FocusTrapDialog` backed by shadcn `Dialog` | `src/ui/Landing.tsx`, `src/ui/primitives/FocusTrapDialog.tsx` |
| Command palette | Backdrop, focus trap, listbox keyboard loop, selected-row scrolling | `CommandDialog`, `CommandInput`, `CommandList`, `CommandItem` | `src/ui/CommandPalette.tsx`, `src/components/ui/command.tsx` |
| Shared modal layer | Handwritten tab loop and focus restoration | shadcn `Dialog`; the design-system `Modal` adapts through `FocusTrapDialog` | `src/ui/primitives/FocusTrapDialog.tsx`, `src/ui/primitives/designSystem.tsx`, `src/components/ui/dialog.tsx` |
| Mobile overflow and action menus | Absolute menus, manual open state, outside/Escape dismissal | `DropdownMenu` | `src/ui/mobile/shell/MobileHeader.tsx`, `src/ui/mobile/MobileApp.tsx` |
| Mobile bottom sheets and nested readers | Scrims, ARIA mutation, tab loop, Escape handling, focus restoration | container-scoped shadcn `Dialog` adapter | `src/ui/mobile/MobileApp.tsx`, `src/components/ui/dialog.tsx`, `src/ui/mobile/mobile.css` |
| Mobile tooltip | Handwritten hover/focus bubble placement | `Tooltip` with bounded long-press adapter | `src/ui/mobile/MobileTooltip.tsx`, `src/ui/mobile/mobileFrame.css` |
| Cell history | Outside/Escape dismissal and clipped scrolling | `Popover` + `ScrollArea` | `src/ui/panels/Artifact.tsx`, `src/ui/panels/artifact-receipts.css` |
| Notebook patch overlay | Manual viewport coordinates and fixed positioning | collision-aware `Popover` | `src/ui/panels/Artifact.tsx`, `src/ui/panels/notebook-paper.css` |
| Evidence citation preview | Manual viewport placement and hover/focus dismissal | `HoverCard` | `src/ui/panels/Artifact.tsx`, `src/app/styles.css` |

The picker content now renders in a Radix portal, uses collision-aware top/end
placement, and sizes from `--radix-popover-content-available-width` and
`--radix-popover-content-available-height`. Existing model-routing callbacks and
legacy proof controls remain unchanged.

The context surfaces use the same portal and collision contract. Context search
still covers artifacts, deck slides, proposals, and traces; the `@` path retains
its leading `@nodeagent` and artifact-reference behavior. The former slash-menu
component was unreachable because its option list was permanently empty, so its
handwritten overlay state was retired. `/ask` and `/free` remain parser-level
compatibility aliases and are intentionally not advertised as a second command UI.

## Replace Next

No P1 or P2 generic primitive migrations remain in this audit. New generic
dialogs, menus, listboxes, popovers, hover previews, tooltips, and scrollable
overlay regions must start from the shared shadcn-generated Radix layer.

The listeners that remain in audited owners are product behavior rather than
overlay plumbing: global command/watch shortcuts, camera-follow cancellation,
spreadsheet drag and scroll measurement, and notebook editor measurement.

## Keep Specialized

These are not generic primitive replacements:

- `GuidedTour` uses measured anchored coaching geometry and is non-modal.
- `TraceLensPanel` is a persistent application region, not a dialog despite its
current ARIA role; fix semantics with the trace-region migration.
- Spreadsheet cells, graph nodes, notebook blocks, and deck objects retain
their domain interaction engines. Radix may wrap menus and dialogs around
them but must not replace editing, drag, selection, or realtime logic.
- Compatibility-only hidden model controls stay until dependent proof scripts
migrate to visible semantic selectors.

## Completion Gate

For each migration: focused behavior tests, Escape/focus restoration, keyboard
selection, outside interaction, narrow desktop and phone screenshots, zero
horizontal overflow, and unchanged store/Convex callbacks.

## Verification

Verified 2026-07-16:

- Focused primitive suite: 122 tests passed across 10 files.
- Repository floor: TypeScript app + Convex projects passed; 2,216 tests passed
across 328 files.
- `design:audit`: passed. Its existing token-drift inventory remains advisory.
- `ui:layer-audit`: passed across 577 source files.
- Live local browser: 390x844 mobile sheet and 900x800 command palette both had
zero horizontal overflow, correct focus containment/restoration, working
Escape dismissal, and zero console errors.
74 changes: 74 additions & 0 deletions docs/design/ui-contract/20260717-radix-primitives/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Radix primitive migration — before/after live capture

Captured: 2026-07-17

| | ref |
| --- | --- |
| **before** | `origin/main` @ `786eef64` |
| **after** | `feat/radix-primitives` @ `db34032c` (= `origin/main` + the migration) |

Both trees were built with `vite build` and served with `vite preview`
(`before` :5291, `after` :5292), then driven with Playwright against the built
preview per `docs/qa/BROWSER_VERIFY.md`. Browser-pane screenshots were NOT used —
preview tabs run with `document.visibilityState === "hidden"`, which pauses capture.

## Screenshots

| surface | before | after |
| --- | --- | --- |
| landing, 1280x860 | `before-01-landing-desktop.png` | `after-01-landing-desktop.png` |
| demo room, 1280x860 | `before-02-room-desktop.png` | `after-02-room-desktop.png` |
| command palette | `before-03-command-palette.png` | `after-03-command-palette.png` |
| landing, 390x844 | `before-04-mobile-390x844.png` | `after-04-mobile-390x844.png` |
| demo room, 390x844 | `before-05-mobile-room.png` | `after-05-mobile-room.png` |

`before-02` and `after-02` are byte-identical in size (167,469) — the room surface
is visually unchanged.

## What actually differs

A primitive migration is behavioural, not visual, so near-identical images are the
intended outcome. The only visible delta is in the command palette:

- Row height is slightly tighter (~36.6px vs ~39.4px) from shadcn's `CommandItem`
padding, so one additional row ("Open Today's Brief") fits inside the same
height-capped list. Same commands on both sides — `Today's Brief` is a demo-room
fixture present in both trees, not new content.

## Behavioural probe (Playwright, both trees)

| check | before | after |
| --- | --- | --- |
| palette opens on Ctrl+K | yes | yes |
| focus starts inside dialog | yes | yes |
| focus stays inside after 12 Tabs | yes | yes |
| Escape dismisses | yes | yes |
| focus restored to trigger | yes | yes |
| outside click dismisses | yes | yes |
| horizontal overflow @1280x860 | 0px | 0px |
| horizontal overflow @390x844 | 0px | 0px |
| console errors | none | none |

**The palette was already correct before this migration.** Focus trapping, Escape
handling, outside dismissal and focus restoration all worked on `origin/main`. This
change does not fix them; it moves them onto a shared primitive so every surface
inherits one implementation instead of re-deriving it.

## Structural delta

- **before**: dialog renders inline inside `#root`; `aria-modal="true"`; custom
`.r-cmdk-backdrop`.
- **after**: dialog is portaled to `document.body` (`#radix-_r_2_`) with Radix focus
guards; `data-state="open"`; no `aria-modal`.

## Open a11y question (NOT fixed by this change)

Neither implementation marks `#root` `aria-hidden` while the modal is open, so
background content stays in the accessibility tree on both sides. `before` carried
`aria-modal="true"`; `after` does not (Radix drops it deliberately in favour of
`hideOthers`, but `hideOthers` is not marking `#root` here).

The aria snapshot shrinks 185 -> 49 lines on `after` vs 185 -> 202 on `before`, but
that is most plausibly Radix's scroll-lock unmounting virtualized binder rows — it is
**not** evidence of aria-hiding and is not claimed as an a11y win. Worth one focused
follow-up.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion e2e/full-modern-ux-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,8 @@ test.describe("full modern UX release bar", () => {

await page.getByRole("button", { name: "Quick actions" }).click();
await expect(page.locator(".na-fab-fan")).toBeVisible();
await page.getByRole("button", { name: "Ask NodeAgent" }).click();
// Fan actions are Radix DropdownMenuItems (role=menuitem), not buttons.
await page.getByRole("menuitem", { name: "Ask NodeAgent" }).click();
await expect(page.locator(".na-ask-wrap")).toHaveAttribute("data-open", "true");

await attachAndAssertHealth(page, testInfo, "mobile-terracotta", health, { assertPerf: false });
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,10 @@
"qa:ui:virality": "tsx scripts/design-quality.ts virality",
"qa:ui:scorecard": "tsx scripts/design-quality.ts scorecard",
"design:manifest": "tsx scripts/design-system.ts manifest --json",
"design:audit": "tsx scripts/design-system.ts audit",
"design:audit": "tsx scripts/design-system.ts audit && npm run ui:layer-audit",
"ui:layer-audit": "tsx scripts/ui-layer-audit.ts",
"ui:primitive-audit": "tsx scripts/ui-layer-audit.ts --scope=primitives",
"ui:motion-audit": "tsx scripts/ui-layer-audit.ts --scope=motion",
"design:parity": "tsx scripts/design-parity.ts",
"workflow:trace-previews": "tsx scripts/render-workflow-preview.ts",
"workflow:app-previews": "playwright test capture-previews",
Expand Down
38 changes: 38 additions & 0 deletions scripts/ui-layer-audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readdirSync, readFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import { auditUiLayerImports, type UiLayerAuditScope } from "../src/design/uiLayerPolicy";

const root = resolve(import.meta.dirname, "..");
const scope = parseScope(process.argv.slice(2));
const files = readSourceFiles(resolve(root, "src"));
const result = auditUiLayerImports(files, scope);
const json = process.argv.includes("--json");

if (json) {
console.log(JSON.stringify({ scope, ...result }, null, 2));
} else if (result.ok) {
console.log(`ui-layer audit: pass (${scope}, ${result.checkedFiles} source files)`);
} else {
console.error(`ui-layer audit: fail (${result.findings.length} finding(s))`);
for (const finding of result.findings) {
console.error(`- ${finding.file}: ${finding.packageName} - ${finding.reason}`);
}
}

if (!result.ok) process.exitCode = 1;

function readSourceFiles(directory: string): Record<string, string> {
const output: Record<string, string> = {};
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) Object.assign(output, readSourceFiles(path));
else if (/\.(?:ts|tsx|js|jsx|mjs)$/.test(entry.name)) output[relative(root, path).replaceAll("\\", "/")] = readFileSync(path, "utf8");
}
return output;
}

function parseScope(args: string[]): UiLayerAuditScope {
const value = args.find((arg) => arg.startsWith("--scope="))?.split("=")[1];
if (value === "primitives" || value === "motion") return value;
return "all";
}
Loading
Loading