A standalone web prototype of a reimagined WordPress admin shell: a persistent structural layer (admin bar + stage region) built around role-based dashboards, navigation as flow, and a Workspaces model that treats every destination as a first-class, multitaskable surface.
This repo implements slice 1 of the plan in
.cursor/plans/wordpress_shell_prototype_plan_4f145b46.plan.md (the shell
chrome and switching model) and slice 2 in
.cursor/plans/manage_edit_relationship_plan.plan.md (the manage ↔ Editor
relationship + a canonical intra-context navigation primitive). It proves
the structural model — not the content. Real workflows, real role recipes,
AI features, and WordPress integration come in later slices.
npm install
npm run dev # dev server
npm run build # type-check + production buildNote on vocabulary: "context" is the internal data model name used throughout the codebase. Users see it as Workspaces in the chrome (admin bar button, tooltip, AI copy, menus). Architecture docs below use "context"; product copy uses "workspace".
The model has two layers, deliberately distinct (think iOS):
- Home — the dashboard. Always present. Singleton. The surface you return
to between tasks.
Dashboard(insrc/workflows/Dashboard.tsx) is rendered as a permanent layer behind everything else. - Contexts — task-shaped workflows like
add-product,pages,editor,settings. Each can be open, focused, or closed. The shape:
type Context = {
id: string
type: ContextType
title: string
params?: Record<string, string | number>
openedAt: number
lastFocusedAt: number
}The contexts store (src/contexts/store.ts) is the source of truth:
open(ref)— open or focus a contextclose(id)— close, falling back to the most recently focused remaining context, or to home if none remainfocus(id)— make a context activegoHome()— leave all contexts open but make home the active surface (activeId = null)closedRecents— last 5 closed contexts (powers the palette's Recent section)
activeId === null means "home is showing". The URL hash mirrors this:
empty hash = home, #pages?view=draft = a specific context with state.
Deep links work without a router library.
Each context type declares its dedupe behaviour in src/contexts/registry.ts
through one of two fields on its meta:
-
singleton: true— at most one context of this type. Reopening focuses the existing one and merges any new params (used bypages,settings,orders, …). -
singletonKey(params)— returns the dedupe key for a given params object.undefinedmeans "always create a new one"; a string means "find or create the context with this key". Lets one type be a default singleton with optional pinned instances. Used byeditor:singletonKey: (params) => params?.instanceId ? String(params.instanceId) : 'default'
In practice: clicking a page row opens (or focuses) the default editor and swaps its document. Cmd/Ctrl-click — or "Open in new context" from the row's action menu — opens a pinned editor for parallel editing.
A type can also declare resolveDefaultParams() to fill in params when
open() is called bare. The Editor uses this to land on the homepage when
reached via the destinations list or a stale URL.
Two roles, one verb between them — WordPress's most-repeated loop made first-class:
- Manage contexts are dataview-shaped (table, filters, bulk actions).
pagesis the first one;posts,templates,patterns,navigation,productsfollow the same shape in later slices. - The Editor context is a single multi-kind editing surface. Its params
are
{ kind, id, instanceId? }, wherekindis'page' | 'post' | 'template' | 'template-part' | 'pattern' | 'navigation'. Same chrome, same loop, regardless ofkind.
Clicking a row in Pages dispatches open({ type: 'editor', params: { kind: 'page', id } }). The Pages context stays open, the Editor swaps its
document (default-singleton behaviour above), and Cmd-` cycles between
them. Closing the Editor falls back to Pages via the LRU stack.
- Add a mock dataset in
src/mocks/<thing>.ts. - Register a singleton context type in
src/contexts/{types,registry,url}.ts. - Build
src/workflows/<Thing>.tsx, composing<ContextLayout>+<ContextSubnav>for the primary intra-context navigation, with the dataview in<ContextLayout.Main>. Row clicks dispatchopen({ type: 'editor', params: { kind: '<thing>', id } }). - Register the workflow in
src/workflows/index.tsx. - Wire it up wherever it should be reached (recipe nav widget, destination list, etc.).
The Editor doesn't need to be touched to support a new kind in the prototype — its mockup canvas just renders whatever document the params point at. In a real implementation, kind-specific document settings would live in the left rail.
Three composable layers, any of which a workspace may opt into:
- Primary navigation — sidebar (
<ContextSubnav>). A static rail on the left.Pagesuses it for status views;Settingsuses it for sections (General, Reading, Shipping, …). - Secondary navigation — horizontal nav (
<ContextHeaderTabs>). Renders as a second row inside<ContextHeader>. Used for sub-sections within a sidebar item (e.g. Shipping → Zones / Classes / Methods), or as the primary nav when a workspace doesn't use a sidebar at all (e.g. Marketing). - Drilldown. Clicking an item in the content area enters a deeper
context — the horizontal nav disappears, the title becomes a
breadcrumb (
Zones / Zone name), and the description can change. The sidebar stays put with its current selection. Settings → Shipping → Zones → click a zone is the canonical example.
Header title rule. When the workspace has a sidebar, the header
title reflects the active sidebar item (e.g. "Shipping"). When the
workspace has no sidebar, the header title is just the workspace name
(e.g. "Marketing"); the active horizontal tab is conveyed by the
underline in the tabs row, not by mutating the title. The header
title never carries an icon in either case — the workspace icon already
appears in the switcher tile and admin bar, and a sidebar's selected
item carries its own icon. Context.title (used by the switcher tile,
palette, and admin bar) always stays as the workspace name.
The Editor is the explicit exception to all of this — its chrome owns both rails for document/inspector and uses the breadcrumb directly.
<ContextLayout>
<ContextSubnav>
<ContextSubnav.Group>
<ContextSubnav.Item icon={Truck} active onClick={() => setSection("shipping")}>
Shipping
</ContextSubnav.Item>
{/* … */}
</ContextSubnav.Group>
</ContextSubnav>
<ContextLayout.Main>
<ContextHeader
ctx={ctx}
tabs={
!drilldown ? (
<ContextHeaderTabs label="Shipping sections">
<ContextHeaderTabs.Tab active={tab === "zones"} onClick={() => setTab("zones")}>
Zones
</ContextHeaderTabs.Tab>
{/* … */}
</ContextHeaderTabs>
) : undefined
}
>
{drilldown ? (
<ContextHeader.Breadcrumb
parents={{ label: "Zones", onClick: clearItem }}
current={zone.name}
subtitle={zone.description}
/>
) : (
<ContextHeader.Title subtitle={section.description}>
{section.label}
</ContextHeader.Title>
)}
</ContextHeader>
{/* content */}
</ContextLayout.Main>
</ContextLayout><ContextHeaderTabs> is a semantic <nav> with aria-current="page"
on the active item — not an ARIA tablist. Activating a tab changes
URL params and the workspace state, not just a panel within the same
view.
State persists in the context's URL params (Settings uses
section / tab / item; Marketing uses tab), so Cmd-away and back, deep links, and switcher tiles all restore the same view.itemis only meaningful when paired withtab. Switching sidebar sections clears tabanditem` — predictable beats clever.
Future deeper drilldowns (Zone → Method → Rate) will replace the flat
item param with a path (slash-separated or array). The
<ContextHeader.Breadcrumb> parents array already accepts the shape.
Below md (≥768px), the sidebar is hidden by default and revealed via
a hamburger button rendered to the left of the title in
<ContextHeader>. The hamburger appears automatically when the
workspace's <ContextLayout> contains a <ContextSubnav> — no per-
workspace wiring. The drawer slides in from the left with a backdrop;
closes on backdrop click, Esc, or any item click. Workspaces without a
sidebar (e.g. Marketing) don't get a hamburger.
This is the only mobile pattern in the prototype — the rest of the shell chrome (admin bar, workspace switcher, dashboard) is still desktop-first.
The dashboard renders a recipe — a list of typed widget definitions. Four
kinds, sharing a WidgetBase shape:
LaunchTile— one-click entry to a context (action: ContextRef)InfoWidget— a list of items, optionally each with their own actionAnalyticsWidget— a metric (value + delta + sparkline)NavWidget— a small list of navigation items, like the classic admin sidebar
WidgetGrid switches on kind to render. Adding a new widget kind means
adding a type, a component, and a case — nothing in the recipe layer or the
shell needs to change. This is the contract that future plugin- or
AI-contributed widgets would target.
A single stub recipe lives in src/recipes/storeManager.ts and demonstrates
all four kinds.
- Admin bar — brand (always goes to Dashboard), Workspaces button (only visible when ≥2 workspaces are open; opens the Workspaces overlay), site menu, global search / palette trigger, notifications, AI sheet, user menu. The admin bar fades out (and becomes inert) while Workspaces is open; clicking anywhere in its region returns to Dashboard and dismisses the overlay.
- Command palette (
⌘K/Ctrl+K) — wraps cossCommand. Two sections: Recent (recently closed workspaces) and Go to (Dashboard + all known destinations). - Workspaces (
⌘\`` /Ctrl+``) — iOS-style horizontal stack. The active surface scales down to its slot at the right edge, and the other open workspaces line up to its left in recency order (see "Context stage" below). Free horizontal scroll via wheel/trackpad; arrow keys snap to focused tile. Enter selects, Esc dismisses, tap on empty space (including the admin bar) returns to Dashboard.
The stage region (src/shell/ContextStage.tsx) renders three layers:
- Home plane — the
Dashboardcomponent, always mounted at the back, visible whenever no context is active and glimpsed (faded + slightly blurred) behind the stack in switcher mode. - Open context tiles — one
ContextTileper open context, absolutely positioned over home. A singleswitcherOpenboolean derives every tile's transform:- Off — active tile is identity-transformed and covers home;
inactive tiles are held at
opacity: 0behind it. - On — every tile translates and scales into a slot in a horizontal
stack (
stageLayout.ts). Tiles are uniform size (40% scale, preserving stage aspect ratio) and ordered left → right = oldest → newest, so the most recent sits at the right edge. With ≥3 contexts, the rightmost two are fully visible at rest and the third peeks from the left. The stage scrolls horizontally (free scroll via wheel/trackpad; arrow keys snap and scroll the focused tile into view). A chrome layer with a click-catcher button and an overlaid close affordance fades in over each tile.
- Off — active tile is identity-transformed and covers home;
inactive tiles are held at
- Tap-empty catcher — only present in switcher mode; sits between the
home plane and the tiles. Clicking anywhere outside a tile calls
goHome()and dismisses the switcher.
Because we transform live DOM (not snapshots), tile previews are pixel-perfect and stay live. Trade-offs:
- Every open context is mounted at full size. Fine for stub workflows; real editors will need lazy mount + keep-alive in a later slice.
- It's not a
<Dialog>. Tab order is the natural DOM order through the tile buttons, anaria-liveregion announces mode changes, andEscdismisses — but focus is not strictly trapped. Accepted for the iOS-style "the workspace itself is the switcher" feel.
| Shortcut | Action |
|---|---|
⌘K |
Toggle command palette |
| `⌃`` | Toggle Workspaces |
Alt+1…9 |
Jump to nth workspace (focus order) |
Esc |
Close any open overlay |
(⌘ / Ctrl is platform-aware via useShortcuts.ts.)
src/
shell/ — Shell.tsx, AdminBar, CommandPalette, ContextStage,
ContextTile, ContextSwitcher, ContextLayout,
ContextSubnav, ContextHeader, ContextHeaderTabs,
stageLayout, useShortcuts, uiStore
contexts/ — store, types, registry (per-type metadata + destinations
+ singletonKey + resolveDefaultParams),
url (hash <-> ref)
widgets/ — types + LaunchTile, Info, Analytics, Nav, WidgetGrid
recipes/ — storeManager.ts, admin.ts (stub recipes)
workflows/ — Dashboard (home), Pages (dataview), Editor (mockup),
AddProduct, EditPage, Settings, Orders, Marketing,
Analytics, ProductReviews, ContextSurface
mocks/ — notifications, user, pages, settings
components/ui/ — coss/ui (shadcn-compatible) primitives
lib/utils.ts — cn helper
- Vite + React 19 + TypeScript
- Tailwind CSS v4 + coss/ui (Base UI primitives + cmdk-style
Autocomplete) — initialized via
npx shadcn@latest init @coss/style - Zustand for the contexts and UI stores
lucide-reactfor icons- No router library — the contexts store + URL hash sync is the source of truth
The data-driven widget definitions and the Recipe schema in
src/widgets/types.ts are the seam. In a later slice, recipes/ would be
generated from PHP/JSON on the WordPress side, and the Shell would render as
a React island inside wp-admin. The contexts model maps onto WP's existing
URL-driven pages (each page becomes a context).
Per the plans, the current build still excludes:
- Real WordPress integration / PHP
- Functional workflows beyond the static Pages dataview and Editor mockup
- A real block editor inside the Editor surface (or any unsaved-changes handling)
- Posts / Templates / Patterns / Navigation as their own dataviews — the model is shaped to absorb them; future slices ship them
- A site-preview dashboard widget that opens the Editor on the homepage
- Unbundling Appearance into
Templates/Patterns/Styles/Navigationsiblings in the classic-admin nav widget - Time-to-destination measurement harness
- Widget reordering / resizing UI
- A full mobile pass on the shell chrome (admin bar, workspace switcher, dashboard, command palette). Workspace sidebars do get a mobile drawer — see "Intra-context navigation" — but the rest is still desktop-first.
- The plan referenced a "Cmd+Tab"-style switcher. macOS reserves
⌘+Tabglobally, so the binding is⌘\`` (withAlt+1…9` as a faster jump for the power user). The behaviour mirrors the macOS app switcher. - The two design sync points in the plan (
Sync 1,Sync 2) had no designer-provided mocks available, so the visual layer uses coss/ui defaults throughout. Swapping in design tokens later means editingsrc/index.cssonly.