editor-shell
v0.36.0
Published
Shared editor-chrome primitives for the EFFICIENT editors: EditorRail (a Next-16-safe left icon-rail leaf), the shell token layer, and GoldTextInput — type in place and watch the markup formatting appear as you type.
Maintainers
Readme
editor-shell
Shared editor-chrome primitives for the EFFICIENT editors — the owner's own editor shell, so every editor draws the same chrome from one source instead of hand-rolling and drifting.
Status: foundation. Ships one brick — the shared left icon-rail. Not published to npm; no product repo consumes it yet.
Why
The storefront editor (efficient-shop, Next 16 + Puck) and the campaign
designer (efficient-admin-portal, Vite/React) each hand-rolled their own narrow
left icon-rail. They had already drifted — button 34px vs 36px, icon 18px vs
20px, radius 9 vs 8, a CSS-var accent vs a Tailwind one. EditorRail is the one
rail they converge on.
The leaf
The storefront's editor chrome renders in Next-16 server components, so the
rail is a self-contained leaf at editor-shell/rail: it imports nothing heavy
and touches no document / window / localStorage at import or render, so a
server component can pull it in safely. tests/rail-leaf-safety.test.tsx proves
it.
// A client component in any editor:
'use client';
import {
EditorRail,
PagesIcon, LayersIcon, StylesIcon, MediaIcon, SitemapIcon, AddIcon,
} from 'editor-shell/rail';
import 'editor-shell/rail.css'; // once, from a client/global entry
export function LeftRail({ view, onView, onAdd }) {
return (
<EditorRail
ariaLabel="Left panel views"
items={[
{ id: 'pages', label: 'Pages', icon: <PagesIcon />, active: view === 'pages', onSelect: () => onView('pages') },
{ id: 'layers', label: 'Layers', icon: <LayersIcon />, active: view === 'layers', onSelect: () => onView('layers') },
{ id: 'styles', label: 'Styles', icon: <StylesIcon />, active: view === 'styles', onSelect: () => onView('styles') },
{ id: 'media', label: 'Media', icon: <MediaIcon />, onSelect: onOpenMedia },
{ id: 'add', label: 'Add page', icon: <AddIcon />, onSelect: onAdd },
]}
/>
);
}API
<EditorRail items ariaLabel? className? orientation? />
A pure presentational toolbar. It owns the look, never the app state — you
decide which item is active and what each onSelect does.
| prop | type | default | notes |
|---|---|---|---|
| items | EditorRailItem[] | — | buttons, in render order |
| ariaLabel | string | "Editor" | names the toolbar for assistive tech |
| className | string | — | merged onto .es-rail |
| orientation | 'column' \| 'row' | 'column' | which way the buttons run |
orientation
'column' is the rail every editor draws today: a fixed 46px strip wearing its
own card. It is the default, and it renders exactly what it rendered before this
prop existed.
'row' lays the same buttons left to right for a top bar — no fixed width
(as wide as its buttons) and no card (no background, radius or shadow),
because the bar already has its own surface. The button size, icon size, gap and
every hover/active colour are identical in both directions; only the container
changes. A row is 34px tall, so a 52px bar centres it with 9px to spare.
// The same rail, in the top bar's empty middle.
<EditorRail orientation="row" ariaLabel="Editor views" items={items} />aria-orientation follows the layout (vertical / horizontal); the toolbar
role does not change, because a rail that mixes view toggles with plain actions
is a toolbar whichever way it runs.
EditorRailItem
interface EditorRailItem {
id: string; // React key + selection correlation
label: string; // aria-label + hover title
icon: React.ReactNode; // an 18px currentColor glyph — use the shipped icons
active?: boolean; // present ⇒ view toggle (renders aria-pressed); omit for actions
disabled?: boolean;
onSelect?: () => void;
}A view item passes active: true | false and renders as a toggle
(aria-pressed, .is-active highlight). An action item (the quick-add "+")
omits active and renders as a plain button.
EditorRailButton — the single-button building block EditorRail maps to — is
exported too, for bespoke layouts.
Tokens
The same values, two ways — railTokens (JS) and rail.css (CSS custom
properties on .es-rail). Taken verbatim from the storefront editor's .sf-rail
(the visual reference).
| token | value | CSS var |
|---|---|---|
| rail width | 46px | --es-rail-w |
| rail padding (y) | 8px | --es-rail-pad-y |
| button gap | 4px | --es-rail-gap |
| rail radius | 12px | --es-rail-radius |
| button size | 34px | --es-rail-btn-size |
| button radius | 9px | --es-rail-btn-radius |
| icon size | 18px | --es-rail-icon-size |
| background | #ffffff | --es-rail-bg |
| idle icon | #6b7280 | --es-rail-fg |
| hover bg / fg | #f3f4f6 / #111827 | --es-rail-hover-bg / --es-rail-hover-fg |
| accent (active) | #2563eb | --es-rail-accent |
| accent tint (active bg) | #eff6ff | --es-rail-accent-tint |
orientation="row" adds no new token. It re-points three of the ones above
— --es-rail-bg to transparent, --es-rail-shadow to none,
--es-rail-radius to 0 — so the card look stays declared in one place, and it
reuses --es-rail-pad-y rotated onto the row's ends and --es-rail-gap as-is.
Re-theme without new CSS by overriding a var on the element:
style={{ ['--es-rail-accent']: brand }}.
The gold layer — type in place
GoldTextInput (subpath editor-shell/gold, added in 0.3.0) is the surface a
merchant types INTO on the page, with the markup formatting appearing as they
type. A textarea whose own text is transparent sits on top of a layer holding the
same characters, painted; what you read is the layer, what the caret walks is the
box. Both inherit their type from whatever the host renders them inside, so the
glyphs land on top of each other.
It is controlled and writes nothing — no document, no sections, no Puck. The host owns the string and decides what a finished edit means.
'use client';
import { GoldTextInput } from 'editor-shell/gold';
import { STOREFRONT_MARKUP } from 'react-os-shell/markup';
import 'editor-shell/gold.css'; // once, from a client/global entry
<GoldTextInput
value={draft}
rules={STOREFRONT_MARKUP}
placeholder="Add a heading"
ariaLabel="Section heading"
onInput={setDraft}
onCommit={(value) => save(value)} // blur, or Enter on a single-line field
onCancel={(restored) => setDraft(restored)}
/>;| prop | type | default | notes |
|---|---|---|---|
| value | string | — | the authored string, delimiters and all |
| onInput | (value: string) => void | — | every keystroke; the layer repaints from what you send back |
| onCommit | (value: string) => void | — | blur, or Enter when multiline is false. Once per session, never if nothing was typed |
| onCancel | (restored: string) => void | — | Escape. The argument is the value held at focus time; the host puts it back |
| multiline | boolean | false | what Enter means, not how the text wraps — both modes wrap |
| placeholder | string | — | visible until the first keystroke |
| className | string | — | merged onto .es-gold |
| rules | readonly InlineRule[] | STANDARD_MARKUP | pass your product's set — STOREFRONT_MARKUP / CAMPAIGN_MARKUP |
| ariaLabel | string | — | in-place editing has no visible label |
The grammar is not ours. Runs are parsed by react-os-shell/markup, the same
module the published page and the campaign email render from, so a toolbar
button, a page and this box can never disagree about what a delimiter means. That
subpath is framework-free and imports nothing, which is why the leaf can use it;
react-os-shell is declared as an optional peer dependency, needed only if
you import editor-shell/gold.
Two deliberate limits. The asterisks are FADED, never removed. They go to
nothing while the box is unfocused, so a merchant who is not typing does not read
** in their own page text, and they come back dimmed while the caret is in the
box — but the characters stay in the layer at their full width in both states.
Actually removing them needs a second text engine, and is out of scope. And the
layer only paints what cannot move a glyph: colour, opacity, decoration, a
faux-bold shadow. A real font-weight: 600 is wider, so a marked line would wrap
earlier than the transparent box on top of it and every following line would be
drawn over the wrong text. The real weight and slant appear on the page the
moment the merchant clicks away.
The edit lock — two operators, one document
editor-shell/edit-lock (added in 0.32.0) is the answer to "is the save path
allowed to run", shared by every editor in the house. It is pure TypeScript: no
React, no browser global, no dependency at all, so a server component may import
it.
It exists because the storefront editor and the campaign designer had written the same rules twice, in two repos, and the two copies had already drifted — the take-over half only existed on one side and the claim driver only on the other. Both are here now, and neither editor keeps a copy.
import { createEditLock, canWriteDocument, documentPermissions } from 'editor-shell/edit-lock';
// The only two things that differ between one editor and the next.
export const pageEditLock = createEditLock({
readFailure: (err) =>
err instanceof ApiError ? { status: err.status, data: err.data } : null,
conflictCode: 'page_changed_elsewhere',
});The plain half needs no binding: canWriteDocument, holderOf,
readLockHolder, accessForNewDocument, documentPermissions, describeAge,
nameOrSomeone, newEditorClientId, shouldReleaseHeldWrite. The bound half —
accessAfterClaimFailure, accessAfterTakeoverFailure,
accessAfterHeartbeatFailure, isAuthRejection, readSaveConflict,
makeClaimBeat — comes back from createEditLock.
Two things this module will not do. It never takes the ability to edit away
because a courtesy call failed: every claim failure that is not a 409 naming a
holder falls back to editing, because the save-time version check is what
actually protects the work. And a failed TAKE-OVER is a different question from a
failed first claim — it keeps the state it was pressed from, so a take-over that
did not land never puts two people in one document with the holder untold.
Develop
No Node on the dev Mac — everything runs in Docker:
docker run --rm -v "$PWD":/w -w /w node:26-slim \
sh -lc "npm install && npm run typecheck && npm test && npm run build"npm run typecheck—tscoversrcand the specs.npm test— the leaf-safety, gold-layer and rail-shape gates. The gold-layer spec is the one that needs a DOM (focus, keys, blur); it stands up a single jsdom window intests/_dom-env.tsand runs in its own process.npm run build—tsup→dist/(root + therail,shellandgoldsubpaths) + the copied CSS.
