Skip to content

Commit ccf348d

Browse files
committed
feat(admin): one flat rail, a Dashboard that leads with a sentence
The UX half of the re-brand. Four rules, now recorded in DESIGN-ALIGNMENT.md, and every change below traces to one of them: one idea per screen; content over chrome; say nothing rather than say nothing; plain, task-first names. **The rail is one flat list.** The three labelled sections were three more things to read before you could read the thing you came for, and seven items is not a wall. This is a pure data change — AppNav takes a mixed array of items and groups, so only what the useMemo returns changed; the JSX did not. Two things fell out of it and are cleaned up here rather than left half-done. TopBar's "group · title" breadcrumb had nothing left to resolve, so the lookup goes and it shows the title. And the footer was 28 lines of raw <button>s re-implementing the DS's own nav-item markup by hand; the DS supports footer groups natively (`footer: true` puts a group in .pp-nav__bottom), which brings aria-current and the collapsed-rail tooltips with it instead of us maintaining them. Two labels now say what you do there: Guidance -> Instructions, Connections -> Apps. Routing keys are untouched, so every #hash link, the ALIASES map and Pro's routing keep working. **The Dashboard leads with a sentence.** It opened with four equal tiles of three different kinds — two counts, a setting and a health state — which reads as a metrics dashboard for a question that is not a metric. The answer to "what can my AI do right now" was already written, in levelFor(tier).one, and the tiles were paraphrasing it down to one word. So that sentence is the page now, the counts are one quiet line under it, and the Connection tile is gone: it said "—" on most installs while a real problem already had its own callout with an explanation and a fix, so it spent a quarter of the page saying nothing. When there are no apps the count is dropped too — the callout directly below already says it. The LEVELS strings in api.js were raw English, never wrapped in __() — including the sentence this design just promoted to the page headline. Fixed, which is most of the 10 new msgids. **The Cookbook is removed** — tab, screen, CSS, REST route, PHP class and its 6 tests. Verified not agent-facing first: Saddle_Cookbook is referenced in exactly three places (itself, its test, one admin REST callback) and has zero hits across includes/abilities, the system context, the context bundle, skills, playbooks and the MCP transport. Not to be confused with Saddle_Recipes, the section recipes behind saddle/list-section-recipes — different system, untouched. 637 tests (was 643; the 6 are the cookbook's), 0 lint errors. Refs #153
1 parent a74a6df commit ccf348d

16 files changed

Lines changed: 560 additions & 1676 deletions

admin/DESIGN-ALIGNMENT.md

Lines changed: 112 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,126 @@
11
# Saddle — Design Alignment
22

