efficient-easel 0.0.0 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +818 -4
  3. package/dist/autosave/index.d.ts +101 -0
  4. package/dist/autosave/index.js +1 -0
  5. package/dist/blocks/index.d.ts +306 -0
  6. package/dist/blocks/index.js +0 -0
  7. package/dist/buckets-1vRlQceY.d.ts +13 -0
  8. package/dist/canvas/index.d.ts +194 -0
  9. package/dist/canvas/index.js +1 -0
  10. package/dist/chrome/chrome.css +570 -0
  11. package/dist/chrome/index.d.ts +219 -0
  12. package/dist/chrome/index.js +21 -0
  13. package/dist/chunk-2LUSK32B.js +1 -0
  14. package/dist/chunk-2OAZYSWJ.js +1 -0
  15. package/dist/chunk-67VZRQLG.js +13 -0
  16. package/dist/chunk-6OPSJGAI.js +1 -0
  17. package/dist/chunk-6PS6STGA.js +1 -0
  18. package/dist/chunk-6WY6ZXJG.js +1 -0
  19. package/dist/chunk-7A7O2JWQ.js +1 -0
  20. package/dist/chunk-BZ5ASXOL.js +1 -0
  21. package/dist/chunk-CERTFXCV.js +13 -0
  22. package/dist/chunk-D2BCHTQR.js +1 -0
  23. package/dist/chunk-FI23ALVJ.js +1 -0
  24. package/dist/chunk-GGTPNGFJ.js +1 -0
  25. package/dist/chunk-H4B72XCJ.js +1 -0
  26. package/dist/chunk-H6DDIBQ4.js +1 -0
  27. package/dist/chunk-KDIVA6WD.js +1 -0
  28. package/dist/chunk-KRXQM65X.js +1 -0
  29. package/dist/chunk-MCLBHENY.js +1 -0
  30. package/dist/chunk-MFIRYGHW.js +1 -0
  31. package/dist/chunk-NKB3KVAB.js +1 -0
  32. package/dist/chunk-O2NLQBFX.js +1 -0
  33. package/dist/chunk-OGZFDJ6G.js +1 -0
  34. package/dist/chunk-OWBO4WKA.js +0 -0
  35. package/dist/chunk-QB3BZIRA.js +5 -0
  36. package/dist/chunk-RE4LP46F.js +1 -0
  37. package/dist/chunk-STFMZJKU.js +14 -0
  38. package/dist/chunk-SUO5627P.js +1 -0
  39. package/dist/chunk-TDA4HH54.js +1 -0
  40. package/dist/chunk-WK3AFPXY.js +1 -0
  41. package/dist/chunk-YIMRTIBN.js +3 -0
  42. package/dist/chunk-YNZZYHA4.js +450 -0
  43. package/dist/chunk-YRLPFZW3.js +1 -0
  44. package/dist/chunk-ZAOM6PFH.js +1 -0
  45. package/dist/chunk-ZNCEOSXO.js +1 -0
  46. package/dist/copy/copy.css +282 -0
  47. package/dist/copy/index.d.ts +336 -0
  48. package/dist/copy/index.js +10 -0
  49. package/dist/data/index.d.ts +375 -0
  50. package/dist/data/index.js +1 -0
  51. package/dist/drag/drag.css +360 -0
  52. package/dist/drag/index.d.ts +379 -0
  53. package/dist/drag/index.js +18 -0
  54. package/dist/edit-lock/index.d.ts +354 -0
  55. package/dist/edit-lock/index.js +1 -0
  56. package/dist/editor/editor.css +58 -0
  57. package/dist/editor/index.d.ts +361 -0
  58. package/dist/editor/index.js +9 -0
  59. package/dist/fields/fields.css +207 -0
  60. package/dist/fields/index.d.ts +273 -0
  61. package/dist/fields/index.js +46 -0
  62. package/dist/gold/gold.css +196 -0
  63. package/dist/gold/index.d.ts +280 -0
  64. package/dist/gold/index.js +1 -0
  65. package/dist/icons/index.d.ts +474 -0
  66. package/dist/icons/index.js +1 -0
  67. package/dist/index.d.ts +6 -0
  68. package/dist/index.js +1 -0
  69. package/dist/markup/index.d.ts +452 -0
  70. package/dist/markup/index.js +1 -0
  71. package/dist/menu/index.d.ts +49 -0
  72. package/dist/menu/index.js +15 -0
  73. package/dist/menu/menu.css +254 -0
  74. package/dist/node-ops/index.d.ts +91 -0
  75. package/dist/node-ops/index.js +1 -0
  76. package/dist/panel/index.d.ts +1088 -0
  77. package/dist/panel/index.js +16 -0
  78. package/dist/panel/panel.css +1460 -0
  79. package/dist/pick/index.d.ts +153 -0
  80. package/dist/pick/index.js +14 -0
  81. package/dist/pick/pick.css +235 -0
  82. package/dist/plan-DChbUXdS.d.ts +88 -0
  83. package/dist/rail/index.d.ts +145 -0
  84. package/dist/rail/index.js +1 -0
  85. package/dist/rail/rail.css +175 -0
  86. package/dist/render/index.d.ts +117 -0
  87. package/dist/render/index.js +5 -0
  88. package/dist/resolve-D68pb3Bx.d.ts +57 -0
  89. package/dist/shell/index.d.ts +159 -0
  90. package/dist/shell/index.js +1 -0
  91. package/dist/shell/shell.css +122 -0
  92. package/dist/strings/catalogue.d.ts +141 -0
  93. package/dist/strings/catalogue.js +1 -0
  94. package/dist/strings/index.d.ts +56 -0
  95. package/dist/strings/index.js +2 -0
  96. package/dist/style-clipboard/index.d.ts +184 -0
  97. package/dist/style-clipboard/index.js +1 -0
  98. package/dist/tree/index.d.ts +173 -0
  99. package/dist/tree/index.js +1 -0
  100. package/dist/tree/tree.css +618 -0
  101. package/dist/types-41704JE4.d.ts +112 -0
  102. package/dist/types-CEqUFH2j.d.ts +129 -0
  103. package/dist/types-CuNiSnX6.d.ts +204 -0
  104. package/dist/types-DNrn_w4V.d.ts +88 -0
  105. package/dist/types-Dy6oR6Z2.d.ts +153 -0
  106. package/dist/types-pGBXevBU.d.ts +131 -0
  107. package/dist/useNodeOps-BF0fQsif.d.ts +68 -0
  108. package/dist/watch-DmasQA61.d.ts +60 -0
  109. package/dist/wordFit-9B9nqyf3.d.ts +35 -0
  110. package/dist/words-B2YLaKEr.d.ts +30 -0
  111. package/dist/words-B87GIFWH.d.ts +86 -0
  112. package/dist/words-BK-npta-.d.ts +27 -0
  113. package/dist/words-CD3WADyb.d.ts +51 -0
  114. package/dist/words-CsRWXkY4.d.ts +129 -0
  115. package/dist/words-D9ChvbNC.d.ts +52 -0
  116. package/dist/words-DOhF-4D4.d.ts +35 -0
  117. package/dist/words-DagJdwIA.d.ts +78 -0
  118. package/dist/words-DfxEp_RH.d.ts +142 -0
  119. package/package.json +202 -3
