efficient-easel 0.99.2 → 0.101.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 (108) hide show
  1. package/README.md +233 -17
  2. package/dist/autosave/index.d.ts +11 -5
  3. package/dist/blocks/index.d.ts +1 -1
  4. package/dist/canvas/index.d.ts +2 -2
  5. package/dist/canvas/index.js +1 -1
  6. package/dist/chrome/chrome.css +96 -29
  7. package/dist/chrome/index.d.ts +67 -18
  8. package/dist/chrome/index.js +25 -21
  9. package/dist/{chunk-JYPOOHII.js → chunk-2FZJWH24.js} +3 -3
  10. package/dist/chunk-3EDSDPIP.js +1 -0
  11. package/dist/chunk-546BBOPE.js +1 -0
  12. package/dist/chunk-67GNT6PF.js +1 -0
  13. package/dist/chunk-EOJNJCMJ.js +16 -0
  14. package/dist/chunk-EWVJSRRY.js +6 -0
  15. package/dist/chunk-FPOKPNO4.js +1 -0
  16. package/dist/chunk-H24N5TMZ.js +11 -0
  17. package/dist/chunk-IG3U5NWJ.js +1 -0
  18. package/dist/chunk-JUQ6YJCG.js +1 -0
  19. package/dist/chunk-KTVH2ZRL.js +15 -0
  20. package/dist/{chunk-PI26YXYD.js → chunk-L5KW4HG5.js} +16 -8
  21. package/dist/chunk-M6F6LS3H.js +1 -0
  22. package/dist/chunk-P6O363EE.js +6 -0
  23. package/dist/chunk-PAELDYYS.js +1 -0
  24. package/dist/chunk-QTLAVDJE.js +1 -0
  25. package/dist/chunk-UH7IGZKF.js +1 -0
  26. package/dist/chunk-WZ7TGAAT.js +3 -0
  27. package/dist/chunk-ZFDCDJNZ.js +16 -0
  28. package/dist/chunk-ZJEBF4NB.js +1 -0
  29. package/dist/chunk-ZNS7727Y.js +12 -0
  30. package/dist/copy/copy.css +442 -5
  31. package/dist/copy/index.d.ts +302 -5
  32. package/dist/copy/index.js +32 -10
  33. package/dist/drag/drag.css +10 -1
  34. package/dist/drag/index.d.ts +99 -8
  35. package/dist/drag/index.js +18 -17
  36. package/dist/easel/easel.css +15 -0
  37. package/dist/easel/index.d.ts +104 -33
  38. package/dist/easel/index.js +9 -1
  39. package/dist/edit-lock/index.d.ts +1 -3
  40. package/dist/edit-lock/index.js +1 -1
  41. package/dist/fields/fields.css +270 -38
  42. package/dist/fields/index.d.ts +209 -26
  43. package/dist/fields/index.js +22 -53
  44. package/dist/gold/index.js +1 -1
  45. package/dist/icons/index.d.ts +13 -1
  46. package/dist/icons/index.js +1 -1
  47. package/dist/index.d.ts +2 -2
  48. package/dist/index.js +1 -1
  49. package/dist/markup/index.d.ts +111 -10
  50. package/dist/markup/index.js +1 -1
  51. package/dist/menu/index.d.ts +58 -5
  52. package/dist/menu/index.js +1 -15
  53. package/dist/menu/menu.css +17 -6
  54. package/dist/node-ops/index.d.ts +1 -1
  55. package/dist/node-ops/index.js +1 -1
  56. package/dist/panel/index.d.ts +83 -4
  57. package/dist/panel/index.js +18 -16
  58. package/dist/panel/panel.css +135 -62
  59. package/dist/pick/index.d.ts +78 -2
  60. package/dist/pick/index.js +7 -14
  61. package/dist/pick/pick.css +197 -10
  62. package/dist/rail/index.d.ts +3 -7
  63. package/dist/rail/index.js +1 -1
  64. package/dist/strings/catalogue.d.ts +13 -35
  65. package/dist/strings/catalogue.js +1 -1
  66. package/dist/strings/index.d.ts +12 -18
  67. package/dist/strings/index.js +2 -2
  68. package/dist/style-clipboard/index.d.ts +1 -1
  69. package/dist/style-clipboard/index.js +1 -1
  70. package/dist/text-styles/index.d.ts +328 -0
  71. package/dist/text-styles/index.js +38 -0
  72. package/dist/text-styles/text-styles.css +309 -0
  73. package/dist/tree/index.d.ts +43 -2
  74. package/dist/tree/index.js +1 -1
  75. package/dist/tree/tree.css +71 -0
  76. package/dist/{types-p9j2Fac7.d.ts → types-DGcCsxs8.d.ts} +72 -7
  77. package/dist/{types-Bs_gVkVI.d.ts → types-DWS-s9z-.d.ts} +1 -9
  78. package/dist/{types-DO-b0LNj.d.ts → types-Djdq6Xkd.d.ts} +9 -8
  79. package/dist/{types-BOSGNvil.d.ts → types-E3HrTPg2.d.ts} +7 -13
  80. package/dist/{types-C7HFvdtk.d.ts → types-VqL6t5HH.d.ts} +82 -5
  81. package/dist/{watch-B5RTR-WX.d.ts → watch-BTYOJpQU.d.ts} +18 -1
  82. package/dist/{words-DagJdwIA.d.ts → words-AHHfiaRY.d.ts} +31 -2
  83. package/dist/words-BGrHZAJJ.d.ts +46 -0
  84. package/dist/{words-c-3h9xQQ.d.ts → words-BMzV04b1.d.ts} +1 -1
  85. package/dist/words-CRYvAiHX.d.ts +105 -0
  86. package/dist/words-D8kf071T.d.ts +93 -0
  87. package/dist/{words-N4ebT88_.d.ts → words-DgHcY5qB.d.ts} +9 -3
  88. package/dist/{words-C74EUpFr.d.ts → words-XH0TvZPg.d.ts} +60 -4
  89. package/package.json +8 -8
  90. package/dist/chunk-66CKSTXP.js +0 -14
  91. package/dist/chunk-672FAEKU.js +0 -1
  92. package/dist/chunk-67VZRQLG.js +0 -13
  93. package/dist/chunk-6PS6STGA.js +0 -1
  94. package/dist/chunk-7A7O2JWQ.js +0 -1
  95. package/dist/chunk-7JTL3P6P.js +0 -1
  96. package/dist/chunk-F2PIG3AA.js +0 -10
  97. package/dist/chunk-H4B72XCJ.js +0 -1
  98. package/dist/chunk-KDIVA6WD.js +0 -1
  99. package/dist/chunk-MCLBHENY.js +0 -1
  100. package/dist/chunk-QB3BZIRA.js +0 -5
  101. package/dist/chunk-SJQG5ENJ.js +0 -1
  102. package/dist/chunk-SUO5627P.js +0 -1
  103. package/dist/chunk-XUE5O62R.js +0 -3
  104. package/dist/chunk-ZT7M24XD.js +0 -1
  105. package/dist/editor/index.d.ts +0 -10
  106. package/dist/editor/index.js +0 -1
  107. package/dist/words-CD3WADyb.d.ts +0 -51
  108. /package/dist/{chunk-2PHD4L6A.js → chunk-OWBO4WKA.js} +0 -0