3-
> **DECIDED (2026-07-10, Fahim):** the admin UI is fully migrated to
4-
> **`@plugpress/ui` v0.3.0** — the shared PlugPress design system — which is
5-
> exactly the "shared component package" outcome the original research called
6-
> for. Import primitives directly from `@plugpress/ui`; the old
7-
> `admin/src/ui.jsx` compat shim is deleted. The system is **light-only**
8-
> (dark mode was removed from the DS in v0.2.0 — the dark-theme and
9-
> `s-palette-*` mentions below are historical). The 2026-07-04 monochrome
10-
> identity **stands**, now expressed through `tokens/accents/saddle.css`
11-
> (a fully monochrome accent) on top of the shared `--pp-*` tokens;
12-
> `style.scss` keeps only product-specific pieces (setup shell, permissions
13-
> lanes/chips, activity timeline, wizard flourishes) aliased to those tokens.
14-
> The brand mark is **single-sourced** from `assets/brand/mark.svg` — React's
15-
> `<BrandMark />` (SVGR import) and the PHP admin-menu icon
16-
> (`class-saddle-settings.php`, `file_get_contents` + recolor) both read that
17-
> one file; edit the SVG once to rebrand every surface.
18-
> Don't re-litigate any of this in future sessions.
3+
> **DECIDED (2026-08-25, Fahim):** Saddle has a brand palette, built on
4+
> **Saddle Violet `#6D3BF5`**. This **supersedes the 2026-07-04 monochrome
5+
> decision** kept below for history. The register is unchanged — calm, restrained,
6+
> Vercel/Geist — but the one accent is now violet instead of near-black.
7+
> **Roughly 85% of the interface stays black, white and gray; violet is the
8+
> remaining 15%.** Don't re-litigate this; do hold the line on the 85/15.
9+
10+
## The palette
11+
12+
| Role | Name | Hex |
13+
|---|---|---|
14+
| Primary brand | Saddle Violet | `#6D3BF5` |
15+
| Hover / active | Deep Violet | `#5121C7` |
16+
| Soft background | Lavender Mist | `#F1ECFF` |
17+
| Main text | Near Black | `#111111` |
18+
| Page background | Warm White | `#FBFBFA` |
19+
| Success / read-only | Lime | `#84CC16` |
20+
| Activity / info | Cyan | `#06B6D4` |
21+
| Warning / gated | Amber ||
22+
| Destructive / blocked | Coral | `#F43F5E` |
23+
24+
It lives in `admin/src/style.scss` as consumer-side `--pp-*` overrides. **Never in
25+
`@plugpress/ui`** — the library is off-limits from a plugin task, and the DS
26+
defines its tokens inside `:where()` (zero specificity), so a plain class selector
27+
wins with no `!important`. Both `.pp-app` and the body class are targeted, because
28+
portaled overlays render outside `.pp-app` and would otherwise lose every token.
29+
30+
## The rule that governs every color
31+
32+
**Every color has a bright value for FILLS and a darker same-hue value for TEXT.**
33+
This is not a preference. Measured on white: violet is 5.84:1, but lime is 1.98,
34+
cyan 2.43 and coral 3.67 — none of them can carry a label. The DS mandates the
35+
same split for the accent (`--pp-accent` paints surfaces at 3:1;
36+
`--pp-accent-text` paints links and labels at 4.5:1, measured against the *tint*,
37+
not just white).
38+
39+
Three consequences worth knowing before you touch a status color:
40+
41+
- **A mark needs 3:1 too.** A status dot, a 2px lane rail or a thin ring is a
42+
graphical object, so lime and cyan are each used **one step down their ramp**
43+
(`#65A30D`, `#0891B2`) — at the `-500` step they are invisible on a white card.
44+
Coral already clears it and is used exactly as given.
45+
- **The DS collapses `--pp-tone-text` into `--pp-tone`** for status tones, so the
46+
readable partners are put back at the tone layer — the same seam the accent tone
47+
already uses.
48+
- **Warning and destructive are two different signals.** Amber means "powerful,
49+
asks first" (the Remove lane, the shield chip, the write-tier pill); coral means
50+
"blocked or failed" (a denied call, a connection error). They need opposite
51+
reactions from the reader — don't fold them together.
52+
53+
## Where violet is allowed to land
54+
55+
**Yes:** primary buttons · links · focus rings · the active nav row · the brand
56+
mark · the Cookbook-style accent bars · `tone="accent"` fills · selection states.
57+
58+
**No:** card backgrounds, page bands, table headers, every icon, borders at large,
59+
section headings. If a screen has more than a few violet elements, remove some
60+
rather than softening the violet.
61+
62+
**One deliberate deviation from the DS guide, recorded so it isn't "fixed" back:**
63+
the guide says *"Primary buttons are near-black (`--pp-action`), never the accent
64+
color."* Saddle's are violet. The cost was that `.pp-code--dark` reads
65+
`--pp-code-bg` from `--pp-action` while hard-coding its border, muted and body
66+
colors, so `--pp-code-bg` is pinned back to near-black in `style.scss`. Code panels
67+
are deliberately **not** the button color.
68+
69+
## The UX register
70+
71+
Four rules the admin is held to. A change that doesn't trace to one of these
72+
doesn't belong.
73+
74+
1. **One idea per screen.** Lead with the thing the user came to find out, in a
75+
sentence. Supporting facts go quiet underneath. The Dashboard is the worked
76+
example: it opens with `levelFor( tier ).one` and nothing competes with it.
77+
2. **Content over chrome.** Structure comes from type, spacing and hairlines, not
78+
from more panels. Radii are 3/4/6px and elevation is flat — close to wp-admin's
79+
own square register, a hair softer.
80+
3. **Say nothing rather than say nothing.** A tile reading `` is worse than no
81+
tile. The Dashboard's old "Connection" tile is why this rule is written down.
82+
4. **Plain, task-first names.** Labels say what you do there. The sidebar is one
83+
flat list with no group headings — seven items is not a wall.
1984

2085
## Constraints that still apply
2186