package/README.md CHANGED
@@ -1,6 +1,820 @@
1
1
  # efficient-easel
2
2
 
3
- Reserved by Efficient Pty Ltd for Efficient Easel, the EFFICIENT editors' engine,
4
- published as `efficient-editor` up to 0.95.0. This 0.0.0 release only holds the
5
- name; do not install it. Real releases under this name start at 0.96.0 and are
6
- published by CI.
3
+ **Efficient Easel** is the engine under every EFFICIENT editor. An easel holds
4
+ the canvas and has no say in what is painted on it: the engine never learns
5
+ what a section is, and each editor brings its own blocks and its own data.
6
+
7
+ The EFFICIENT editors' shared engine and chrome — the campaign designer
8
+ (`efficient-admin-portal`) and the website editor (`efficient-shop`) draw from
9
+ one source instead of hand-rolling and drifting — and the engine both run on,
10
+ which replaced Puck (`@measured/puck`) layer by layer and has carried none of
11
+ it since 0.93.0.
12
+
13
+ > **Published as `efficient-easel`** on public npm from 0.96.0 (it was
14
+ > `efficient-editor` from 0.69.0 to 0.95.0, and `editor-shell` up to 0.66.0).
15
+ > Released by CI from a GitHub Release — see [PUBLISHING.md](./PUBLISHING.md).
16
+ > Moving from the old name without touching an import:
17
+ > `"efficient-editor": "npm:efficient-easel@^0.96.0"`.
18
+
19
+ ## Why
20
+
21
+ The storefront editor (`efficient-shop`, Next 16 + Puck) and the campaign
22
+ designer (`efficient-admin-portal`, Vite/React) each hand-rolled their own narrow
23
+ left icon-rail. They had already drifted — button 34px vs 36px, icon 18px vs
24
+ 20px, radius 9 vs 8, a CSS-var accent vs a Tailwind one. `EditorRail` is the one
25
+ rail they converge on.
26
+
27
+ ## The leaf
28
+
29
+ The storefront's editor chrome renders in **Next-16 server components**, so the
30
+ rail is a **self-contained leaf** at `efficient-easel/rail`: it imports nothing heavy
31
+ and touches no `document` / `window` / `localStorage` at import or render, so a
32
+ server component can pull it in safely. `tests/rail-leaf-safety.test.tsx` proves
33
+ it.
34
+
35
+ ```tsx
36
+ // A client component in any editor:
37
+ 'use client';
38
+ import {
39
+ EditorRail,
40
+ PagesIcon, LayersIcon, StylesIcon, MediaIcon, SitemapIcon, AddIcon,
41
+ } from 'efficient-easel/rail';
42
+ import 'efficient-easel/rail.css'; // once, from a client/global entry
43
+
44
+ export function LeftRail({ view, onView, onAdd }) {
45
+ return (
46
+ <EditorRail
47
+ ariaLabel="Left panel views"
48
+ items={[
49
+ { id: 'pages', label: 'Pages', icon: <PagesIcon />, active: view === 'pages', onSelect: () => onView('pages') },
50
+ { id: 'layers', label: 'Layers', icon: <LayersIcon />, active: view === 'layers', onSelect: () => onView('layers') },
51
+ { id: 'styles', label: 'Styles', icon: <StylesIcon />, active: view === 'styles', onSelect: () => onView('styles') },
52
+ { id: 'media', label: 'Media', icon: <MediaIcon />, onSelect: onOpenMedia },
53
+ { id: 'add', label: 'Add page', icon: <AddIcon />, onSelect: onAdd },
54
+ ]}
55
+ />
56
+ );
57
+ }
58
+ ```
59
+
60
+ ## API
61
+
62
+ ### `<EditorRail items ariaLabel? className? orientation? />`
63
+
64
+ A **pure presentational** toolbar. It owns the look, never the app state — you
65
+ decide which item is `active` and what each `onSelect` does.
66
+
67
+ | prop | type | default | notes |
68
+ |---|---|---|---|
69
+ | `items` | `EditorRailItem[]` | — | buttons, in render order |
70
+ | `ariaLabel` | `string` | `"Editor"` | names the toolbar for assistive tech |
71
+ | `className` | `string` | — | merged onto `.es-rail` |
72
+ | `orientation` | `'column' \| 'row'` | `'column'` | which way the buttons run |
73
+
74
+ #### `orientation`
75
+
76
+ `'column'` is the rail every editor draws today: a fixed 46px strip wearing its
77
+ own card. It is the default, and it renders exactly what it rendered before this
78
+ prop existed.
79
+
80
+ `'row'` lays the same buttons left to right for a top bar — **no fixed width**
81
+ (as wide as its buttons) and **no card** (no background, radius or shadow),
82
+ because the bar already has its own surface. The button size, icon size, gap and
83
+ every hover/active colour are identical in both directions; only the container
84
+ changes. A row is 34px tall, so a 52px bar centres it with 9px to spare.
85
+
86
+ ```tsx
87
+ // The same rail, in the top bar's empty middle.
88
+ <EditorRail orientation="row" ariaLabel="Editor views" items={items} />
89
+ ```
90
+
91
+ `aria-orientation` follows the layout (`vertical` / `horizontal`); the `toolbar`
92
+ role does not change, because a rail that mixes view toggles with plain actions
93
+ is a toolbar whichever way it runs.
94
+
95
+ ### `EditorRailItem`
96
+
97
+ ```ts
98
+ interface EditorRailItem {
99
+ id: string; // React key + selection correlation
100
+ label: string; // aria-label + hover title
101
+ icon: React.ReactNode; // an 18px currentColor glyph — use the shipped icons
102
+ active?: boolean; // present ⇒ view toggle (renders aria-pressed); omit for actions
103
+ disabled?: boolean;
104
+ onSelect?: () => void;
105
+ }
106
+ ```
107
+
108
+ A **view item** passes `active: true | false` and renders as a toggle
109
+ (`aria-pressed`, `.is-active` highlight). An **action item** (the quick-add "+")
110
+ omits `active` and renders as a plain button.
111
+
112
+ `EditorRailButton` — the single-button building block `EditorRail` maps to — is
113
+ exported too, for bespoke layouts.
114
+
115
+ ## Tokens
116
+
117
+ The same values, two ways — `railTokens` (JS) and `rail.css` (CSS custom
118
+ properties on `.es-rail`). Taken verbatim from the storefront editor's `.sf-rail`
119
+ (the visual reference).
120
+
121
+ | token | value | CSS var |
122
+ |---|---|---|
123
+ | rail width | `46px` | `--es-rail-w` |
124
+ | rail padding (y) | `8px` | `--es-rail-pad-y` |
125
+ | button gap | `4px` | `--es-rail-gap` |
126
+ | rail radius | `12px` | `--es-rail-radius` |
127
+ | button size | `34px` | `--es-rail-btn-size` |
128
+ | button radius | `9px` | `--es-rail-btn-radius` |
129
+ | icon size | `18px` | `--es-rail-icon-size` |
130
+ | background | `#ffffff` | `--es-rail-bg` |
131
+ | idle icon | `#6b7280` | `--es-rail-fg` |
132
+ | hover bg / fg | `#f3f4f6` / `#111827` | `--es-rail-hover-bg` / `--es-rail-hover-fg` |
133
+ | accent (active) | `#2563eb` | `--es-rail-accent` |
134
+ | accent tint (active bg) | `#eff6ff` | `--es-rail-accent-tint` |
135
+
136
+ `orientation="row"` adds **no new token**. It re-points three of the ones above
137
+ — `--es-rail-bg` to `transparent`, `--es-rail-shadow` to `none`,
138
+ `--es-rail-radius` to `0` — so the card look stays declared in one place, and it
139
+ reuses `--es-rail-pad-y` rotated onto the row's ends and `--es-rail-gap` as-is.
140
+
141
+ Re-theme without new CSS by overriding a var on the element:
142
+ `style={{ ['--es-rail-accent']: brand }}`.
143
+
144
+ ## The gold layer — type in place
145
+
146
+ `GoldTextInput` (subpath `efficient-easel/gold`, added in 0.3.0) is the surface a
147
+ merchant types INTO on the page, with the markup formatting appearing as they
148
+ type. A textarea whose own text is transparent sits on top of a layer holding the
149
+ same characters, painted; what you read is the layer, what the caret walks is the
150
+ box. Both inherit their type from whatever the host renders them inside, so the
151
+ glyphs land on top of each other.
152
+
153
+ It is **controlled and writes nothing** — no document, no sections, no Puck. The
154
+ host owns the string and decides what a finished edit means.
155
+
156
+ ```tsx
157
+ 'use client';
158
+ import { GoldTextInput } from 'efficient-easel/gold';
159
+ import { STOREFRONT_MARKUP } from 'efficient-easel/markup';
160
+ import 'efficient-easel/gold.css'; // once, from a client/global entry
161
+
162
+ <GoldTextInput
163
+ value={draft}
164
+ rules={STOREFRONT_MARKUP}
165
+ placeholder="Add a heading"
166
+ ariaLabel="Section heading"
167
+ onInput={setDraft}
168
+ onCommit={(value) => save(value)} // blur, or Enter on a single-line field
169
+ onCancel={(restored) => setDraft(restored)}
170
+ />;
171
+ ```
172
+
173
+ | prop | type | default | notes |
174
+ |---|---|---|---|
175
+ | `value` | `string` | — | the authored string, delimiters and all |
176
+ | `onInput` | `(value: string) => void` | — | every keystroke; the layer repaints from what you send back |
177
+ | `onCommit` | `(value: string) => void` | — | blur, or Enter when `multiline` is false. Once per session, never if nothing was typed |
178
+ | `onCancel` | `(restored: string) => void` | — | Escape. The argument is the value held at focus time; the host puts it back |
179
+ | `multiline` | `boolean` | `false` | what Enter means, **not** how the text wraps — both modes wrap |
180
+ | `placeholder` | `string` | — | visible until the first keystroke |
181
+ | `className` | `string` | — | merged onto `.es-gold` |
182
+ | `rules` | `readonly InlineRule[]` | `STANDARD_MARKUP` | pass your product's set — `STOREFRONT_MARKUP` / `CAMPAIGN_MARKUP` |
183
+ | `ariaLabel` | `string` | — | in-place editing has no visible label |
184
+
185
+ **The grammar is ours, and there is one.** Runs are parsed by
186
+ `efficient-easel/markup`, which this package owns since 2026-09-28 (it started as
187
+ `efficient-shell/markup`). The published page and the campaign email render from
188
+ the same module, so a toolbar button, a page and this box can never disagree
189
+ about what a delimiter means. It is framework-free and imports nothing. See
190
+ [the text grammar](#the-text-grammar--efficient-easelmarkup) below.
191
+
192
+ **Two deliberate limits.** The asterisks are FADED, never removed. They go to
193
+ nothing while the box is unfocused, so a merchant who is not typing does not read
194
+ `**` in their own page text, and they come back dimmed while the caret is in the
195
+ box — but the characters stay in the layer at their full width in both states.
196
+ Actually removing them needs a second text engine, and is out of scope. And the
197
+ layer only paints what cannot move a glyph: colour, opacity, decoration, a
198
+ faux-bold shadow. A real `font-weight: 600` is wider, so a marked line would wrap
199
+ earlier than the transparent box on top of it and every following line would be
200
+ drawn over the wrong text. The real weight and slant appear on the page the
201
+ moment the merchant clicks away.
202
+
203
+ ## The text grammar — `efficient-easel/markup`
204
+
205
+ The ONE rule for copy a merchant types, in both editors, on the page and in the
206
+ email. Framework-free: no React, no DOM, no imports.
207
+
208
+ | Mark | Means | Written by a button |
209
+ |---|---|---|
210
+ | `**x**` | **Strong** (bold) | yes, ⌘B |
211
+ | `==x==` | **Emphasis** (the brand accent) | yes |
212
+ | `^^x^^` | **Quiet** | yes |
213
+ | `_x_` | italic, only at a word edge | yes |
214
+ | `~~x~~` | strikethrough | yes |
215
+ | `[words](href)` | a link — `http`, `https`, `mailto`, `tel` or no scheme only | yes |
216
+ | `- ` at the start of a line | a bullet line, one level | yes |
217
+ | `@@x@@`, `++x++` | Emphasis, Strong (the storefront's old word styles) | no — read only |
218
+ | `*x*`, `#x#`, `[x]` | legacy accent, bold, italic (per product set) | no — read only |
219
+
220
+ The three names a merchant sees are in `MARK_NAMES`. A run never crosses a line
221
+ break, and a run may hold one more run or link (`**bold _with italic_**`); a third
222
+ level stays as typed.
223
+
224
+ | Export | Notes |
225
+ |---|---|
226
+ | `parseMarkup(value, rules)` → `MarkupLine[]` | The whole grammar as a tree. Each run keeps the delimiters that were saved. |
227
+ | `serializeMarkup(lines)` | The inverse: `serializeMarkup(parseMarkup(s, rules)) === s`, for every string. Pinned by `tests/markup-round-trip.test.ts` (made-up strings in the shape of saved copy). All 917 saved production strings were checked once, off-repo, on 2026-09-29 (editor-shell #89). |
228
+ | `isSafeLinkHref(href)` | The link rule. Call it before writing any `href`. |
229
+ | `tokenizeInline(value, rules)`, `stripInline` | The flat reading older renderers use: runs only, no links, no nesting. |
230
+ | `applyMark(text, start, end, style)`, `MARKUP_TOOLS`, `markupTools`, `COPY_FIELD_TOOLS` | The writer behind a toolbar button, and the button descriptors. |
231
+ | `STANDARD_MARKUP`, `STOREFRONT_MARKUP`, `CAMPAIGN_MARKUP` | The rule sets. The last two add each product's legacy runs. |
232
+
233
+ ## The edit lock — two operators, one document
234
+
235
+ `efficient-easel/edit-lock` (added in 0.32.0) is the answer to "is the save path
236
+ allowed to run", shared by every editor in the house. It is pure TypeScript: no
237
+ React, no browser global, no dependency at all, so a server component may import
238
+ it.
239
+
240
+ It exists because the storefront editor and the campaign designer had written
241
+ the same rules twice, in two repos, and the two copies had already drifted — the
242
+ take-over half only existed on one side and the claim driver only on the other.
243
+ Both are here now, and neither editor keeps a copy.
244
+
245
+ ```ts
246
+ import { createEditLock, canWriteDocument, documentPermissions } from 'efficient-easel/edit-lock';
247
+
248
+ // The only two things that differ between one editor and the next.
249
+ export const pageEditLock = createEditLock({
250
+ readFailure: (err) =>
251
+ err instanceof ApiError ? { status: err.status, data: err.data } : null,
252
+ conflictCode: 'page_changed_elsewhere',
253
+ });
254
+ ```
255
+
256
+ The plain half needs no binding: `canWriteDocument`, `holderOf`,
257
+ `readLockHolder`, `accessForNewDocument`, `documentPermissions`, `describeAge`,
258
+ `nameOrSomeone`, `newEditorClientId`, `shouldReleaseHeldWrite`,
259
+ `shouldReloadAfterTakeover`, `documentVersionMoved`, `reloadIfVersionMoved`.
260
+ The bound half —
261
+ `accessAfterClaimFailure`, `accessAfterTakeoverFailure`,
262
+ `accessAfterHeartbeatFailure`, `isAuthRejection`, `readSaveConflict`,
263
+ `readLockTaken`, `makeClaimBeat` — comes back from `createEditLock`.
264
+
265
+ **A take-over stops the other tab at its next save** (0.77.0). A document write
266
+ that carries the tab's `client_id` (the one its claim sends) is refused with
267
+ `EDIT_LOCK_TAKEN` once somebody else has taken the document from that tab.
268
+ `readLockTaken` reads the holder out of that refusal: set `takenover` and HOLD
269
+ the edit, never retry it as a failed save. The other half is the taker's: when
270
+ a take-over is granted and nothing of theirs is waiting (`shouldReloadAfterTakeover`,
271
+ or `makeClaimBeat`'s optional `reloadIfMoved`), load the stored copy if it moved
272
+ while they could only look (`reloadIfVersionMoved`, which re-checks that nothing
273
+ was typed while the version was being read).
274
+
275
+ **Two things this module will not do.** It never takes the ability to edit away
276
+ because a courtesy call failed: every claim failure that is not a 409 naming a
277
+ holder falls back to `editing`, because the save-time version check is what
278
+ actually protects the work. And a failed TAKE-OVER is a different question from a
279
+ failed first claim — it keeps the state it was pressed from, so a take-over that
280
+ did not land never puts two people in one document with the holder untold.
281
+
282
+ ## The editor frame — top bar, panels, inspector head
283
+
284
+ `efficient-easel/chrome` (added in 0.41.0) is the frame both editors wear: the 52px
285
+ top bar and its three clusters, the icon-only device switch, the save pill, flat
286
+ side panels with drag grips, and the inspector head. Every part is controlled —
287
+ the editor owns the device, the save status, the widths and which panels are open.
288
+
289
+ ```tsx
290
+ import {
291
+ EditorTopBar, TopBarCluster, TopBarDivider, EditorIconButton, DeviceSwitch,
292
+ SaveState, PanelResizeHandle, InspectorHead, DEFAULT_PANEL_W, panelWidthVars,
293
+ } from 'efficient-easel/chrome';
294
+ import 'efficient-easel/chrome.css'; // once, after shell.css
295
+
296
+ <div className="es-editor" style={panelWidthVars(widths)}> {/* the vars go on the ROOT */}
297
+ <EditorTopBar>
298
+ <TopBarCluster kind="rail"><EditorRail orientation="row" items={items} /></TopBarCluster>
299
+ <TopBarCluster>
300
+ <EditorIconButton label="Undo" onClick={undo}><UndoIcon /></EditorIconButton>
301
+ <TopBarDivider />
302
+ <DeviceSwitch devices={devices} value={device} onChange={setDevice} />
303
+ </TopBarCluster>
304
+ <TopBarCluster kind="right">
305
+ <SaveState tone="saved" label="" />
306
+ <button className="es-btn-primary">Done</button>
307
+ </TopBarCluster>
308
+ </EditorTopBar>
309
+ …
310
+ </div>
311
+ ```
312
+
313
+ ## The Layers tree — one row at every depth
314
+
315
+ `efficient-easel/tree` (added in 0.42.0) is the Layers tree both editors draw. Every
316
+ row has the same shape; only `Subtree` says depth.
317
+
318
+ ```tsx
319
+ import { LayersTree, TreeGroupLabel, Subtree, TreeRow, TreeRowAction } from 'efficient-easel/tree';
320
+ import 'efficient-easel/tree.css';
321
+
322
+ <LayersTree>
323
+ <TreeGroupLabel>Body</TreeGroupLabel>
324
+ <TreeRow icon={<SectionGlyph />} name="Section" preview="FUSION FORGED · GTS-8"
325
+ selected fold={{ open, label: 'section', onToggle }}
326
+ actions={<TreeRowAction kind="eye" label="Hide Section" onClick={hide}><EyeIcon /></TreeRowAction>} />
327
+ <Subtree>
328
+ <TreeRow icon={<TextLinesIcon />} name="Text" preview="BACK IN STOCK NOW" />
329
+ </Subtree>
330
+ </LayersTree>
331
+ ```
332
+
333
+ ## Copy style, paste style — one rule for both editors
334
+
335
+ `efficient-easel/style-clipboard` (added in 0.50.0) is the right-click menu's
336
+ **Copy style** / **Paste style from …** pair (⌥⌘C / ⌥⌘V, Ctrl+Alt+C / Ctrl+Alt+V).
337
+ The host says which of a node's rows are Style or Layout and saves the new props
338
+ through its own op; everything else is here, once:
339
+
340
+ ```ts
341
+ import { copyStyle, heldStyle, planPaste, pasteMessage, styleMenuItems, styleShortcut, fieldKind, withStyleKind } from 'efficient-easel/style-clipboard';
342
+
343
+ copyStyle({ scope: `site:${siteId}`, type, label, rows, props }); // rows: { path, kind }[]
344
+ const held = heldStyle(`site:${siteId}`); // null in any other scope
345
+ const plan = planPaste(held, { type, rows, props }); // → { props, pasted, changed }
346
+ toast(pasteMessage(plan, held.sourceLabel, label)); // "Pasted 4 settings from Heading."
347
+ ```
348
+
349
+ A row pastes only when the **key and the kind** match (`fieldKind`: a tagged
350
+ factory's kind, `number`/`text`, or a choice row's control plus the values it
351
+ offers). Two **choice** rows of the same control are the same setting, and the
352
+ value decides: it pastes when the target offers it (0.51.0). A row whose kind is
353
+ unknown pastes only between two components of the same type. An empty
354
+ (inherited) value pastes as empty, so the target inherits too.
355
+
356
+ ## The document — `efficient-easel/data`
357
+
358
+ `efficient-easel/data` (added in 0.67.0) is the bottom layer of the engine that
359
+ replaces Puck (the blueprint's step 1.1): the saved document as pure
360
+ functions. It imports nothing and touches no browser global, so the editor's
361
+ store, a Next server component, a Node script and a test all call the same
362
+ code.
363
+
364
+ **The format does not change.** A document is exactly what Puck 0.20 saves —
365
+ `{ root: { props }, content: [...], zones: {} }`, with a block's children
366
+ inline in its slot props — so no campaign or page migrates when Puck goes.
367
+
368
+ ```ts
369
+ import { normalizeData, find, insert, move, remove, duplicate, setProps, setRoot, createNode, walk, ROOT_LIST } from 'efficient-easel/data';
370
+
371
+ const doc = normalizeData(saved, config); // what the editor does on open
372
+ const at = find(doc, config, id); // { node, place, path } | null
373
+ let next = insert(doc, config, { ...ROOT_LIST, index: 2 }, createNode(config, 'EmailSection'));
374
+ next = move(next, config, id, { parentId: sectionId, slot: 'col2', index: 0 });
375
+ next = duplicate(next, config, id, { strip: releaseBakedImage });
376
+ next = setProps(next, config, id, { level: 3 });
377
+ ```
378
+
379
+ | Export | Notes |
380
+ |---|---|
381
+ | `normalizeData(data, config)` | Puck's load: missing top-level slots become `[]`, `zones` becomes `{}`, the root's `defaultProps` fill what is missing. Older formats (props written on `root`, children parked in `zones`) are converted the way Puck's `migrate()` converts them. **A document already in shape comes back as the same object.** |
382
+ | `find`, `walk`, `slotsOf`, `getList` | Read. `walk` visits parent before children, slots in declaration order; returning a node from the visitor replaces it. |
383
+ | `insert`, `move`, `remove`, `duplicate` | Change the tree. `move` within one list takes the index AFTER the move (Puck's rule). |
384
+ | `setProps`, `setRoot` | Merge props. A key keeps its place; a patch that changes nothing returns the same document. |
385
+ | `createNode(config, type, props?)`, `newId(type)` | A new block the way the Add panel makes one: `{ ...defaultProps, id }`, ids `<Type>-<uuid>`. |
386
+ | `zoneKey`, `parseZoneKey`, `ROOT_LIST` | A place is `{ parentId, slot, index }`; the top-level list is `{ parentId: 'root', slot: 'default-zone' }` — the two halves of Puck's zone key. |
387
+
388
+ `config` is the app's block config: only `fields` (which props are slots, including
389
+ slots inside `object` / `array` fields) and `defaultProps` are read, so a Puck
390
+ `Config` works as it is. A type the config does not know is kept whole.
391
+
392
+ Every edit copies only the path to the change, so an untouched block is the
393
+ same object before and after. An edit that names a missing node or slot, an
394
+ index off the end, a move into the node's own subtree, or an insert that
395
+ would put a second copy of an id into the document throws a `DataError` with a
396
+ `code`.
397
+
398
+ Key order is not part of the saved format — the backend stores these in
399
+ Postgres `jsonb` — but an edited key keeps its place and a new one goes on the
400
+ end, so a save diffs as the lines that changed.
401
+
402
+ ### A page in more than one language — the translation overlay
403
+
404
+ Added in 0.97.0, in the same entry. A page is written once, in a base
405
+ language; each other language is an **overlay** the host stores beside the
406
+ document, never inside it:
407
+
408
+ ```ts
409
+ type TranslationOverlay = Record<string /* node id, or "root" */, Record<string /* prop path */, TranslatedValue>>;
410
+ interface TranslatedValue { v: unknown; src: string } // src: a fingerprint of the BASE value it translates
411
+ ```
412
+
413
+ **The document format is unchanged**, and a page with one language stores no
414
+ overlay: `applyTranslation(data, config)` returns the object it was given.
415
+ `Render` knows nothing of languages. It is handed a document that has already
416
+ been localised.
417
+
418
+ ```ts
419
+ import { applyTranslation, extractTranslation, translationStatus } from 'efficient-easel/data';
420
+
421
+ // Draw the page in German. A value with no entry is the base language's.
422
+ <Render config={config} data={applyTranslation(page, config, german)} />
423
+
424
+ // A translator typed over that page: what to save for German.
425
+ const next = extractTranslation(page, edited, config, german);
426
+
427
+ // How far German has got.
428
+ const { total, missing, stale } = translationStatus(page, config, next);
429
+ ```
430
+
431
+ **A block's fields say what is translated**, with `translatable`:
432
+
433
+ ```ts
434
+ fields: {
435
+ title: { type: 'text' }, // text and textarea: translated
436
+ slug: { type: 'text', translatable: false }, // … unless the field says not
437
+ heading: { type: 'custom', translatable: true, render }, // anything else: only when it says so
438
+ banner: { type: 'custom', translatable: true, render }, // a picture with words in it: one whole value
439
+ height: { type: 'number' }, // never, unless flagged
440
+ items: { type: 'array', arrayFields: { q: { type: 'text' }, href: { type: 'text', translatable: false } } },
441
+ content: { type: 'slot' }, // never: a child block has its own id
442
+ }
443
+ ```
444
+
445
+ An `object` or `array` field is a container: the fields inside it are read by
446
+ the same rule, and a flag on the container is the default for them. A path is
447
+ the prop's keys joined by dots, with an array index as a key: `title`,
448
+ `cta.label`, `items.2.q`. The root's props are translated under the id
449
+ `"root"`.
450
+
451
+ | Export | Notes |
452
+ |---|---|
453
+ | `applyTranslation(data, config, overlay?)` | The document in one language. No overlay (or an empty one): **the same object**. Otherwise only the nodes that took a value are copied. An entry for a node that is not there, or a path that is not words, is passed over. |
454
+ | `extractTranslation(base, edited, config, previous?)` | The overlay a language's edit comes to: an entry for every translatable value that differs from the base. A value equal to the base's makes no entry. Anything else that differs (a block added or moved, a number) is passed over. With `previous`, an entry the edit did not touch is kept as it was, old `src` and all. |
455
+ | `translationStatus(data, config, overlay?)` | `{ total, missing, stale }`, the two lists as `{ id, path }` in document order. A value the base leaves blank wants nothing. |
456
+ | `translatablePaths(node, config)` | `{ path, value }[]` for a node, or for the root (`data.root`). |
457
+ | `rebaseTranslation(previousBase, nextBase, config, overlay)` | Carries an overlay across an edit of the base that moved, added, copied or removed **array items**. |
458
+ | `mapIds(source, copy, config)`, `copyTranslation(overlay, ids)` | For `duplicate` and `copyNode`, which give every copied node a new id: old id → new id, then the copies' entries (only those; lay them over the overlay). |
459
+ | `pruneTranslation(data, config, overlay)` | Drops the entries of nodes that are gone and of paths that no longer exist. Nothing else drops anything. |
460
+ | `translationFingerprint(value)` | What `src` holds. |
461
+
462
+ **Stale.** `src` is the fingerprint of the base value a translation was made
463
+ from (`s<length>.<hash>` of a string; of anything else, its JSON with the keys
464
+ sorted). When the base value changes, the translation is stale:
465
+ `translationStatus` reports it, and it is **still drawn**, because an
466
+ out-of-date translation beats the wrong language. An emptied or removed base
467
+ value is a changed one.
468
+
469
+ **Arrays.** An array item has no id, and the format does not allow one, so its
470
+ entries are stored under its index (`items.2.q`). When the base value at that
471
+ index no longer has the entry's `src`, the entry is looked for among the other
472
+ items of the same array, in the same field, and drawn **there**: the item
473
+ moved. Found nowhere, it is not drawn (the item at that index may be another
474
+ item now) and is reported stale. Two items with the same base words can only be
475
+ told apart by position: an entry that still matches where it stands stays, and
476
+ a displaced one takes the first free match.
477
+
478
+ **What a host does when the base is edited:**
479
+
480
+ | Edit of the base | The host calls |
481
+ |---|---|
482
+ | `move` | nothing: ids do not change |
483
+ | `remove` | nothing. The entries stay, unread, so undoing the delete brings the translations back. `pruneTranslation` when they should go for good. |
484
+ | `duplicate`, `copyNode` | `mapIds(source, copy, config)`, then for each language `{ ...overlay, ...copyTranslation(overlay, ids) }` |
485
+ | `setProps`, `setRoot`, `replaceNode` | nothing: a changed value is stale |
486
+ | an array value changed (an item moved, added, copied, removed) | `rebaseTranslation(before, after, config, overlay)` for each language. It sees both sides, so stale entries travel with their item, a copied item starts with its original's translations, and the saved overlay has the right indices. It drops the entries of an item that was removed: keep the overlay in the same undo step as the base to get them back. |
487
+
488
+ A block type the config does not know keeps its entries for as long as the
489
+ block is in the document.
490
+
491
+ Not here yet: the editor's side (a language switch, the Inspector showing the
492
+ base words under a translation, markers on untranslated rows). Routing and
493
+ `<html lang>` are the host's.
494
+
495
+ ## Block writing — `efficient-easel/blocks`
496
+
497
+ `efficient-easel/blocks` (added in 0.92.0) holds the types a block is written
498
+ with: Puck 0.20's `Config`, `ComponentConfig`, `Fields`, `Field`,
499
+ `CustomField`, `ComponentData`, `Data`, `Slot` and `SlotComponent`, with the
500
+ same generics. An app that typed its blocks with Puck's imports them from here
501
+ instead, and no block changes. It is types only: nothing in it exists at
502
+ runtime. One key is the engine's own: `translatable` on a field (0.97.0),
503
+ which says whether the prop is written again per language (the translation
504
+ overlay, above). It is optional, so a field written for Puck is still a field.
505
+
506
+ ```ts
507
+ import type { Config, CustomField, Slot } from 'efficient-easel/blocks';
508
+
509
+ type HeroProps = { title: string; content: Slot };
510
+ const config: Config<{ Hero: HeroProps }> = {
511
+ components: {
512
+ Hero: {
513
+ fields: { title: { type: 'text' }, content: { type: 'slot' } },
514
+ render: ({ title, content: Content }) => (
515
+ <section><h1>{title}</h1><Content /></section>
516
+ ),
517
+ },
518
+ },
519
+ };
520
+ ```
521
+
522
+ ## The published renderer — `efficient-easel/render`
523
+
524
+ `efficient-easel/render` (added in 0.68.0) draws a saved document the way the
525
+ published site does — the drop-in for Puck's `<Render>` (the blueprint's step
526
+ 1.2). Same config, same document, **the same HTML**: the specs hold it to
527
+ Puck 0.20.2's markup, recorded from Puck's own `<Render>`, byte for byte.
528
+
529
+ ```tsx
530
+ import { Render } from 'efficient-easel/render';
531
+
532
+ <Render config={config} data={page} metadata={{ site }} />;
533
+ ```
534
+
535
+ It is a plain function: **no hook, no context, no `'use client'`**. It runs in a
536
+ server component, a client component or a script, and `Render(props)` called
537
+ directly returns the element tree. (Puck's reads the document from a context
538
+ and builds props in hooks, so it only runs as a client component.) Whether the
539
+ blocks themselves can run on the server is up to the blocks.
540
+
541
+ The block authoring shape is Puck's, so an app passes the config it has: a
542
+ block's `render` gets its props, `puck`, and each slot as a component —
543
+ `render: ({ content: Content }) => <Content className="grid" />` — which draws
544
+ `<div className style>` around its blocks. The top-level list is wrapped in one
545
+ plain `<div>`, and `puck` carries exactly what Puck's client `<Render>` gives at
546
+ each level. A type the config does not know draws nothing.
547
+
548
+ `className` (0.95.0) is an opt-in class for that top-level wrapper, for a host
549
+ whose stylesheet has to reach it: `<Render … className="page" />`. Left out,
550
+ the wrapper is bare and the markup is Puck's.
551
+
552
+ ## The canvas — `efficient-easel/canvas`
553
+
554
+ `efficient-easel/canvas` (added in 0.70.0, blueprint step 2.1) is the one way
555
+ into the editor's canvas iframe — `#preview-frame`, the engine's own
556
+ same-origin canvas frame (Puck's, until the editors left it). It replaces the
557
+ editors' own polling loops, host-head style injection and hand-written
558
+ coordinate maths.
559
+
560
+ ```tsx
561
+ import { useCanvasFrame, useCanvasEvent, useCanvasStyle, useCanvasLayout, frameRectToHost, isTypingTarget, nodeIdAt, BLOCK_SELECTOR } from 'efficient-easel/canvas';
562
+
563
+ const canvas = useCanvasFrame(editorRootRef, { readySelector: BLOCK_SELECTOR });
564
+ useCanvasStyle(canvas, 'hidden-blocks', hiddenBlockCss(ids)); // canvas document only
565
+ useCanvasEvent(canvas, 'contextmenu', (e) => open(nodeIdAt(e.target))); // follows a document swap
566
+ const tick = useCanvasLayout(canvas); // re-measure when this changes
567
+ ```
568
+
569
+ | Export | What it answers |
570
+ |---|---|
571
+ | `useCanvasFrame(rootRef, { selector?, readySelector? })` | `{ frame, document, window }` or `null`. Found by a MutationObserver and the frame's `load` event — never a timer. `readySelector` waits for the blocks to arrive in a loaded-but-empty document. |
572
+ | `useCanvasEvent(canvas, type, handler, { on?, capture? })` | A listener on the canvas document (or window) that moves to a replacement document and always calls the latest handler, without re-binding on every render. |
573
+ | `useCanvasStyle(canvas, key, css)` | A `<style>` in the canvas document's head only — re-injected into a replacement, put back if something empties the head (Puck's AutoFrame does, once), removed on unmount, never written to the host page. |
574
+ | `useCanvasLayout(canvas)` | A number that changes when the canvas scrolls, resizes, is replaced, or the host scrolls or resizes (plus two settle passes). |
575
+ | `frameRectToHost`, `framePointToHost`, `frameScale` | A box or point measured inside the frame, on the host page — through the frame's SCALE (a phone view drawn small). |
576
+ | `fitCanvas({ available, width, maxScale })` | The canvas width and the scale that fits it into the pane. |
577
+ | `isTypingTarget(target)` | Is this key pressed somewhere a person types (text inputs, textarea, select, editable regions)? A shortcut must stay out. |
578
+ | `nodeIdAt(target, attribute?)` | The block a pointer event landed on. |
579
+ | `watchCanvasFrame(root, onChange, options)` | The watcher the hook wraps, for code outside React. |
580
+ | `setCanvasNonce(document, nonce)` | The CSP nonce every `<style>` created in that canvas document carries (0.95.0). `CanvasFrame`'s `nonce` prop sets it; a host that builds its own frame calls it. |
581
+
582
+ ## Node operations — `efficient-easel/node-ops`
583
+
584
+ `efficient-easel/node-ops` (added in 0.71.0, blueprint step 2.2) is what can be
585
+ done TO a block, once for both editors: the operations, the right-click rows,
586
+ the clipboard, and the "up one level" answer.
587
+
588
+ ```tsx
589
+ import { useNodeOps, nodeMenuItems, createNodeClipboard, toItemSelector } from 'efficient-easel/node-ops';
590
+ import { InspectorUpLevel } from 'efficient-easel/chrome';
591
+
592
+ const clipboard = createNodeClipboard(); // once, at module scope
593
+
594
+ const ops = useNodeOps({
595
+ data, config, clipboard, readOnly,
596
+ commit: (next, { select }) => store.write(next, select), // ONE write per action
597
+ strip: releaseBakedImage, afterRemove: 'none', stepBounds: bodyBand,
598
+ });
599
+
600
+ const items = nodeMenuItems({
601
+ title: 'Heading',
602
+ actions: { duplicate: () => ops.duplicate(id), delete: () => ops.remove(id) /* … only what is allowed */ },
603
+ style: styleMenuItems({ … }),
604
+ });
605
+
606
+ const up = ops.parentOf(selection); // null → render nothing
607
+ {up && <InspectorUpLevel name={nameOf(up)} onSelect={() => select(up)} icon={<UpGlyph />} />}
608
+ ```
609
+
610
+ - **One action, one commit.** Every operation (remove, duplicate, move, step,
611
+ setProps, cut, paste) is planned whole on `efficient-easel/data` and handed
612
+ to `host.commit` ONCE, with the selection it leaves — so it is one undo step.
613
+ Two operations in one handler chain correctly: the second is planned on the
614
+ first's result.
615
+ - **Selection is by id** (or a slot, for the campaign designer's columns),
616
+ never by position. After a delete: nothing (default) or a neighbour — never
617
+ whatever slid into the gap. `toItemSelector` / `fromItemSelector` translate
618
+ to and from `itemSelector` (`{ zone, index }`, the shape the store's
619
+ `ui.itemSelector` reads in), and a stale selector reads as `null`, not a throw.
620
+ - **Rows are offered by presence.** `nodeMenuItems` fixes the order and groups
621
+ (`title | pick | make | style | change | remove`); the host passes the
622
+ actions it allows and nothing else. Labels, glyphs and shortcuts can be
623
+ overridden per row, so each editor keeps its own words.
624
+ - **Read-only** makes every write a no-op; Copy still works.
625
+
626
+ The plain functions (`planRemove`, `planDuplicate`, `planMove`, `planStep`,
627
+ `planPaste`, `planSetProps`, `canStep`, `parentStep`) are exported too, and
628
+ `efficient-easel/data` gains `copyNode` — a block with fresh ids, what a paste
629
+ inserts.
630
+
631
+ ## Autosave — `efficient-easel/autosave`
632
+
633
+ `efficient-easel/autosave` (added in 0.72.0, blueprint step 2.5) is how both
634
+ editors save: a second after the operator stops, silently when it works, and
635
+ without losing the edit when it does not.
636
+
637
+ ```tsx
638
+ import { useAutosave } from 'efficient-easel/autosave';
639
+ import { EditNotice, SaveState } from 'efficient-easel/chrome';
640
+
641
+ const autosave = useAutosave<Data>({
642
+ save: (doc) => api.saveDocument(id, doc, version), // read at call time
643
+ onFailure: (_err, attempt) => attempt === 0 && toast.error('Could not save — retrying'),
644
+ });
645
+
646
+ onChange={(doc) => canWrite && autosave.push(doc)}
647
+ <SaveState {...autosave.indicator} />
648
+ {autosave.state.failed && <button onClick={() => void autosave.retryNow()}>Retry now</button>}
649
+ <EditNotice access={access} conflict={conflict} noun="page"
650
+ onTakeOver={takeOver} onViewReadOnly={viewReadOnly} onReload={reload} />
651
+ ```
652
+
653
+ - **One save at a time, the newest edit.** A save that lands never clears an
654
+ edit made while it was out; that edit goes next.
655
+ - **1 s after the last edit, at least every 3 s while typing.** So "Unsaved
656
+ changes" — shown only once the oldest unsaved edit is 5 s old — never appears
657
+ on a healthy connection.
658
+ - **A failed save retries at 2 / 5 / 15 / 30 s, then every 30 s, for ever.**
659
+ New edits do not bring the retry forward; `retryNow()` does, and restarts the
660
+ backoff.
661
+ - **`pause()` holds, `resume()` catches up.** Paused, nothing is written on a
662
+ timer, on tab-away or on unmount; `flush()` still writes, because someone
663
+ asked.
664
+ - **`stop()` on a refused save.** Call it where your save reads the version
665
+ conflict, then rethrow: nothing more is written and the edit is kept, so the
666
+ close-tab warning still fires. `resume()` does not undo it; a reload does.
667
+ - **`reset()` on a document switch.** An editor that changes documents without
668
+ remounting drops the old document's pending edit, failure and refusal here
669
+ (after saving or discarding it); a save still out for the old document can
670
+ no longer change the new one's state.
671
+
672
+ `createAutosave` is the same engine without React, with the clock passed in —
673
+ the specs run minutes of debounce and backoff on a fake one.
674
+
675
+ `EditNotice` (in `efficient-easel/chrome`) is what to TELL the operator when the
676
+ save cannot go: a bar, never a dialog, with a refused save outranking the lock.
677
+
678
+ ## Drag-and-drop — `efficient-easel/drag`
679
+
680
+ `efficient-easel/drag` (added in 0.74.0, blueprint step 3.1) is the one drag
681
+ engine for both editors. The host keeps what is its own: whether a drop may
682
+ land (`canDrop`) and what it writes (`onDrop`, `onStep`).
683
+
684
+ ```tsx
685
+ import { useTreeDrag, gapToMoveIndex } from 'efficient-easel/drag';
686
+ import { LayersTree, TreeRow, TreeSeam } from 'efficient-easel/tree';
687
+
688
+ const drag = useTreeDrag<Item>({
689
+ keyOf: (item) => item.id,
690
+ canDrop: (item, to) => rules.canDrop(item, to), // refused: no line, no drop
691
+ onDrop: (item, to) => ops.move(item.id, { ...to, index: gapToMoveIndex(item.place, to) }),
692
+ onStep: (item, dir) => ops.step(item.id, dir), // ⌥↑ / ⌥↓
693
+ canStep: (item, dir) => ops.canStep(item.id, dir),
694
+ });
695
+
696
+ <LayersTree rootProps={drag.rootProps()}>
697
+ <TreeSeam {...drag.seam({ place: { ...list, index: 0 } })} onAdd={() => openAdd(list, 0)} label="Add block here" />
698
+ <TreeRow {...drag.row({ item, place, into: containerPlace, pinned })} icon={…} name={…} onSelect={…} />
699
+ ```
700
+
701
+ - **The whole row is picked up**, and the drag picture is the row, raised.
702
+ - **One hit rule:**
703
+ - a seam (the gap) is "between";
704
+ - a container's whole row is "into", at the place you name (its start or
705
+ end is your call);
706
+ - an ordinary row splits in half;
707
+ - a `pinned: 'first' | 'last'` row never moves, and a drop on it lands on its
708
+ inner side.
709
+ - **One drop line**, in the middle of the gap, wherever the pointer is.
710
+ - **⌥↑ / ⌥↓** step a row. The list **scrolls at its edges**.
711
+ - **A seam is the gap:** seams in every gap of a list, or in none. The list's
712
+ spacing does not change either way.
713
+
714
+ **The canvas and the Add list** (0.75.0, step 3.2) join the same drag through a
715
+ shared session, so a thing picked up on one surface can land on another:
716
+
717
+ ```tsx
718
+ const session = useMemo(() => createDragSession<Item>(), []);
719
+ const drag = useTreeDrag({ …, session }); // the tree
720
+
721
+ <div style={{ position: 'relative' }}> // holds the canvas frame
722
+ <CanvasFrame … />
723
+ <CanvasDragLayer canvas={canvas} session={session} model={toModel}
724
+ canDrop={canDrop} onDrop={onDrop} onSeamAdd={openAddAt} onZoneAdd={openAddAt} />
725
+ </div>
726
+
727
+ <AddPanel groups={blocks} session={session} itemFor={(type) => ({ kind: 'new', type })}
728
+ onInsert={insertAtOpenPlace} at={openPlace && { label: 'Adding to Column 2', onClear }} />
729
+ ```
730
+
731
+ - **The canvas measures itself:** `measureCanvas` reads the canvas's lists and
732
+ blocks. Your `model` says what may be picked up (leave out what cannot move)
733
+ and where "+ Add section" goes (`seamsBetween`).
734
+ - **One handle,** on the smallest box under the pointer, never unmounted
735
+ mid-drag.
736
+ - **The line** runs across a list, or down between columns (a slot laid out
737
+ across).
738
+ - **An empty slot** is a dashed "+ Add block" cell that also takes drops.
739
+ - Styles: `import 'efficient-easel/drag.css'`.
740
+
741
+ ## The engine's words — `efficient-easel/strings`
742
+
743
+ `efficient-easel/strings` (added in 0.95.0) is every word the engine itself
744
+ draws or returns — visible text, `aria-label`s, tooltips, placeholders — as one
745
+ typed catalogue, so a host in another language can translate it. A host that
746
+ does nothing sees the English it always saw.
747
+
748
+ ```tsx
749
+ import { EditorStringsProvider, useEditorStrings, type DeepPartial, type EditorStrings } from 'efficient-easel/strings';
750
+
751
+ const german: DeepPartial<EditorStrings> = {
752
+ save: { failed: 'Speichern fehlgeschlagen' },
753
+ lock: { minutesAgo: ({ count }) => `vor ${count} Min.` }, // a value or a plural: a function
754
+ };
755
+
756
+ <EditorStringsProvider value={german} locale="de">
757
+ <Editor />
758
+ </EditorStringsProvider>;
759
+ ```
760
+
761
+ - **`EditorStrings`** is grouped by the module that says the word (`notice`,
762
+ `lock`, `save`, `chrome`, `rail`, `tree`, `panel`, `fields`, `pick`, `drag`,
763
+ `menu`, `styleClipboard`, `copy`, `canvas`). A plain word is a `string`;
764
+ anything with a value in it is a function, so the host places the value and
765
+ does its own plural. `DEFAULT_EDITOR_STRINGS` is the English.
766
+ - **What is left out falls back** (`mergeEditorStrings`), and a provider inside
767
+ another lays its words over the outer one's.
768
+ - **The provider reaches the parts that have hooks:** the edit notice, the device
769
+ switch, "up one level", the panel grip and the top-bar rail; the copy box, its
770
+ bar and its link window; `Inspector`, the number box, the dropdown and the list
771
+ field; the canvas frame's title.
772
+ - **The leaves are handed their words.** The rail, the settings-panel
773
+ primitives and the Layers tree are hook-free; the pickers, the Add panel and
774
+ the canvas layer may import only their own folder. None of them reads the
775
+ provider. Each takes its group as a prop, and the plain functions as an
776
+ argument:
777
+
778
+ ```tsx
779
+ const strings = useEditorStrings();
780
+
781
+ <EditorRail items={items} ariaLabel={strings.rail.label} />
782
+ <TreeRow … words={strings.tree} />
783
+ <ColourPicker … words={strings.pick.colour} />
784
+ <ImageBox … words={strings.pick.image} />
785
+ <AddPanel … words={strings.drag.add} />
786
+ <CanvasDragLayer … words={strings.drag.canvas} />
787
+
788
+ editNotice(access, conflict, noun, strings);
789
+ saveIndicator(snapshot, strings.save);
790
+ nodeMenuItems({ …, words: strings.menu });
791
+ styleMenuItems({ …, words: strings.styleClipboard });
792
+ pasteMessage(plan, source, target, strings.styleClipboard);
793
+ ```
794
+ - **`useEditorLocale()`** is the provider's `locale` (`'en'` with none), for a
795
+ host's own blocks and fields. The engine does not read it.
796
+ - **A server file may import it.** Importing the entry runs nothing — the React
797
+ context is made on first use. `efficient-easel/strings/catalogue` is the
798
+ catalogue, its types and `mergeEditorStrings` with no React in its graph at
799
+ all: what a host's own test imports to check its languages against the
800
+ engine's keys.
801
+
802
+ What a host supplies was never the engine's and is not in the catalogue: a
803
+ block's name, a field's label, a tile's caption, the document's `noun`.
804
+
805
+ ## Develop
806
+
807
+ Node is native on the dev Mac (v26.8.1) — run these in your own worktree; a
808
+ shared checkout's node_modules are linux-only. Docker stays as the fallback:
809
+
810
+ ```bash
811
+ docker run --rm -v "$PWD":/w -w /w node:26-slim \
812
+ sh -lc "npm install && npm run typecheck && npm test && npm run build"
813
+ ```
814
+
815
+ - `npm run typecheck` — `tsc` over `src` and the specs.
816
+ - `npm test` — the leaf-safety, gold-layer and rail-shape gates. The gold-layer
817
+ spec is the one that needs a DOM (focus, keys, blur); it stands up a single
818
+ jsdom window in `tests/_dom-env.ts` and runs in its own process.
819
+ - `npm run build` — `tsup` → `dist/` (root + the `rail`, `shell` and `gold`
820
+ subpaths) + the copied CSS.