Review Mode
Turn a running React app into its own design review workbench. Mount it once behind a build flag; production never ships it.
One mount · no second screen list · nothing in production
Review the real app, not a copy of it
Review Mode reads the app’s own route tree, so the screen and navigation maps never drift from what is built. Reviewers force states, compare proposed designs, pin notes and run a self-check on the screens people will use.
- A manifest
- One lazy mount
- Design routes
- Forced state at the data seam
01 · What it adds
Everything a review needs, on the app itself
Review bar
Areas, structure pages, Built and Proposed variants, screen picker, forced states, roles, width presets, light and dark.
Forced states
Loaded, empty, loading, error and no access, driven from the address so every reviewer sees the same thing.
Pinned notes
Notes pinned to elements on the screen, with a backlog page that tracks open, doing, done and won’t do.
Self-check
Structure, overflow, contrast, breakpoints, tokens, placeholders and console errors, for one page or every page.
Design pages
Screen map and navigation map derived from the route tree, plus design system, design ideas and brainstorm.
Production-safe
Mounted behind a build flag. Screens import only inert hooks, so production builds carry none of it.
02 · Try it
Live demo
The demo is a small sample app with the real bar mounted over it. Switch Orders to Empty or Error, flip it to Proposed, press N to see the pinned note, try the 390 width preset and open the screen map from the Structure row.
03 · Install
Add the package
pnpm add -D @snyder-tech/brandos-review-modePeer dependencies are React 19 and React DOM 19. TanStack Router is needed only for the bundled adapter, TanStack Query only forwithReviewQueryClient. Inside the snyder-apps workspace, depend on @brandos/review-mode with workspace:*.
04 · Set it up
Five steps
1. A build-time flag
// vite.config.ts: 'on' for the dev server, refused in production builds
define: {
'import.meta.env.VITE_REVIEW_MODE': JSON.stringify(reviewModeFor(command, mode)),
},2. A manifest for what the route tree cannot express
// src/review/review.ts
import { defineReviewApp, type Note } from '@snyder-tech/brandos-review-mode/core';
export const reviewApp = defineReviewApp({
name: 'YourApp',
entries: [{ id: 'app', label: 'App', key: '1', first: '/' }],
modules: [{ id: 'orders', label: 'Orders', sub: 'what customers bought' }],
notes: import.meta.glob<{ default: readonly Note[] }>('../routes/**/*.notes.ts', { eager: true }),
});3. One lazy mount at the root
// src/routes/__root.tsx
const ReviewModeMount =
import.meta.env.VITE_REVIEW_MODE === 'on' ? lazy(() => import('../review/ReviewModeMount')) : null;
// src/review/ReviewModeMount.tsx
import { ReviewMode } from '@snyder-tech/brandos-review-mode/mount';
import { tanstackRouterAdapter } from '@snyder-tech/brandos-review-mode/tanstack-router';
export default function ReviewModeMount() {
return <ReviewMode app={reviewApp} router={tanstackRouterAdapter(useRouter())} />;
}4. Forced states at the data seam
import { useReviewState } from '@snyder-tech/brandos-review-mode/hooks';
const forced = useReviewState(); // 'loaded' outside review mode
const query = useQuery({ ...ordersQuery, enabled: forced === 'loaded' });5. The design pages
import { ScreenMap, NavigationMap, NotesBacklog } from '@snyder-tech/brandos-review-mode/pages';
// Mount them on your own /design/$view route, lazily and behind the same flag.05 · Entry points
Import only what each place needs
| Import | Contents | In production |
|---|---|---|
./core | defineReviewApp, reviewModeEnabled, activateReviewMode, the RouterAdapter contract, route catalogs and types | Types only |
./hooks | useReviewState, useVariant, useReviewRole, ReviewLink. No stylesheet. | Yes, inert |
./scenario | ReviewScenario stand-ins for loading, empty, error and no access | No |
./mount | ReviewMode: the bar, notes layer, check panel and width stage | No |
./pages | ScreenMap, NavigationMap, NotesBacklog, DesignSystem, DesignIdeas, Brainstorm and planning pages | No |
./tanstack-router | tanstackRouterAdapter(router) | No |
./tanstack-query | withReviewQueryClient(queryClient) | No |
06 · Routes
Screens come from the route tree
A route improves its entry through static data. Without it, the module is the first path segment and the label is the last one, title-cased. Routes under /design are never screens.
import type { ReviewRouteStaticData } from '@snyder-tech/brandos-review-mode/core';
declare module '@tanstack/react-router' {
interface StaticDataRouteOption extends ReviewRouteStaticData {}
}
createFileRoute('/orders')({
staticData: { module: 'orders', label: 'Orders', reviewStates: ['loaded', 'empty'] },
component: Orders,
});07 · Any router
Router adapters
Everything Review Mode needs from routing goes through one object. TanStack Router is bundled. For another router, implement these members; the live demo runs on a hash-based adapter of about forty lines.
type RouterAdapter = {
routes: () => readonly ReviewRouteInfo[];
getLocation: () => ReviewLocation; // same object until the location changes
subscribe: (listener: () => void) => () => void;
navigate: (target: { href: string } | { to: string; search?: Record<string, unknown> }) => void;
Link: ComponentType<ReviewLinkProps>;
frameHref?: (appHref: string) => string; // hash routers: '/orders' -> '/page#/orders'
};08 · Production
Keep it out of production builds
- Gate the mount, the design pages,
./scenarioand any review-only server code on the build flag. - Make the production build refuse to turn the flag on.
- Build for production and search the output for
rvm-and the package name. - Allow same-origin framing on development responses so the width presets can frame the page.
09 · Keyboard
Shortcuts
| Area key | Each entry’s own key, for example 1 or 2 |
| T | Light or dark |
| H | Hide or show the bar |
| W | Cycle width presets |
| N | Notes layer |
| C | Self-check panel |
| Esc | Close the check panel |