22-
- UI primitives come from `@plugpress/ui` only — no `@wordpress/components`,
23-
no Tailwind, no styled-components, no second UI kit.
87+
- UI primitives come from `@plugpress/ui` only — no `@wordpress/components`, no
88+
Tailwind, no styled-components, no second UI kit.
2489
- The Settings page must remain a single mounted React root (`#saddle-root`).
25-
- Don't regress accessibility: labels, focus rings, `role`/`aria-*` semantics,
26-
and `prefers-reduced-motion` support must survive any restyling.
90+
- Don't regress accessibility: labels, focus rings, `role`/`aria-*` semantics and
91+
`prefers-reduced-motion` must survive any restyling. Every text pair in the
92+
palette clears AA; muted text actually improved (4.40 → 4.50).
2793
- Light-only: never add a theme toggle.
28-
- Portaled DS overlays (dialogs, dropdowns, toasts) read tokens from the
29-
`pp-scope` class on `<body>` (added via `admin_body_class`) — keep it.
30-
- Stylesheet order: the DS bundle (`index.css` → handle `saddle-admin-ds`)
31-
loads before Saddle's own `style-index.css` so product rules win.
94+
- Portaled DS overlays read tokens from `pp-scope` on `<body>` (via
95+
`admin_body_class`) — keep it.
96+
- Stylesheet order: the DS bundle (`index.css``saddle-admin-ds`) loads before
97+
Saddle's `style-index.css` so product rules win.
98+
- **The brand mark is recolored through CSS `color`, never by editing
99+
`assets/brand/mark.svg`.** `class-saddle-settings.php` does
100+
`str_replace( 'currentColor', 'black', … )`, and that `black` is a sentinel
101+
core's `svg-painter.js` needs to repaint the wp-admin menu icon per the user's
102+
admin color scheme. The menu icon is deliberately left alone.
32103

33104
---
34105

35106
## History
36107

37-
> **DECIDED (2026-07-04, Fahim):** the monochrome identity is intentional,
38-
> not a placeholder. Saddle keeps its own near-black/white, OpenAI/Apple-register
39-
> look — one restrained accent reserved for status/safety. The earlier
40-
> "align to inbees/outbees" directive below is SUPERSEDED; kept for history.
108+
> **SUPERSEDED (2026-07-04, Fahim):** the monochrome identity is intentional, not
109+
> a placeholder. Saddle keeps its own near-black/white, OpenAI/Apple-register
110+
> look — one restrained accent reserved for status/safety.
111+
>
112+
> Still true in spirit: the restraint, the single accent, the register. What
113+
> changed on 2026-08-25 is only which color the accent is.
114+
115+
> **DECIDED (2026-07-10, Fahim):** the admin UI is fully migrated to
116+
> `@plugpress/ui` — the shared PlugPress design system. Import primitives directly
117+
> from `@plugpress/ui`; the old `admin/src/ui.jsx` compat shim is deleted. The
118+
> system is light-only (dark mode was removed from the DS in v0.2.0).
41119
42-
### The former rule (superseded)
120+
### The former rule (superseded 2026-07-04)
43121

44-
Do **not** invent a visual design from memory or taste. Saddle has to read as
45-
"the same workspace" as the rest of the PlugPress portfolio (inbees/outbees).
46-
That means pulling real values, not approximating them — and if a shared
47-
PlugPress design-system package exists, consume it rather than re-implementing
48-
it. (That package now exists: `@plugpress/ui`. It happened.)
122+
Do **not** invent a visual design from memory or taste. Saddle has to read as "the
123+
same workspace" as the rest of the PlugPress portfolio (inbees/outbees). That means
124+
pulling real values, not approximating them — and if a shared PlugPress
125+
design-system package exists, consume it rather than re-implementing it. (That
126+
package now exists: `@plugpress/ui`. It happened.)

admin/build/index.asset.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '1e6978e822c8398099c0');
1+
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '768b5d174835e661c422');

admin/build/index.js

Lines changed: 46 additions & 46 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

admin/build/style-index-rtl.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

admin/build/style-index.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

admin/src/App.jsx