package/README.md CHANGED
@@ -216,7 +216,7 @@ email. Framework-free: no React, no DOM, no imports.
216
216
  | `- ` at the start of a line | a bullet line, one level | yes |
217
217
  | `1. ` at the start of a line | a numbered line, one level: 1-3 digits, `. `, words; the number is kept as typed | yes |
218
218
  | `[x]{underline}`, `[x]{sup}`, `[x]{sub}` | underline, superscript, subscript (tree only) | not yet — `applyMark` writes them; the buttons come with the final text box |
219
- | `[x]{color=name}` | a theme colour, by name only (`isColorName`; never a hex) (tree only) | not yet — `applyColor` writes it |
219
+ | `[x]{color=name}` / `[x]{color=#rrggbb}` | a theme colour by name (`isColorName`), or any colour as exactly six hex digits (`isColorValue`; saved lowercase) (tree only) | not yet — `applyColor` writes it |
220
220
  | `@@x@@`, `++x++` | Emphasis, Strong (the storefront's old word styles) | no — read only |
221
221
  | `*x*`, `#x#`, `[x]` | legacy accent, bold, italic (per product set) | no — read only |
222
222
 
@@ -235,7 +235,7 @@ not learned them prints the delimiters.
235
235
  | `isSafeLinkHref(href)` | The link rule. Call it before writing any `href`. |
236
236
  | `tokenizeInline(value, rules)`, `stripInline` | The flat reading older renderers use: runs only, no links, no nesting. |
237
237
  | `applyMark(text, start, end, style)`, `MARKUP_TOOLS`, `markupTools`, `COPY_FIELD_TOOLS` | The writer behind a toolbar button, and the button descriptors. |
238
- | `applyColor(text, start, end, name)`, `isColorName`, `spanFor` | A colour carries a name, so it has its own writer: it writes, renames and (`null`) takes off `[x]{color=name}`, and changes nothing for a name `isColorName` refuses. |
238
+ | `applyColor(text, start, end, name)`, `isColorName`, `isColorValue`, `spanFor` | A colour carries a value, so it has its own writer: it writes, changes and (`null`) takes off `[x]{color=name}` — a theme name or a 6-digit hex, saved lowercase — and changes nothing for a value `isColorValue` refuses (`#f00`, `rgb()`, a typo). |
239
239
  | `STANDARD_MARKUP`, `STOREFRONT_MARKUP`, `CAMPAIGN_MARKUP` | The rule sets. The last two add each product's legacy runs. |
240
240
 
241
241
  ## The edit lock — two operators, one document
@@ -297,7 +297,7 @@ the editor owns the device, the save status, the widths and which panels are ope
297
297
  ```tsx
298
298
  import {
299
299
  EaselTopBar, TopBarCluster, TopBarDivider, EaselIconButton, DeviceSwitch,
300
- SaveState, PanelResizeHandle, InspectorHead, DEFAULT_PANEL_W, panelWidthVars,
300
+ SaveState, PanelResizeHandle, InspectorHead, DEFAULT_PANEL_W, panelWidthVars, setPanelWidthVars,
301
301
  } from 'efficient-easel/chrome';
302
302
  import 'efficient-easel/chrome.css'; // once, after shell.css
303
303
 
@@ -318,13 +318,24 @@ import 'efficient-easel/chrome.css'; // once, after shell.css
318
318
  </div>
319
319
  ```
320
320
 
321
+ A server renders that `style` prop as a `style=""` attribute. Under a
322
+ Content-Security-Policy with no `'unsafe-inline'` for styles the browser refuses
323
+ it, so a **server-rendered** editor writes the widths through the CSSOM instead:
324
+
325
+ ```tsx
326
+ <div className="es-editor" ref={(el) => { if (el) setPanelWidthVars(el, widths); }}>
327
+ ```
328
+
329
+ Until that runs, chrome.css draws the panels at `DEFAULT_PANEL_W`. See
330
+ [Server rendering and a strict Content-Security-Policy](#server-rendering-and-a-strict-content-security-policy).
331
+
321
332
  ## The Layers tree — one row at every depth
322
333
 
323
334
  `efficient-easel/tree` (added in 0.42.0) is the Layers tree both editors draw. Every
324
335
  row has the same shape; only `Subtree` says depth.
325
336
 
326
337
  ```tsx
327
- import { LayersTree, TreeGroupLabel, Subtree, TreeRow, TreeRowAction } from 'efficient-easel/tree';
338
+ import { LayersTree, TreeGroupLabel, Subtree, TreeRow, TreeRowAction, TreeEmptySlot } from 'efficient-easel/tree';
328
339
  import 'efficient-easel/tree.css';
329
340
 
330
341
  <LayersTree>
@@ -335,9 +346,17 @@ import 'efficient-easel/tree.css';
335
346
  <Subtree>
336
347
  <TreeRow icon={<TextLinesIcon />} name="Text" preview="BACK IN STOCK NOW" />
337
348
  </Subtree>
349
+ <Subtree>
350
+ <TreeEmptySlot onAdd={addToColumn} /> {/* a list with nothing in it */}
351
+ </Subtree>
338
352
  </LayersTree>
339
353
  ```
340
354
 
355
+ `TreeEmptySlot` is an **empty list as a place**: a dashed box the size of a row
356
+ that says "Add a block", takes a drop and says "Drop here" while one may land.
357
+ With no `onAdd` (a read-only tree) it says "No blocks yet". The three words are
358
+ `tree.addBlock`, `tree.dropHere` and `tree.emptySlot` in the strings catalogue.
359
+
341
360
  ## Copy style, paste style — one rule for both editors
342
361
 
343
362
  `efficient-easel/style-clipboard` (added in 0.50.0) is the right-click menu's
@@ -566,9 +585,9 @@ editors' own polling loops, host-head style injection and hand-written
566
585
  coordinate maths.
567
586
 
568
587
  ```tsx
569
- import { useCanvasFrame, useCanvasEvent, useCanvasStyle, useCanvasLayout, frameRectToHost, isTypingTarget, nodeIdAt, BLOCK_SELECTOR } from 'efficient-easel/canvas';
588
+ import { useCanvasFrame, useCanvasEvent, useCanvasStyle, useCanvasLayout, frameRectToHost, isTypingTarget, nodeIdAt, LIST_SELECTOR } from 'efficient-easel/canvas';
570
589
 
571
- const canvas = useCanvasFrame(editorRootRef, { readySelector: BLOCK_SELECTOR });
590
+ const canvas = useCanvasFrame(editorRootRef, { readySelector: LIST_SELECTOR }); // see the note below
572
591
  useCanvasStyle(canvas, 'hidden-blocks', hiddenBlockCss(ids)); // canvas document only
573
592
  useCanvasEvent(canvas, 'contextmenu', (e) => open(nodeIdAt(e.target))); // follows a document swap
574
593
  const tick = useCanvasLayout(canvas); // re-measure when this changes
@@ -576,7 +595,7 @@ const tick = useCanvasLayout(canvas); // re-m
576
595
 
577
596
  | Export | What it answers |
578
597
  |---|---|
579
- | `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. |
598
+ | `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 canvas to be drawn in a loaded-but-empty document. |
580
599
  | `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. |
581
600
  | `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. |
582
601
  | `useCanvasLayout(canvas)` | A number that changes when the canvas scrolls, resizes, is replaced, or the host scrolls or resizes (plus two settle passes). |
@@ -587,6 +606,13 @@ const tick = useCanvasLayout(canvas); // re-m
587
606
  | `watchCanvasFrame(root, onChange, options)` | The watcher the hook wraps, for code outside React. |
588
607
  | `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. |
589
608
 
609
+ ⚠️ **Wait for a list, not for a block.** `LIST_SELECTOR` matches every list
610
+ the canvas draws, and the page's own is there with nothing in it.
611
+ `readySelector: BLOCK_SELECTOR` means "ready once the canvas holds a block", so
612
+ the canvas of a page with **no blocks** is never published: the drag layer,
613
+ canvas stylesheets and context menus do nothing on the one page a first block
614
+ has to be dropped on. `BLOCK_SELECTOR` is for a canvas that is never empty.
615
+
590
616
  ## Node operations — `efficient-easel/node-ops`
591
617
 
592
618
  `efficient-easel/node-ops` (added in 0.71.0, blueprint step 2.2) is what can be
@@ -612,7 +638,7 @@ const items = nodeMenuItems({
612
638
  });
613
639
 
614
640
  const up = ops.parentOf(selection); // null → render nothing
615
- {up && <InspectorUpLevel name={nameOf(up)} onSelect={() => select(up)} icon={<UpGlyph />} />}
641
+ {up && <InspectorUpLevel name={nameOf(up)} onSelect={() => select(up)} />} // ← Section
616
642
  ```
617
643
 
618
644
  - **One action, one commit.** Every operation (remove, duplicate, move, step,
@@ -677,6 +703,14 @@ onChange={(doc) => canWrite && autosave.push(doc)}
677
703
  (after saving or discarding it); a save still out for the old document can
678
704
  no longer change the new one's state.
679
705
 
706
+ - **Feed it from `createEasel({ onChange })`, not from `easel.subscribe`.**
707
+ `onChange` is called for a change to the document and never for `load()`. A
708
+ subscriber is also told of a load, a selection and a permission, and pushing
709
+ each of those saves a document nobody edited.
710
+ - **`autosave.indicator` is always English.** Autosave does not read the
711
+ strings provider. In another language, build the pill from the state:
712
+ `<SaveState {...saveIndicator(autosave.state, useEaselStrings().save)} />`.
713
+
680
714
  `createAutosave` is the same engine without React, with the clock passed in —
681
715
  the specs run minutes of debounce and backoff on a fake one.
682
716
 
@@ -691,7 +725,7 @@ land (`canDrop`) and what it writes (`onDrop`, `onStep`).
691
725
 
692
726
  ```tsx
693
727
  import { useTreeDrag, gapToMoveIndex } from 'efficient-easel/drag';
694
- import { LayersTree, TreeRow, TreeSeam } from 'efficient-easel/tree';
728
+ import { LayersTree, Subtree, TreeEmptySlot, TreeRow, TreeSeam } from 'efficient-easel/tree';
695
729
 
696
730
  const drag = useTreeDrag<Item>({
697
731
  keyOf: (item) => item.id,
@@ -704,6 +738,9 @@ const drag = useTreeDrag<Item>({
704
738
  <LayersTree rootProps={drag.rootProps()}>
705
739
  <TreeSeam {...drag.seam({ place: { ...list, index: 0 } })} onAdd={() => openAdd(list, 0)} label="Add block here" />
706
740
  <TreeRow {...drag.row({ item, place, into: containerPlace, pinned })} icon={…} name={…} onSelect={…} />
741
+ <Subtree> {/* a list with nothing in it: not a seam */}
742
+ <TreeEmptySlot {...drag.seam({ place: { ...column, index: 0 } })} onAdd={() => openAdd(column, 0)} />
743
+ </Subtree>
707
744
  ```
708
745
 
709
746
  - **The whole row is picked up**, and the drag picture is the row, raised.
@@ -718,6 +755,17 @@ const drag = useTreeDrag<Item>({
718
755
  - **⌥↑ / ⌥↓** step a row. The list **scrolls at its edges**.
719
756
  - **A seam is the gap:** seams in every gap of a list, or in none. The list's
720
757
  spacing does not change either way.
758
+ - **An empty list is not a seam.** A seam hands its height back to the rows
759
+ either side of it; alone in a list it is a strip a few pixels tall, and a
760
+ drop aimed at "into this empty column" lands on whatever is really under the
761
+ pointer. Draw `TreeEmptySlot` (`efficient-easel/tree`) there, with the same
762
+ `drag.seam()`.
763
+ - **A target names an effect its source allowed.** A row or a block picked up
764
+ is a move; a tile from the Add list is a copy (`effectAllowed = 'copy'`), and
765
+ a browser refuses a drop whose `dropEffect` the source did not allow: the
766
+ line is drawn and `drop` never fires. The tree and the canvas both answer
767
+ with `dropEffectFor(e.dataTransfer.effectAllowed)`; a drop target of a
768
+ host's own in the same session should too.
721
769
 
722
770
  **The canvas and the Add list** (0.75.0, step 3.2) join the same drag through a
723
771
  shared session, so a thing picked up on one surface can land on another:
@@ -740,12 +788,172 @@ const drag = useTreeDrag({ …, session }); // the tree
740
788
  blocks. Your `model` says what may be picked up (leave out what cannot move)
741
789
  and where "+ Add section" goes (`seamsBetween`).
742
790
  - **One handle,** on the smallest box under the pointer, never unmounted
743
- mid-drag.
791
+ mid-drag. It follows the CONTENT, not the last mouse move: it is picked
792
+ again whenever the canvas is measured again (a wheel in a frame that scrolls
793
+ itself, a block added above) and when a drag ends, so a host has no reason
794
+ to nudge it with a mouse move of its own.
744
795
  - **The line** runs across a list, or down between columns (a slot laid out
745
796
  across).
746
797
  - **An empty slot** is a dashed "+ Add block" cell that also takes drops.
798
+ - **A drop the innermost list refuses falls through to the list outside it.**
799
+ A section held over a column lands between sections, and the line is drawn
800
+ where it will land before it is let go. Without that a page covered by its
801
+ inner lists could take a section only on the few pixels between sections.
802
+ A host for whom a refusal is final says so per drop:
803
+ `fallThrough={(item, refused, from) => …}` is asked only about a place
804
+ `canDrop` has just refused, and `false` ends it there: no line, nothing
805
+ lands. For example, a Columns block over a column, or a block about to
806
+ leave the container it was picked up in (`from`). Left out, every refusal
807
+ falls through. A drop back where the thing already is never falls through.
808
+ - **The canvas scrolls at its edges,** like the tree: the host's pane around a
809
+ frame as tall as its content, and the frame's own document when the frame
810
+ fills its pane and scrolls itself (`EaselCanvas height="fill"`).
811
+ - **One tile can be off:** `AddTile.disabled`, with `disabledReason` as its
812
+ tooltip ("A page has one header"). The tile stays in the list, dimmed, is
813
+ still found by search and counted; a click adds nothing and a drag does not
814
+ start. It is `aria-disabled`, so it keeps the focus and its reason can be
815
+ reached. `readOnly` is still the whole panel's.
747
816
  - Styles: `import 'efficient-easel/drag.css'`.
748
817
 
818
+ ## The store, its keys and its permissions — `efficient-easel/easel`
819
+
820
+ ```tsx
821
+ import { createEasel, EaselProvider, EaselCanvas, useSelectedFields } from 'efficient-easel/easel';
822
+ import 'efficient-easel/easel.css';
823
+
824
+ const easel = createEasel<EaselConfig>({ config, data, onChange: (doc) => autosave.push(doc) });
825
+
826
+ <EaselProvider easel={easel} hotkeys={ownsThePage}>
827
+ <EaselCanvas height="fill" />
828
+ </EaselProvider>
829
+ ```
830
+
831
+ - **Say the type: `createEasel<EaselConfig>(…)`.** `createEasel` infers its
832
+ type from the config it is handed, and a config typed more narrowly (a
833
+ `Config<…>` from `efficient-easel/blocks`) gives an `Easel<ThatConfig>`,
834
+ which `EaselProvider` does not accept.
835
+ - **The store enforces no permission.** `dispatch`, `run`, `undo` and `redo`
836
+ write whatever they are asked to. Permissions are what the PARTS read: the
837
+ settings panel (`useSelectedFields().readOnly`), the menus, the undo keys. A
838
+ host that must not write, because somebody else holds the document, gates
839
+ its own calls.
840
+ - **`setPermissions` replaces, and tells its readers.** It is not merged over
841
+ the last value (name every key: `documentPermissions` in
842
+ `efficient-easel/edit-lock` does). A panel reading
843
+ `useSelectedFields().readOnly` or `useEasel((s) => s.getPermissions)`
844
+ re-renders when the permissions change, and only then; the same permissions
845
+ again tell nobody, so it is safe to call from an effect.
846
+ - **Undo and redo on the keyboard.** `EaselProvider` binds ⌘Z / Ctrl+Z and
847
+ the redo keys to the page, and `EaselCanvas` binds them in the frame's own
848
+ document, because a key pressed in the frame never reaches the page.
849
+ - `hotkeys={false}` on the provider turns **both** off, for a host that binds
850
+ its own.
851
+ - `<EaselCanvas hotkeys>` says otherwise for the frame alone: a host that
852
+ binds the page's keys itself (to its own root, because the page is another
853
+ app's) and still wants the frame's passes `hotkeys` there.
854
+ - Whatever those say, the keys do nothing while the easel-wide `edit`
855
+ permission is off. An undo is a write. `bindUndoKeys(target, editor)`, for
856
+ a host's own target, is held the same way.
857
+ - **`planFields().rows` holds `slot` rows.** They draw nothing and are there so
858
+ the slot's name has a bucket. Filter `row.field.type !== 'slot'` before
859
+ drawing or counting rows of your own.
860
+ - **Inserting a block runs no resolver; `easel.resolveNode` does.** The
861
+ `insert` action puts the block in with its `defaultProps`, as Puck's reducer
862
+ did. What ran a new block's `resolveData` in Puck was its own drop from the
863
+ component list, and the engine has no drop of its own: the Add list's click
864
+ and the drag's drop are the host's. So follow an insert with it:
865
+
866
+ ```ts
867
+ easel.dispatch({ type: 'insert', componentType, destinationZone, destinationIndex, id });
868
+ void easel.resolveNode(id, { trigger: 'insert' }); // or after `easel.run(…)`
869
+ ```
870
+
871
+ The block is told `trigger: 'insert'`, what it holds is resolved with it,
872
+ and what changed is written into the insert's own undo step. A block
873
+ changed by hand, or taken out, while its resolver ran is left as it is.
874
+ - **`InspectorUpLevel` draws its own `←`** (the one up-level look, Lewis
875
+ 2026-10-06): a blue text link, the arrow and the parent's name, no box and
876
+ no hover change. `icon` is ignored. Inside a panel body, pass `inline`. The
877
+ icon set has no back arrow, on purpose: nothing needs one now, and a glyph
878
+ enters the set by the owner's icon standard, not for a host.
879
+
880
+ ### What an accessibility audit of a second host found
881
+
882
+ A host built only from the parts, audited with axe-core in a real Chromium.
883
+ What follows is how the parts answer it; `scripts/second-host-clickthrough.mjs`
884
+ runs the same rules (`--axe <axe.min.js>`).
885
+
886
+ - **The right-click menu lives on `<body>`, and a host may move it into its
887
+ own landmarks.** On the body the box is page content outside every landmark
888
+ (axe `region`, a best-practice rule). It is not wrapped in a region or a
889
+ dialog to satisfy that: a menu is neither. A host that wants it inside its
890
+ landmarks portals it into its editor's root, through the check:
891
+
892
+ ```tsx
893
+ import { EaselContextMenu, menuPortalTarget } from 'efficient-easel/menu';
894
+
895
+ <EaselContextMenu portalTo={menuPortalTarget(rootRef.current)} … />
896
+ ```
897
+
898
+ The box is `position: fixed` at the pointer, and an ancestor that is
899
+ transformed, filtered or contained places and clips a fixed box inside it.
900
+ `menuPortalTarget` gives the root back only when none does, and `null` (the
901
+ body) otherwise, so a root that is scaled on one screen and not on another
902
+ is right on both. The body stays the default because it is the one place
903
+ that cannot go wrong. The root's stacking is the host's: the box is
904
+ `z-index: 9999` inside it.
905
+ - **A dropdown's open list has a name.** In a settings row
906
+ (`InspectorField`) the list is `aria-labelledby` the row's own label. A
907
+ `FieldSelect` on its own is named by `ariaLabelledBy` or `ariaLabel`, and
908
+ with neither the list takes its button's name, so it is never nameless.
909
+ - **Small grey words can be read.** The Add panel's group count, a menu
910
+ row's shortcut, a read-only or disabled field's value and an empty slot's
911
+ note are at least 4.5:1 on their ground (WCAG AA), held by ratio in
912
+ `tests/contrast-small-text.test.ts`. A host that overrode
913
+ `.es-add-group-count` or `--es-menu-shortcut-fg` can drop the override.
914
+ - **The rail says which words it draws.** The width decides in CSS between
915
+ every word, the open panel's word, and glyphs only, so a longer language
916
+ folds sooner (German "Zurück", "Ebenen"). `<TopBarRail onWords={…}>` is told
917
+ `'all' | 'chosen' | 'none'`, read back from what the stylesheet drew, when
918
+ it changes. Every button keeps its name as `title` and `aria-label`.
919
+ - **A list's drag hint is drawn whole under its last row.** A list in the
920
+ settings panel (`ListField`) is not a scroll box of its own; the panel
921
+ around it scrolls.
922
+
923
+ ### Server rendering and a strict Content-Security-Policy
924
+
925
+ A policy with no `'unsafe-inline'` for styles (`style-src 'self'`, or by nonce)
926
+ refuses every `style=""` **attribute** in the HTML a server sends. It does not
927
+ govern the CSSOM, which is how React writes a `style` prop in the browser, so a
928
+ client-rendered editor is not affected. And React does not repair it when it
929
+ hydrates: an attribute it finds matching is left alone, so a refused style stays
930
+ refused until its value changes.
931
+
932
+ So the frame's parts write no `style` of their own into a server's HTML:
933
+
934
+ - **The canvas frame's look is a class** (`.es-canvas-frame`, `.is-fill`, in
935
+ `efficient-easel/easel.css`): no border, the pane's width, and the pane's
936
+ height when it fills.
937
+ - **The panel widths have their default in the stylesheet** wherever chrome.css
938
+ reads them, and a host writes its own with `setPanelWidthVars(el, widths)`
939
+ from a ref rather than `style={panelWidthVars(widths)}`.
940
+ - **Stylesheets in the frame** take the page's nonce: `<EaselCanvas nonce>`.
941
+
942
+ What still writes a `style`, and where it matters:
943
+
944
+ | Part | When | Under such a policy |
945
+ |---|---|---|
946
+ | The drag layer's handle, line and cells; context menus; the rail's measured words | In the browser only, from state or a measurement | Nothing to do: never in a server's HTML |
947
+ | Everything inside the canvas frame (lists, block wrappers, outlines) | In the browser only: the frame is filled once it mounts | Nothing to do |
948
+ | `EaselCanvas frame={false}` | Server-rendered with the page | Mount it on the client |
949
+ | `ColourPicker`'s swatch, when it has a value | Server-rendered with a settings panel that holds a colour | Mount that panel on the client |
950
+ | `SaveState` with `counting` and `waitMs` | Only while a save waits, which a server render is not | Nothing to do |
951
+ | A `style` or a slot's `style` you pass (`CanvasFrame`, `TopBarCluster`, `Render`) | As you wrote it | Yours to move to a class |
952
+
953
+ `tests/csp-server-render.test.tsx` server-renders a frame built from the parts
954
+ and fails on any `style` attribute; `scripts/second-host-clickthrough.mjs`
955
+ serves one under a real policy in Chrome.
956
+
749
957
  ## The engine's words — `efficient-easel/strings`
750
958
 
751
959
  `efficient-easel/strings` (added in 0.95.0) is every word the engine itself
@@ -812,10 +1020,11 @@ block's name, a field's label, a tile's caption, the document's `noun`.
812
1020
 
813
1021
  ## The names before the Easel rename
814
1022
 
815
- The package took the name `efficient-easel` at 0.96.0. Its exports followed:
816
- every `Editor…` name is an `Easel…` one, and the store's subpath is
817
- `efficient-easel/easel`. **Every old name still works**, deprecated, and is the
818
- same value as its new name, so an app moves file by file.
1023
+ The package took the name `efficient-easel` at 0.96.0. Its exports followed at
1024
+ 0.99.2: every `Editor…` name is an `Easel…` one, and the store's subpath is
1025
+ `efficient-easel/easel`. From 0.99.2 to 0.99.x the old names were kept as
1026
+ deprecated aliases. **They are removed in 0.100.0**: a host must be on the new
1027
+ names, and on `efficient-web-builder` 0.7.1 or later, before it takes 0.100.0.
819
1028
 
820
1029
  | Subpath | Was | Is |
821
1030
  | --- | --- | --- |
@@ -831,9 +1040,6 @@ same value as its new name, so an app moves file by file.
831
1040
  | `/strings` | `EditorStrings`, `DEFAULT_EDITOR_STRINGS`, `mergeEditorStrings`, `EditorStringsProvider`, `EditorStringsProviderProps`, `useEditorStrings`, `useEditorLocale` | `EaselStrings`, `DEFAULT_EASEL_STRINGS`, `mergeEaselStrings`, `EaselStringsProvider`, `EaselStringsProviderProps`, `useEaselStrings`, `useEaselLocale` |
832
1041
  | `/edit-lock` | `newEditorClientId` | `newEaselClientId` |
833
1042
 
834
- `efficient-easel/editor` and `efficient-easel/easel` are built on one chunk, so
835
- a provider imported from one is read by a hook imported from the other.
836
-
837
1043
  Not renamed, because other code holds them: the `.es-editor` class and every
838
1044
  other `es-` class, the `--es-*` variables, the `editor-shell:style-clipboard`
839
1045
  storage key, and the rail's default label, the word "Editor", which a screen
@@ -855,3 +1061,13 @@ docker run --rm -v "$PWD":/w -w /w node:26-slim \
855
1061
  jsdom window in `tests/_dom-env.ts` and runs in its own process.
856
1062
  - `npm run build` — `tsup` → `dist/` (root + the `rail`, `shell` and `gold`
857
1063
  subpaths) + the copied CSS.
1064
+ - `node scripts/second-host-clickthrough.mjs [out-dir]` — what jsdom cannot
1065
+ hold. It server-renders a lean editor built only from the parts
1066
+ (`tests/fixtures/second-host-page.tsx`), serves it under a
1067
+ Content-Security-Policy with no `'unsafe-inline'`, and drives it in headless
1068
+ Chrome with real drags and keys: a tile dropped on the tree, the canvas
1069
+ scrolling under a drag, undo keys with editing held, a permission reaching
1070
+ the settings panel, no refused `style` attribute, an empty slot taking a
1071
+ drop. One PASS or FAIL per step and a screenshot of each. Not part of
1072
+ `npm test`: CI has no Chrome. Run it when you touch `drag`, `editor`,
1073
+ `chrome` or `tree`.
@@ -1,7 +1,7 @@
1
- import { A as Autosave, a as AutosaveSnapshot, b as SaveIndicator, c as AutosaveOptions } from '../words-c-3h9xQQ.js';
2
- export { d as AutosaveClock, S as SaveWords, e as createAutosave, s as saveIndicator } from '../words-c-3h9xQQ.js';
3
- import '../types-p9j2Fac7.js';
4
- import '../types-Bs_gVkVI.js';
1
+ import { A as Autosave, a as AutosaveSnapshot, b as SaveIndicator, c as AutosaveOptions } from '../words-BMzV04b1.js';
2
+ export { d as AutosaveClock, S as SaveWords, e as createAutosave, s as saveIndicator } from '../words-BMzV04b1.js';
3
+ import '../types-DGcCsxs8.js';
4
+ import '../types-DWS-s9z-.js';
5
5
  import '../wordFit-D4S36PvB.js';
6
6
  import 'react';
7
7
  import '../edit-lock/index.js';
@@ -16,7 +16,13 @@ type UseAutosaveOptions<T> = Omit<AutosaveOptions<T>, 'save' | 'onFailure'> & {
16
16
  };
17
17
  type UseAutosave<T> = Omit<Autosave<T>, 'setSave' | 'getSnapshot' | 'subscribe' | 'dispose'> & {
18
18
  state: AutosaveSnapshot;
19
- /** The pill's tone and words — `<SaveState {...indicator} />`. */
19
+ /**
20
+ * The pill's tone and words — `<SaveState {...indicator} />`.
21
+ *
22
+ * ⚠️ ALWAYS ENGLISH. Autosave is held to its own lane and does not read the
23
+ * strings provider. A host in another language builds the pill from the
24
+ * state instead: `saveIndicator(autosave.state, useEaselStrings().save)`.
25
+ */
20
26
  indicator: SaveIndicator;
21
27
  };
22
28
  declare function useAutosave<T>(options: UseAutosaveOptions<T>): UseAutosave<T>;
@@ -1,5 +1,5 @@
1
1
  import { ReactElement, FC, CSSProperties, ReactNode, JSX } from 'react';
2
- import { P as Permissions, A as AppState } from '../types-BOSGNvil.js';
2
+ import { P as Permissions, A as AppState } from '../types-E3HrTPg2.js';
3
3
  import '../data/index.js';
4
4
  import '../types-41704JE4.js';
5
5
  import '../resolve-D68pb3Bx.js';
@@ -1,6 +1,6 @@
1
1
  import { RefObject } from 'react';
2
- import { C as CanvasTarget, W as WatchOptions } from '../watch-B5RTR-WX.js';
3
- export { B as BLOCK_SELECTOR, a as CANVAS_SELECTOR, i as isFrameReady, w as watchCanvasFrame } from '../watch-B5RTR-WX.js';
2
+ import { C as CanvasTarget, W as WatchOptions } from '../watch-BTYOJpQU.js';
3
+ export { B as BLOCK_SELECTOR, a as CANVAS_SELECTOR, L as LIST_SELECTOR, i as isFrameReady, w as watchCanvasFrame } from '../watch-BTYOJpQU.js';
4
4
 
5
5
  /**
6
6
  * The small facts every canvas surface needs, written once — pure DOM, no
@@ -1 +1 @@
1
- export{BLOCK_SELECTOR,CANVAS_SELECTOR,isFrameReady,useCanvasEvent,useCanvasFrame,useCanvasLayout,useCanvasStyle,watchCanvasFrame}from"../chunk-MCLBHENY.js";export{BLOCK_ATTRIBUTE,clientPointToLayer,elementScale,fitCanvas,framePointToHost,frameRectToHost,frameRectToLayer,frameScale,isTypingTarget,nodeIdAt,removeCanvasStyle,setCanvasNonce,upsertCanvasStyle}from"../chunk-ZNCEOSXO.js";
1
+ export{BLOCK_SELECTOR,CANVAS_SELECTOR,LIST_SELECTOR,isFrameReady,useCanvasEvent,useCanvasFrame,useCanvasLayout,useCanvasStyle,watchCanvasFrame}from"../chunk-PAELDYYS.js";export{BLOCK_ATTRIBUTE,clientPointToLayer,elementScale,fitCanvas,framePointToHost,frameRectToHost,frameRectToLayer,frameScale,isTypingTarget,nodeIdAt,removeCanvasStyle,setCanvasNonce,upsertCanvasStyle}from"../chunk-ZNCEOSXO.js";
@@ -9,7 +9,16 @@
9
9
  * colours are the same colours, not copies of them.
10
10
  *
11
11
  * Needs `efficient-easel/shell.css` for the `--es-*` tokens. Widths come in as
12
- * `--es-left-w` / `--es-right-w` from `panelWidthVars()`, set on the editor ROOT.
12
+ * `--es-left-w` / `--es-right-w` from `panelWidthVars()` or `setPanelWidthVars()`,
13
+ * set on the editor ROOT.
14
+ *
15
+ * EVERY READ OF A WIDTH CARRIES ITS DEFAULT (`DEFAULT_PANEL_W`: 288 / 320;
16
+ * `tests/csp-server-render` holds the two together). A server-rendered editor
17
+ * has no width in scope until its script runs — and under a
18
+ * Content-Security-Policy that refuses `style` attributes, none from the HTML
19
+ * at all — and without a fallback the rail's column was 1px wide. A fallback
20
+ * at the read, not a declaration on a root: a host may set the variables on an
21
+ * ancestor of its own, and a default declared lower down would shadow them.
13
22
  *
14
23
  * Light only (owner, 2026-08-06: no dark mode in the editor or the designer).
15
24
  */
@@ -47,7 +56,7 @@
47
56
  want more room than the window has — the column collapses to its buttons
48
57
  with a correct `width` sitting right here (found twice on :5173, 2026-09-04). */
49
58
  flex: none;
50
- width: var(--es-left-w);
59
+ width: var(--es-left-w, 288px);
51
60
  box-sizing: border-box;
52
61
  height: 100%;
53
62
  gap: 0;
@@ -320,11 +329,11 @@
320
329
  container-type: inline-size;
321
330
  }
322
331
  .es-sidepanel--left {
323
- width: var(--es-left-w);
332
+ width: var(--es-left-w, 288px);
324
333
  border-right: 1px solid var(--es-line);
325
334
  }
326
335
  .es-sidepanel--right {
327
- width: var(--es-right-w);
336
+ width: var(--es-right-w, 320px);
328
337
  border-left: 1px solid var(--es-line);
329
338
  }
330
339
  .es-panel-head {
@@ -363,11 +372,11 @@
363
372
  touch-action: none;
364
373
  }
365
374
  .es-resizer--left {
366
- left: var(--es-left-w);
375
+ left: var(--es-left-w, 288px);
367
376
  justify-content: flex-start;
368
377
  }
369
378
  .es-resizer--right {
370
- right: var(--es-right-w);
379
+ right: var(--es-right-w, 320px);
371
380
  justify-content: flex-end;
372
381
  }
373
382
  .es-resizer-bar {
@@ -393,45 +402,42 @@ body.es-col-resizing iframe {
393
402
  }
394
403
 
395
404
  /* ── Inspector head ──────────────────────────────────────────────────────── */
396
- /* ↰ Go up one level: a quiet full-width row above the inspector head
397
- (campaign designer UpLevelControl, card 66042). The whole row is the button. */
405
+ /* ← Go up one level: the blue text link (owner ruling 2026-10-06, the website
406
+ Locations panel's look, one look in both editors). The arrow and the parent's
407
+ name in the accent colour, 12px; no box, no fill, no divider, and NO hover
408
+ change, as on the link it was taken from. Only the focus ring shows. At the
409
+ panel top it sits in the panel's own inset, 16 and 12 under 260px (one
410
+ spacing scale, owner ruling 2026-10-02); `inline` drops the wrapper. */
398
411
  .es-insp-up {
399
412
  display: flex;
400
- align-items: center;
401
413
  flex: none;
402
- padding: 8px;
403
- border-bottom: 1px solid var(--es-divider);
414
+ min-width: 0;
415
+ padding: 12px 16px 0;
404
416
  }
405
417
  .es-insp-up-btn {
406
- display: flex;
407
- align-items: center;
408
- gap: 8px;
409
- flex: 1;
418
+ display: inline-flex;
419
+ align-items: baseline;
420
+ gap: 4px;
421
+ max-width: 100%;
410
422
  min-width: 0;
411
- padding: 4px 8px;
423
+ padding: 0;
412
424
  border: 0;
413
- border-radius: 6px;
414
- background: transparent;
415
- cursor: pointer;
425
+ background: none;
416
426
  font: inherit;
417
427
  font-size: 12px;
418
- color: var(--es-muted);
428
+ color: var(--es-accent);
419
429
  text-align: left;
420
- transition: background 0.12s, color 0.12s;
421
- }
422
- .es-insp-up-btn:hover {
423
- background: var(--es-hover);
424
- color: var(--es-text);
430
+ cursor: pointer;
425
431
  }
426
432
  .es-insp-up-btn:focus-visible {
427
433
  outline: 2px solid var(--es-accent);
428
- outline-offset: 1px;
434
+ outline-offset: 2px;
429
435
  }
430
- .es-insp-up-icon {
431
- display: inline-flex;
436
+ .es-insp-up-arrow {
432
437
  flex: none;
433
438
  }
434
439
  .es-insp-up-name {
440
+ min-width: 0;
435
441
  overflow: hidden;
436
442
  text-overflow: ellipsis;
437
443
  white-space: nowrap;
@@ -450,7 +456,46 @@ body.es-col-resizing iframe {
450
456
  is the host's. */
451
457
  @container (width < 260px) {
452
458
  .es-panel-head,
453
- .es-insp-head { padding-inline: 12px; }
459
+ .es-insp-head,
460
+ .es-insp-up { padding-inline: 12px; }
461
+ }
462
+ /* A HEAD WITH A WAY-OUT WORD, UNDER 260px (card [MERGE] One Media Panel Head
463
+ For Both Editors, GG 2026-10-06). At a 220px panel the chip, Cancel, the
464
+ hairline and Hide left the titles about 38px: "Choose image" drew "Ch…" in
465
+ both editors. Nothing is cut (owner: change the layout, never the feature):
466
+ the word takes its own row, under the titles, and the hairline goes because
467
+ nothing stands beside Hide to part it from.
468
+ row 1 [chip] Choose image [hide]
469
+ For Background image
470
+ row 2 [Cancel]
471
+ The actions box steps aside (`display: contents`) so its buttons can sit in
472
+ different rows; every glyph button is locked to row 1 and takes the next
473
+ free column, so a head with several tools keeps them side by side. */
474
+ @container (width < 260px) {
475
+ .es-insp-head:has(.es-insp-textbtn) {
476
+ display: grid;
477
+ grid-template-columns: auto minmax(0, 1fr);
478
+ grid-auto-columns: auto;
479
+ align-items: center;
480
+ column-gap: 12px;
481
+ row-gap: 8px;
482
+ }
483
+ .es-insp-head:has(.es-insp-textbtn) > .es-insp-chip,
484
+ .es-insp-head:has(.es-insp-textbtn) > .es-insp-titles,
485
+ .es-insp-head:has(.es-insp-textbtn) .es-insp-iconbtn {
486
+ grid-row: 1;
487
+ }
488
+ .es-insp-head:has(.es-insp-textbtn) > .es-insp-actions {
489
+ display: contents;
490
+ }
491
+ .es-insp-head:has(.es-insp-textbtn) .es-insp-textbtn {
492
+ grid-row: 2;
493
+ grid-column: 2;
494
+ justify-self: start;
495
+ }
496
+ .es-insp-head:has(.es-insp-textbtn) .es-insp-divider {
497
+ display: none;
498
+ }
454
499
  }
455
500
  .es-insp-chip {
456
501
  width: 26px;
@@ -519,6 +564,28 @@ body.es-col-resizing iframe {
519
564
  color: var(--es-accent);
520
565
  }
521
566
 
567
+ /* A side panel's head has two pieces besides its glyph buttons (MediaPanelHead,
568
+ card [MERGE] One Media Panel Head, 2026-10-06; until then each editor's own
569
+ sheet drew them, as `.sf-head-*` and `.cd-head-*`). A MODE's way out is a
570
+ word ("Cancel", "Done") on the ghost button, drawn to the 28px of the head's
571
+ icon buttons so the row keeps one height (its sides are the scale's 12; both
572
+ editors drew 10, off the 2/4/8/12/16/32 scale); the hairline parts the
573
+ panel's own tools from the Hide panel button that ends every head. */
574
+ .es-btn-ghost.es-insp-textbtn {
575
+ height: 28px;
576
+ padding: 0 12px;
577
+ font-size: 12.5px;
578
+ border-radius: 7px;
579
+ white-space: nowrap;
580
+ }
581
+ .es-insp-divider {
582
+ width: 1px;
583
+ height: 16px;
584
+ margin: 0 4px;
585
+ background: var(--es-line);
586
+ flex: none;
587
+ }
588
+
522
589
  /* ── Edit notice — somebody else is in here, or a save was refused ───────── */
523
590
  /* A bar under the top bar, never a dialog (blueprint 2.5): the operator has to
524
591
  be able to read and copy from the screen it warns them about. No token of its