Lines changed: 45 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import {
4040
PlugIcon,
4141
ActivityIcon,
4242
SettingsIcon,
43-
SparklesIcon,
4443
} from '@plugpress/ui';
4544
import { __, sprintf, _n } from '@wordpress/i18n';
4645
import { api, levelFor } from './api';
@@ -50,7 +49,6 @@ import Dashboard from './components/Dashboard';
5049
import Permissions from './components/Permissions';
5150
import Guidance from './components/Guidance';
5251
import Memory from './components/Memory';
53-
import Cookbook from './components/Cookbook';
5452
import Apps from './components/ConnectedClients';
5553
import Integrations from './components/Integrations';
5654
import Activity from './components/Activity';
@@ -65,25 +63,20 @@ const TABS = [
6563
title: __( 'Dashboard', 'saddle' ),
6664
icon: <DashboardIcon />,
6765
},
68-
{
69-
name: 'cookbook',
70-
title: __( 'Cookbook', 'saddle' ),
71-
icon: <SparklesIcon />,
72-
},
7366
{
7467
name: 'permissions',
7568
title: __( 'Permissions', 'saddle' ),
7669
icon: <ShieldIcon />,
7770
},
7871
{
7972
name: 'guidance',
80-
title: __( 'Guidance', 'saddle' ),
73+
title: __( 'Instructions', 'saddle' ),
8174
icon: <BookOpenIcon />,
8275
},
8376
{ name: 'memory', title: __( 'Memory', 'saddle' ), icon: <InboxIcon /> },
8477
{
8578
name: 'connect',
86-
title: __( 'Connections', 'saddle' ),
79+
title: __( 'Apps', 'saddle' ),
8780
icon: <LinkIcon />,
8881
},
8982
{
@@ -103,22 +96,18 @@ const TABS = [
10396
},
10497
];
10598

106-
// Sidebar grouping — labeled sections so eight items don't read as a flat
107-
// wall. Names reference TABS (routing stays keyed by name); Settings lives in
108-
// the rail footer.
109-
const NAV_GROUPS = [
110-
{ key: 'top', label: '', items: [ 'dashboard', 'cookbook' ] },
111-
{
112-
key: 'ai',
113-
label: __( 'Your AI', 'saddle' ),
114-
items: [ 'permissions', 'guidance', 'memory' ],
115-
},
116-
{
117-
key: 'connect',
118-
label: __( 'Connect', 'saddle' ),
119-
items: [ 'connect', 'integrations' ],
120-
},
121-
{ key: 'monitor', label: __( 'Monitor', 'saddle' ), items: [ 'activity' ] },
99+
// The rail, in order. One flat list rather than labeled sections: seven items
100+
// is not a wall, and the headings were three more things to read before you
101+
// could read the thing you came for. Names reference TABS (routing stays keyed
102+
// by name); Settings sits in the rail footer.
103+
const NAV_MAIN = [
104+
'dashboard',
105+
'permissions',
106+
'guidance',
107+
'memory',
108+
'connect',
109+
'integrations',
110+
'activity',
122111
];
123112
const NAV_FOOTER = [ 'settings' ];
124113

@@ -159,13 +148,11 @@ const DOT_TONES = {
159148
paused: 'neutral',
160149
};
161150

162-
// The slim sticky bar above the content column: page context on the left
163-
// (nav group · page title), the always-visible safety-status pill on the
164-
// right. The pill is a real button — it jumps to Settings, where the
165-
// controls it reflects live.
151+
// The slim sticky bar above the content column: the page title on the left,
152+
// the always-visible safety-status pill on the right. The pill is a real
153+
// button — it jumps to Settings, where the controls it reflects live.
166154
function TopBar( { tab, tier, paused, onNavigate, notices } ) {
167155
const t = TABS.find( ( x ) => x.name === tab );
168-
const group = NAV_GROUPS.find( ( g ) => g.items.includes( tab ) );
169156
const level = levelFor( tier );
170157
let tone = level.key === 'read' ? 'safe' : 'active';
171158
if ( paused ) {
@@ -174,16 +161,6 @@ function TopBar( { tab, tier, paused, onNavigate, notices } ) {
174161
return (
175162
<header className="saddle-topbar">
176163
<div className="saddle-topbar__context">
177-
{ !! group?.label && (
178-
<>
179-
<span className="saddle-topbar__group">
180-
{ group.label }
181-
</span>
182-
<span className="saddle-topbar__sep" aria-hidden="true">
183-
·
184-
</span>
185-
</>
186-
) }
187164
<span className="saddle-topbar__title">{ t?.title }</span>
188165
</div>
189166
<div className="saddle-topbar__right">
@@ -310,7 +287,7 @@ export default function App() {
310287
// every addon bundle registered its filters at script evaluation.
311288
const extTabs = useMemo( collectTabs, [] );
312289
const extNames = useMemo( () => extTabs.map( ( t ) => t.id ), [ extTabs ] );
313-
const { navItems, navFooter } = useMemo( () => {
290+
const navItems = useMemo( () => {
314291
const allTabs = [
315292
...TABS,
316293
...extTabs.map( ( t ) => ( {
@@ -319,24 +296,31 @@ export default function App() {
319296
icon: <t.Icon />,
320297
} ) ),
321298
];
322-
return {
323-
navItems: NAV_GROUPS.map( ( g ) => ( {
324-
heading: g.label || undefined,
325-
items: [
326-
...g.items,
327-
...extTabs
328-
.filter( ( t ) => t.group === g.key )
329-
.map( ( t ) => t.id ),
330-
].map( ( name ) => navItem( name, allTabs ) ),
331-
} ) ),
299+
// A contributed tab joins the main list unless it asked for the footer.
300+
// The groups it used to be able to name are gone, so anything that is
301+
// not explicitly 'footer' lands in the one list rather than nowhere.
302+
const extMain = extTabs
303+
.filter( ( t ) => t.group !== 'footer' )
304+
.map( ( t ) => t.id );
305+
const extFooter = extTabs
306+
.filter( ( t ) => t.group === 'footer' )
307+
.map( ( t ) => t.id );
308+
309+
return [
310+
...[ ...NAV_MAIN, ...extMain ].map( ( name ) =>
311+
navItem( name, allTabs )
312+
),
313+
// A real DS group rather than hand-rolled buttons in the `footer`
314+
// slot: `footer: true` is what puts it in .pp-nav__bottom, and it
315+
// brings aria-current and the collapsed-rail tooltips with it.
332316
// Extension footer entries sit above Settings, which stays last.
333-
navFooter: [
334-
...extTabs
335-
.filter( ( t ) => t.group === 'footer' )
336-
.map( ( t ) => t.id ),
337-
...NAV_FOOTER,
338-
].map( ( name ) => navItem( name, allTabs ) ),
339-
};
317+
{
318+
footer: true,
319+
items: [ ...extFooter, ...NAV_FOOTER ].map( ( name ) =>
320+
navItem( name, allTabs )
321+
),
322+
},
323+
];
340324
}, [ extTabs ] );
341325
const [ tab, setTabState ] = useState( () => tabFromHash( extNames ) );
342326
const [ wizardOpen, setWizardOpen ] = useState( false );
@@ -544,42 +528,12 @@ export default function App() {
544528
<span>{ __( 'Saddle', 'saddle' ) }</span>
545529
</>
546530
}
547-
items={ navItems }
548-
value={ tab }
549-
onChange={ setTab }
550531
// Navigation only. Docs, Rate Saddle and the version
551532
// stamp all live in Settings → About; repeating them
552533
// here made a five-item footer out of a two-item one.
553-
footer={
554-
<>
555-
{ navFooter.map( ( item ) => (
556-
<button
557-
key={ item.value }
558-
type="button"
559-
className="pp-nav__item"
560-
aria-current={
561-
tab === item.value
562-
? 'page'
563-
: undefined
564-
}
565-
title={ item.label }
566-
onClick={ () =>
567-
setTab( item.value )
568-
}
569-
>
570-
<span
571-
className="pp-nav__icon"
572-
aria-hidden="true"
573-
>
574-
{ item.icon }
575-
</span>
576-
<span className="pp-nav__label">
577-
{ item.label }
578-
</span>
579-
</button>
580-
) ) }
581-
</>
582-
}
534+
items={ navItems }
535+
value={ tab }
536+
onChange={ setTab }
583537
/>
584538
}
585539
>
@@ -639,9 +593,6 @@ export default function App() {
639593
onCapsChanged={ loadCaps }
640594
/>
641595
) }
642-
{ tab === 'cookbook' && (
643-
<Cookbook onNavigate={ setTab } />
644-
) }
645596
{ tab === 'guidance' && <Guidance /> }
646597
{ tab === 'memory' && <Memory /> }
647598
{ tab === 'activity' && <Activity /> }

0 commit comments

Comments
 (0)