editor-shell 0.30.0 → 0.32.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.
package/README.md CHANGED
@@ -191,6 +191,43 @@ earlier than the transparent box on top of it and every following line would be
191
191
  drawn over the wrong text. The real weight and slant appear on the page the
192
192
  moment the merchant clicks away.
193
193
 
194
+ ## The edit lock — two operators, one document
195
+
196
+ `editor-shell/edit-lock` (added in 0.32.0) is the answer to "is the save path
197
+ allowed to run", shared by every editor in the house. It is pure TypeScript: no
198
+ React, no browser global, no dependency at all, so a server component may import
199
+ it.
200
+
201
+ It exists because the storefront editor and the campaign designer had written
202
+ the same rules twice, in two repos, and the two copies had already drifted — the
203
+ take-over half only existed on one side and the claim driver only on the other.
204
+ Both are here now, and neither editor keeps a copy.
205
+
206
+ ```ts
207
+ import { createEditLock, canWriteDocument, documentPermissions } from 'editor-shell/edit-lock';
208
+
209
+ // The only two things that differ between one editor and the next.
210
+ export const pageEditLock = createEditLock({
211
+ readFailure: (err) =>
212
+ err instanceof ApiError ? { status: err.status, data: err.data } : null,
213
+ conflictCode: 'page_changed_elsewhere',
214
+ });
215
+ ```
216
+
217
+ The plain half needs no binding: `canWriteDocument`, `holderOf`,
218
+ `readLockHolder`, `accessForNewDocument`, `documentPermissions`, `describeAge`,
219
+ `nameOrSomeone`, `newEditorClientId`, `shouldReleaseHeldWrite`. The bound half —
220
+ `accessAfterClaimFailure`, `accessAfterTakeoverFailure`,
221
+ `accessAfterHeartbeatFailure`, `isAuthRejection`, `readSaveConflict`,
222
+ `makeClaimBeat` — comes back from `createEditLock`.
223
+
224
+ **Two things this module will not do.** It never takes the ability to edit away
225
+ because a courtesy call failed: every claim failure that is not a 409 naming a
226
+ holder falls back to `editing`, because the save-time version check is what
227
+ actually protects the work. And a failed TAKE-OVER is a different question from a
228
+ failed first claim — it keeps the state it was pressed from, so a take-over that
229
+ did not land never puts two people in one document with the holder untold.
230
+
194
231
  ## Develop
195
232
 
196
233
  No Node on the dev Mac — everything runs in Docker:
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Who may WRITE the open document, and what to tell the operator when they may
3
+ * not — for EVERY editor in the house.
4
+ *
5
+ * This module is the merge of two files that had grown side by side and said
6
+ * the same things in two places (card 11161):
7
+ *
8
+ * * `efficient-admin-portal/src/components/email/campaignEditLock.ts` — the
9
+ * campaign designer's copy, which alone had the TAKE-OVER half;
10
+ * * `efficient-shop/lib/editor-page-lock.ts` — the storefront editor's copy,
11
+ * which alone had the claim/heartbeat driver, the auth-rejection rule and
12
+ * the "a template has no lock" rule.
13
+ *
14
+ * Neither was a subset of the other, so this file keeps EVERY capability of
15
+ * both, and both editors now import it instead of carrying a copy.
16
+ *
17
+ * TWO INDEPENDENT PROTECTIONS, and it matters which is which:
18
+ *
19
+ * * the SAVE-TIME version check is what actually protects work. The backend
20
+ * refuses a document write whose base version has moved on, so no amount of
21
+ * client-side confusion can erase a colleague's edits. Nothing here can
22
+ * weaken it — the token travels with the write and the server decides.
23
+ * * this advisory LOCK only stops two people STARTING, and is soft on
24
+ * purpose: it expires, it can always be taken over, and — see
25
+ * `accessAfterClaimFailure` — it FAILS OPEN. A lock that could refuse to
26
+ * let anybody edit would be worse than no lock at all.
27
+ *
28
+ * The backend rule is one rule shared by both (efficient/editlock.py). These
29
+ * are two CONSUMERS of one server-side contract, which is exactly why one
30
+ * client module can serve them.
31
+ *
32
+ * WHAT IS NOT SHARED, and why there is a factory rather than plain functions:
33
+ * the two editors talk to the backend through different HTTP clients, so a
34
+ * failure arrives as an `AxiosError` in one and as an `ApiError` in the other,
35
+ * and the refused-save payload carries a different `code`. Those two facts —
36
+ * and nothing else — are handed in by the consumer through `createEditLock`.
37
+ * The module itself imports no HTTP client and knows about no repo.
38
+ *
39
+ * LEAF DISCIPLINE: pure TypeScript. No React, no `document`/`window`, no
40
+ * dependency at all. `crypto` is touched inside a function, never at module
41
+ * eval, so a Next server component may import this module safely.
42
+ */
43
+ /** The `edit-lock` body — identical for every editable document. */
44
+ type EditLockBody = {
45
+ held_by_you: boolean;
46
+ holder_name: string;
47
+ client_id: string;
48
+ last_seen_at: string;
49
+ seconds_since_seen: number;
50
+ seconds_since_held: number;
51
+ heartbeat_seconds: number;
52
+ expiry_seconds: number;
53
+ };
54
+ /** The other operator, as the notices need to describe them. */
55
+ type LockHolder = {
56
+ name: string;
57
+ /** Seconds since their last heartbeat, as the SERVER measured it. */
58
+ secondsSinceSeen: number;
59
+ /** Seconds since they TOOK the document, as the SERVER measured it. Not the
60
+ * same number: an active holder's heartbeat keeps `secondsSinceSeen` near
61
+ * zero, so only this one can say "took over 20 seconds ago". */
62
+ secondsSinceHeld: number;
63
+ };
64
+ /**
65
+ * Whether the open document may be written, and why not when it may not.
66
+ *
67
+ * `editing` is the ONLY writable state, and `canWriteDocument` is the only place
68
+ * that says so — so a new state added later is read-only until someone
69
+ * deliberately makes it writable, rather than writable until someone remembers
70
+ * to block it.
71
+ */
72
+ type EditAccess =
73
+ /** The claim is still in flight. Holding writes for the moment it takes is
74
+ * what stops a fast typist's first autosave landing before we know whether
75
+ * somebody else is in here. */
76
+ {
77
+ kind: 'checking';
78
+ }
79
+ /** You hold the document. */
80
+ | {
81
+ kind: 'editing';
82
+ }
83
+ /** Somebody else holds it and you have not chosen yet — view, or take over. */
84
+ | {
85
+ kind: 'blocked';
86
+ holder: LockHolder;
87
+ }
88
+ /** You chose to look without touching. */
89
+ | {
90
+ kind: 'readonly';
91
+ holder: LockHolder;
92
+ }
93
+ /** You HAD it and somebody took it. Your work is still on screen. */
94
+ | {
95
+ kind: 'takenover';
96
+ holder: LockHolder;
97
+ };
98
+ /** The one gate on the document write path. */
99
+ declare const canWriteDocument: (access: EditAccess) => boolean;
100
+ /**
101
+ * The access a freshly opened document starts from.
102
+ *
103
+ * A document WITH a lock starts `checking` and the claim answers within a round
104
+ * trip. One WITHOUT — the storefront's templates — has no claim effect at all,
105
+ * so `checking` there is not a question awaiting an answer, it is a state
106
+ * nothing will ever leave, and every write for the rest of the session is
107
+ * refused: the merchant's edits and, first of all, the pinned Header/Footer seed
108
+ * that runs in the chrome's mount commit. The editor's MOUNT has always known
109
+ * this; a document SWITCH did not, which is what card 1096 found.
110
+ *
111
+ * Not a loosening of `canWriteDocument`: it says which QUESTION a document opens
112
+ * with, and a document with no lock was never asking one.
113
+ */
114
+ declare function accessForNewDocument(hasEditLock: boolean): EditAccess;
115
+ /** The holder named by a state that has one (for the banner / the notices). */
116
+ declare const holderOf: (access: EditAccess) => LockHolder | null;
117
+ /** Read the `edit-lock` body — from a 200 or from a 409's error payload. */
118
+ declare function readLockHolder(data: unknown): LockHolder | null;
119
+ /** The five capabilities Puck's `permissions` prop carries for the canvas. */
120
+ type DocumentPermissions = {
121
+ edit: boolean;
122
+ insert: boolean;
123
+ delete: boolean;
124
+ duplicate: boolean;
125
+ drag: boolean;
126
+ };
127
+ /**
128
+ * The canvas's Puck `permissions`, derived from the write gate.
129
+ *
130
+ * BOTH branches name ALL FIVE keys — and that completeness is the whole point,
131
+ * not tidiness. Puck does not replace this prop when it changes; it MERGES it
132
+ * over the permissions it already holds (store/slices/permissions.ts,
133
+ * `useRegisterPermissionsSlice`: `{ ...existingGlobalPermissions, ...prop }`),
134
+ * and a field's editability is the live `getPermissions().edit`. Puck mounts
135
+ * while the lock claim is still `checking`, so `canEdit` is false and the store
136
+ * latches every key false. A PARTIAL writable value (say `{ drag: false }`)
137
+ * would then merge WITHOUT an `edit` key and leave `edit:false` stuck — every
138
+ * inspector field disabled for good, even after the claim resolves to
139
+ * `editing`. Spelling out edit/insert/delete/duplicate on the writable branch
140
+ * resets them, so the panel comes back the instant `canEdit` flips true.
141
+ *
142
+ * `drag` stays false in EITHER state: both editors move blocks with native DnD
143
+ * rails of their own, so Puck's flaky cross-iframe canvas drag is always off.
144
+ *
145
+ * Write safety is unchanged: the `checking` window is still non-editable here,
146
+ * and the host's own write gate still drops any edit attempted before the claim
147
+ * resolves, so nothing is saved early.
148
+ */
149
+ declare const documentPermissions: (canEdit: boolean) => DocumentPermissions;
150
+ /** "just now" / "2 minutes ago" — one phrasing, shared by every notice. */
151
+ declare function describeAge(seconds: number): string;
152
+ /** Names the person, or says so honestly when the backend could not. */
153
+ declare const nameOrSomeone: (name: string) => string;
154
+ /**
155
+ * The identity of THIS editor tab.
156
+ *
157
+ * Per-tab, not per-user: it is what lets the backend tell "me, in a second tab"
158
+ * (take my own lock over silently) from "me, here". Deliberately not persisted —
159
+ * a reload IS a new editor session, and a remembered id would let a closed tab's
160
+ * claim look alive.
161
+ */
162
+ declare function newEditorClientId(): string;
163
+ /** A refused save: the stored document moved on since this editor loaded it. */
164
+ type SaveConflict = {
165
+ /** Who saved over you. Empty when the backend could not attribute the write. */
166
+ savedBy: string;
167
+ /** True when it was YOU, in another tab — never blame a colleague for it. */
168
+ savedByYou: boolean;
169
+ secondsAgo: number;
170
+ };
171
+ /**
172
+ * A write the editor is HOLDING rather than sending, and the two other reasons
173
+ * it might be held. `onChange` never drops a refused write — the operator's
174
+ * screen is the only copy of it — so each hold owes a release.
175
+ */
176
+ type HeldWrite = {
177
+ /** An unpersisted layout is waiting (the host's pending-data ref). */
178
+ pending: boolean;
179
+ /** Autosave paused: the merchant turned writes off, and the pill's resume is
180
+ * the only thing that may flush. */
181
+ autoSaveEnabled: boolean;
182
+ /** A refused save: their work sits behind a banner and only they decide what
183
+ * happens to it. Retrying behind their back is how it gets lost. */
184
+ saveConflict: boolean;
185
+ };
186
+ /** Everything the claim path reads and drives, passed in rather than imported so
187
+ * the path itself can be RUN in a test — a rule that only lives inside a React
188
+ * effect is a rule nothing can check. */
189
+ type ClaimBeatDeps = {
190
+ /** Claim the document (or take it). */
191
+ claim: (takeover: boolean) => Promise<{
192
+ heartbeat_seconds: number;
193
+ }>;
194
+ /** The effect's cleanup ran: a late answer must not grant a document this
195
+ * editor has already left, nor flush one that is no longer open. */
196
+ cancelled: () => boolean;
197
+ currentAccess: () => EditAccess;
198
+ heldWrite: () => HeldWrite;
199
+ setAccess: (access: EditAccess) => void;
200
+ /** Send the held write — the host's one write path, never a second one. */
201
+ releaseHeldWrite: () => void;
202
+ /** Start the heartbeat that KEEPS the document, at the backend's own interval. */
203
+ startHeartbeat: (intervalMs: number) => void;
204
+ /** Stop beating. Called when the SESSION is refused, never when a single
205
+ * request fails — see `isAuthRejection`. The effect re-runs and claims again
206
+ * the moment a fresh token arrives, so this ends a doomed loop rather than
207
+ * ending the editor's hold on the document. */
208
+ stopHeartbeat: () => void;
209
+ };
210
+ /**
211
+ * Must a write held ONLY because the document was not yet writable go out now?
212
+ *
213
+ * Keyed on the TRANSITION, not on the current state: `beat` also runs on every
214
+ * heartbeat, where access is already `editing`. Releasing on "is writable" would
215
+ * flush the merchant's in-progress edit on every beat and delete the debounce
216
+ * that keeps whole-document writes rare.
217
+ *
218
+ * Exported so the rule has a test of its own, and because it is the one piece of
219
+ * `makeClaimBeat` a caller can reason about without a driver.
220
+ */
221
+ declare function shouldReleaseHeldWrite(previous: EditAccess, next: EditAccess, held: HeldWrite): boolean;
222
+ /** A failed request reduced to the only two things any decision here needs. */
223
+ type EditLockFailure = {
224
+ status: number;
225
+ data: unknown;
226
+ };
227
+ /**
228
+ * The two things that genuinely differ between one editor and the next.
229
+ *
230
+ * Kept to two on purpose. Anything a THIRD editor would also have to answer
231
+ * belongs in this module, not in another adapter.
232
+ */
233
+ type EditLockAdapter = {
234
+ /**
235
+ * Pull `{ status, data }` out of whatever this repo's HTTP client threw, or
236
+ * null when the rejection is not a server answer at all (a dropped socket, a
237
+ * programming error). Every "fails open" branch below leans on that null.
238
+ */
239
+ readFailure: (err: unknown) => EditLockFailure | null;
240
+ /** The `code` the backend puts in a refused-save body for THIS document type
241
+ * — `page_changed_elsewhere`, `campaign_changed_elsewhere`. */
242
+ conflictCode: string;
243
+ };
244
+ /** The decisions that need the adapter. Everything else above is plain. */
245
+ type EditLock = {
246
+ accessAfterClaimFailure: (err: unknown) => EditAccess;
247
+ accessAfterTakeoverFailure: (err: unknown, current: EditAccess) => EditAccess;
248
+ accessAfterHeartbeatFailure: (err: unknown, current: EditAccess) => EditAccess;
249
+ isAuthRejection: (err: unknown) => boolean;
250
+ readSaveConflict: (err: unknown) => SaveConflict | null;
251
+ makeClaimBeat: (deps: ClaimBeatDeps) => (takeover: boolean) => Promise<void>;
252
+ };
253
+ /** Bind the decisions to one editor's HTTP client and document type. */
254
+ declare function createEditLock(adapter: EditLockAdapter): EditLock;
255
+
256
+ export { type ClaimBeatDeps, type DocumentPermissions, type EditAccess, type EditLock, type EditLockAdapter, type EditLockBody, type EditLockFailure, type HeldWrite, type LockHolder, type SaveConflict, accessForNewDocument, canWriteDocument, createEditLock, describeAge, documentPermissions, holderOf, nameOrSomeone, newEditorClientId, readLockHolder, shouldReleaseHeldWrite };
@@ -0,0 +1,109 @@
1
+ // src/edit-lock/index.ts
2
+ var canWriteDocument = (access) => access.kind === "editing";
3
+ function accessForNewDocument(hasEditLock) {
4
+ return hasEditLock ? { kind: "checking" } : { kind: "editing" };
5
+ }
6
+ var holderOf = (access) => access.kind === "blocked" || access.kind === "readonly" || access.kind === "takenover" ? access.holder : null;
7
+ function readLockHolder(data) {
8
+ if (!data || typeof data !== "object") return null;
9
+ const lock = data;
10
+ if (typeof lock.holder_name !== "string") return null;
11
+ return {
12
+ name: lock.holder_name,
13
+ secondsSinceSeen: typeof lock.seconds_since_seen === "number" ? lock.seconds_since_seen : 0,
14
+ secondsSinceHeld: typeof lock.seconds_since_held === "number" ? lock.seconds_since_held : 0
15
+ };
16
+ }
17
+ var documentPermissions = (canEdit) => canEdit ? { edit: true, insert: true, delete: true, duplicate: true, drag: false } : { edit: false, insert: false, delete: false, duplicate: false, drag: false };
18
+ function describeAge(seconds) {
19
+ if (!Number.isFinite(seconds) || seconds < 45) return "just now";
20
+ const minutes = Math.round(seconds / 60);
21
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
22
+ const hours = Math.round(minutes / 60);
23
+ return `${hours} hour${hours === 1 ? "" : "s"} ago`;
24
+ }
25
+ var nameOrSomeone = (name) => name.trim() || "Someone else";
26
+ function newEditorClientId() {
27
+ const rand = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2);
28
+ return `tab-${rand}`.slice(0, 64);
29
+ }
30
+ function shouldReleaseHeldWrite(previous, next, held) {
31
+ if (canWriteDocument(previous) || !canWriteDocument(next)) return false;
32
+ return held.pending && held.autoSaveEnabled && !held.saveConflict;
33
+ }
34
+ function createEditLock(adapter) {
35
+ const { readFailure, conflictCode } = adapter;
36
+ const holderFromConflict = (err) => {
37
+ const f = readFailure(err);
38
+ if (!f || f.status !== 409) return null;
39
+ return readLockHolder(f.data);
40
+ };
41
+ const accessAfterClaimFailure = (err) => {
42
+ const holder = holderFromConflict(err);
43
+ return holder ? { kind: "blocked", holder } : { kind: "editing" };
44
+ };
45
+ const accessAfterTakeoverFailure = (err, current) => {
46
+ const holder = holderFromConflict(err);
47
+ return holder ? { kind: "blocked", holder } : current;
48
+ };
49
+ const accessAfterHeartbeatFailure = (err, current) => {
50
+ const holder = holderFromConflict(err);
51
+ return holder ? { kind: "takenover", holder } : current;
52
+ };
53
+ const isAuthRejection = (err) => {
54
+ const f = readFailure(err);
55
+ return f !== null && (f.status === 401 || f.status === 403);
56
+ };
57
+ const readSaveConflict = (err) => {
58
+ const f = readFailure(err);
59
+ if (!f || f.status !== 409) return null;
60
+ if (!f.data || typeof f.data !== "object") return null;
61
+ const body = f.data;
62
+ if (body.code !== conflictCode) return null;
63
+ return {
64
+ savedBy: typeof body.saved_by === "string" ? body.saved_by : "",
65
+ // Compared with `=== true`: the wire value must be a real boolean. DRF
66
+ // coerces exception-detail leaves to strings, and "False" is truthy here —
67
+ // which would tell you that you overwrote your own document when a
68
+ // colleague did. Both backends assign the payload directly to avoid it.
69
+ savedByYou: body.saved_by_you === true,
70
+ secondsAgo: typeof body.seconds_ago === "number" ? body.seconds_ago : 0
71
+ };
72
+ };
73
+ const makeClaimBeat = (deps) => {
74
+ let heartbeatStarted = false;
75
+ return async (takeover) => {
76
+ let previous;
77
+ let next;
78
+ try {
79
+ const lock = await deps.claim(takeover);
80
+ if (deps.cancelled()) return;
81
+ previous = deps.currentAccess();
82
+ next = { kind: "editing" };
83
+ if (!heartbeatStarted && lock.heartbeat_seconds > 0) {
84
+ heartbeatStarted = true;
85
+ deps.startHeartbeat(lock.heartbeat_seconds * 1e3);
86
+ }
87
+ } catch (err) {
88
+ if (deps.cancelled()) return;
89
+ previous = deps.currentAccess();
90
+ next = takeover ? accessAfterTakeoverFailure(err, previous) : previous.kind === "checking" ? accessAfterClaimFailure(err) : accessAfterHeartbeatFailure(err, previous);
91
+ if (isAuthRejection(err)) deps.stopHeartbeat();
92
+ }
93
+ deps.setAccess(next);
94
+ if (shouldReleaseHeldWrite(previous, next, deps.heldWrite())) deps.releaseHeldWrite();
95
+ };
96
+ };
97
+ return {
98
+ accessAfterClaimFailure,
99
+ accessAfterTakeoverFailure,
100
+ accessAfterHeartbeatFailure,
101
+ isAuthRejection,
102
+ readSaveConflict,
103
+ makeClaimBeat
104
+ };
105
+ }
106
+
107
+ export { accessForNewDocument, canWriteDocument, createEditLock, describeAge, documentPermissions, holderOf, nameOrSomeone, newEditorClientId, readLockHolder, shouldReleaseHeldWrite };
108
+ //# sourceMappingURL=index.js.map
109
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/edit-lock/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Who may WRITE the open document, and what to tell the operator when they may\n * not — for EVERY editor in the house.\n *\n * This module is the merge of two files that had grown side by side and said\n * the same things in two places (card 11161):\n *\n * * `efficient-admin-portal/src/components/email/campaignEditLock.ts` — the\n * campaign designer's copy, which alone had the TAKE-OVER half;\n * * `efficient-shop/lib/editor-page-lock.ts` — the storefront editor's copy,\n * which alone had the claim/heartbeat driver, the auth-rejection rule and\n * the \"a template has no lock\" rule.\n *\n * Neither was a subset of the other, so this file keeps EVERY capability of\n * both, and both editors now import it instead of carrying a copy.\n *\n * TWO INDEPENDENT PROTECTIONS, and it matters which is which:\n *\n * * the SAVE-TIME version check is what actually protects work. The backend\n * refuses a document write whose base version has moved on, so no amount of\n * client-side confusion can erase a colleague's edits. Nothing here can\n * weaken it — the token travels with the write and the server decides.\n * * this advisory LOCK only stops two people STARTING, and is soft on\n * purpose: it expires, it can always be taken over, and — see\n * `accessAfterClaimFailure` — it FAILS OPEN. A lock that could refuse to\n * let anybody edit would be worse than no lock at all.\n *\n * The backend rule is one rule shared by both (efficient/editlock.py). These\n * are two CONSUMERS of one server-side contract, which is exactly why one\n * client module can serve them.\n *\n * WHAT IS NOT SHARED, and why there is a factory rather than plain functions:\n * the two editors talk to the backend through different HTTP clients, so a\n * failure arrives as an `AxiosError` in one and as an `ApiError` in the other,\n * and the refused-save payload carries a different `code`. Those two facts —\n * and nothing else — are handed in by the consumer through `createEditLock`.\n * The module itself imports no HTTP client and knows about no repo.\n *\n * LEAF DISCIPLINE: pure TypeScript. No React, no `document`/`window`, no\n * dependency at all. `crypto` is touched inside a function, never at module\n * eval, so a Next server component may import this module safely.\n */\n\n/* ── The wire, and the people on the other end of it ─────────────────────── */\n\n/** The `edit-lock` body — identical for every editable document. */\nexport type EditLockBody = {\n held_by_you: boolean;\n holder_name: string;\n client_id: string;\n last_seen_at: string;\n seconds_since_seen: number;\n seconds_since_held: number;\n heartbeat_seconds: number;\n expiry_seconds: number;\n};\n\n/** The other operator, as the notices need to describe them. */\nexport type LockHolder = {\n name: string;\n /** Seconds since their last heartbeat, as the SERVER measured it. */\n secondsSinceSeen: number;\n /** Seconds since they TOOK the document, as the SERVER measured it. Not the\n * same number: an active holder's heartbeat keeps `secondsSinceSeen` near\n * zero, so only this one can say \"took over 20 seconds ago\". */\n secondsSinceHeld: number;\n};\n\n/**\n * Whether the open document may be written, and why not when it may not.\n *\n * `editing` is the ONLY writable state, and `canWriteDocument` is the only place\n * that says so — so a new state added later is read-only until someone\n * deliberately makes it writable, rather than writable until someone remembers\n * to block it.\n */\nexport type EditAccess =\n /** The claim is still in flight. Holding writes for the moment it takes is\n * what stops a fast typist's first autosave landing before we know whether\n * somebody else is in here. */\n | { kind: 'checking' }\n /** You hold the document. */\n | { kind: 'editing' }\n /** Somebody else holds it and you have not chosen yet — view, or take over. */\n | { kind: 'blocked'; holder: LockHolder }\n /** You chose to look without touching. */\n | { kind: 'readonly'; holder: LockHolder }\n /** You HAD it and somebody took it. Your work is still on screen. */\n | { kind: 'takenover'; holder: LockHolder };\n\n/** The one gate on the document write path. */\nexport const canWriteDocument = (access: EditAccess): boolean =>\n access.kind === 'editing';\n\n/**\n * The access a freshly opened document starts from.\n *\n * A document WITH a lock starts `checking` and the claim answers within a round\n * trip. One WITHOUT — the storefront's templates — has no claim effect at all,\n * so `checking` there is not a question awaiting an answer, it is a state\n * nothing will ever leave, and every write for the rest of the session is\n * refused: the merchant's edits and, first of all, the pinned Header/Footer seed\n * that runs in the chrome's mount commit. The editor's MOUNT has always known\n * this; a document SWITCH did not, which is what card 1096 found.\n *\n * Not a loosening of `canWriteDocument`: it says which QUESTION a document opens\n * with, and a document with no lock was never asking one.\n */\nexport function accessForNewDocument(hasEditLock: boolean): EditAccess {\n return hasEditLock ? { kind: 'checking' } : { kind: 'editing' };\n}\n\n/** The holder named by a state that has one (for the banner / the notices). */\nexport const holderOf = (access: EditAccess): LockHolder | null =>\n access.kind === 'blocked' || access.kind === 'readonly' || access.kind === 'takenover'\n ? access.holder\n : null;\n\n/** Read the `edit-lock` body — from a 200 or from a 409's error payload. */\nexport function readLockHolder(data: unknown): LockHolder | null {\n if (!data || typeof data !== 'object') return null;\n const lock = data as Partial<EditLockBody>;\n if (typeof lock.holder_name !== 'string') return null;\n return {\n name: lock.holder_name,\n secondsSinceSeen:\n typeof lock.seconds_since_seen === 'number' ? lock.seconds_since_seen : 0,\n secondsSinceHeld:\n typeof lock.seconds_since_held === 'number' ? lock.seconds_since_held : 0,\n };\n}\n\n/* ── What the canvas may do ──────────────────────────────────────────────── */\n\n/** The five capabilities Puck's `permissions` prop carries for the canvas. */\nexport type DocumentPermissions = {\n edit: boolean;\n insert: boolean;\n delete: boolean;\n duplicate: boolean;\n drag: boolean;\n};\n\n/**\n * The canvas's Puck `permissions`, derived from the write gate.\n *\n * BOTH branches name ALL FIVE keys — and that completeness is the whole point,\n * not tidiness. Puck does not replace this prop when it changes; it MERGES it\n * over the permissions it already holds (store/slices/permissions.ts,\n * `useRegisterPermissionsSlice`: `{ ...existingGlobalPermissions, ...prop }`),\n * and a field's editability is the live `getPermissions().edit`. Puck mounts\n * while the lock claim is still `checking`, so `canEdit` is false and the store\n * latches every key false. A PARTIAL writable value (say `{ drag: false }`)\n * would then merge WITHOUT an `edit` key and leave `edit:false` stuck — every\n * inspector field disabled for good, even after the claim resolves to\n * `editing`. Spelling out edit/insert/delete/duplicate on the writable branch\n * resets them, so the panel comes back the instant `canEdit` flips true.\n *\n * `drag` stays false in EITHER state: both editors move blocks with native DnD\n * rails of their own, so Puck's flaky cross-iframe canvas drag is always off.\n *\n * Write safety is unchanged: the `checking` window is still non-editable here,\n * and the host's own write gate still drops any edit attempted before the claim\n * resolves, so nothing is saved early.\n */\nexport const documentPermissions = (canEdit: boolean): DocumentPermissions =>\n canEdit\n ? { edit: true, insert: true, delete: true, duplicate: true, drag: false }\n : { edit: false, insert: false, delete: false, duplicate: false, drag: false };\n\n/* ── Words ───────────────────────────────────────────────────────────────── */\n\n/** \"just now\" / \"2 minutes ago\" — one phrasing, shared by every notice. */\nexport function describeAge(seconds: number): string {\n if (!Number.isFinite(seconds) || seconds < 45) return 'just now';\n const minutes = Math.round(seconds / 60);\n if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;\n const hours = Math.round(minutes / 60);\n return `${hours} hour${hours === 1 ? '' : 's'} ago`;\n}\n\n/** Names the person, or says so honestly when the backend could not. */\nexport const nameOrSomeone = (name: string): string => name.trim() || 'Someone else';\n\n/**\n * The identity of THIS editor tab.\n *\n * Per-tab, not per-user: it is what lets the backend tell \"me, in a second tab\"\n * (take my own lock over silently) from \"me, here\". Deliberately not persisted —\n * a reload IS a new editor session, and a remembered id would let a closed tab's\n * claim look alive.\n */\nexport function newEditorClientId(): string {\n const rand =\n typeof crypto !== 'undefined' && 'randomUUID' in crypto\n ? crypto.randomUUID()\n : Math.random().toString(36).slice(2);\n return `tab-${rand}`.slice(0, 64);\n}\n\n/* ── A refused save ──────────────────────────────────────────────────────── */\n\n/** A refused save: the stored document moved on since this editor loaded it. */\nexport type SaveConflict = {\n /** Who saved over you. Empty when the backend could not attribute the write. */\n savedBy: string;\n /** True when it was YOU, in another tab — never blame a colleague for it. */\n savedByYou: boolean;\n secondsAgo: number;\n};\n\n/* ── The claim, and the write it was holding up ──────────────────────────── */\n\n/**\n * A write the editor is HOLDING rather than sending, and the two other reasons\n * it might be held. `onChange` never drops a refused write — the operator's\n * screen is the only copy of it — so each hold owes a release.\n */\nexport type HeldWrite = {\n /** An unpersisted layout is waiting (the host's pending-data ref). */\n pending: boolean;\n /** Autosave paused: the merchant turned writes off, and the pill's resume is\n * the only thing that may flush. */\n autoSaveEnabled: boolean;\n /** A refused save: their work sits behind a banner and only they decide what\n * happens to it. Retrying behind their back is how it gets lost. */\n saveConflict: boolean;\n};\n\n/** Everything the claim path reads and drives, passed in rather than imported so\n * the path itself can be RUN in a test — a rule that only lives inside a React\n * effect is a rule nothing can check. */\nexport type ClaimBeatDeps = {\n /** Claim the document (or take it). */\n claim: (takeover: boolean) => Promise<{ heartbeat_seconds: number }>;\n /** The effect's cleanup ran: a late answer must not grant a document this\n * editor has already left, nor flush one that is no longer open. */\n cancelled: () => boolean;\n currentAccess: () => EditAccess;\n heldWrite: () => HeldWrite;\n setAccess: (access: EditAccess) => void;\n /** Send the held write — the host's one write path, never a second one. */\n releaseHeldWrite: () => void;\n /** Start the heartbeat that KEEPS the document, at the backend's own interval. */\n startHeartbeat: (intervalMs: number) => void;\n /** Stop beating. Called when the SESSION is refused, never when a single\n * request fails — see `isAuthRejection`. The effect re-runs and claims again\n * the moment a fresh token arrives, so this ends a doomed loop rather than\n * ending the editor's hold on the document. */\n stopHeartbeat: () => void;\n};\n\n/**\n * Must a write held ONLY because the document was not yet writable go out now?\n *\n * Keyed on the TRANSITION, not on the current state: `beat` also runs on every\n * heartbeat, where access is already `editing`. Releasing on \"is writable\" would\n * flush the merchant's in-progress edit on every beat and delete the debounce\n * that keeps whole-document writes rare.\n *\n * Exported so the rule has a test of its own, and because it is the one piece of\n * `makeClaimBeat` a caller can reason about without a driver.\n */\nexport function shouldReleaseHeldWrite(\n previous: EditAccess,\n next: EditAccess,\n held: HeldWrite,\n): boolean {\n if (canWriteDocument(previous) || !canWriteDocument(next)) return false;\n return held.pending && held.autoSaveEnabled && !held.saveConflict;\n}\n\n/* ── The half that needs to know what an error looks like ────────────────── */\n\n/** A failed request reduced to the only two things any decision here needs. */\nexport type EditLockFailure = { status: number; data: unknown };\n\n/**\n * The two things that genuinely differ between one editor and the next.\n *\n * Kept to two on purpose. Anything a THIRD editor would also have to answer\n * belongs in this module, not in another adapter.\n */\nexport type EditLockAdapter = {\n /**\n * Pull `{ status, data }` out of whatever this repo's HTTP client threw, or\n * null when the rejection is not a server answer at all (a dropped socket, a\n * programming error). Every \"fails open\" branch below leans on that null.\n */\n readFailure: (err: unknown) => EditLockFailure | null;\n /** The `code` the backend puts in a refused-save body for THIS document type\n * — `page_changed_elsewhere`, `campaign_changed_elsewhere`. */\n conflictCode: string;\n};\n\n/** The decisions that need the adapter. Everything else above is plain. */\nexport type EditLock = {\n accessAfterClaimFailure: (err: unknown) => EditAccess;\n accessAfterTakeoverFailure: (err: unknown, current: EditAccess) => EditAccess;\n accessAfterHeartbeatFailure: (err: unknown, current: EditAccess) => EditAccess;\n isAuthRejection: (err: unknown) => boolean;\n readSaveConflict: (err: unknown) => SaveConflict | null;\n makeClaimBeat: (deps: ClaimBeatDeps) => (takeover: boolean) => Promise<void>;\n};\n\n/** Bind the decisions to one editor's HTTP client and document type. */\nexport function createEditLock(adapter: EditLockAdapter): EditLock {\n const { readFailure, conflictCode } = adapter;\n\n /** The holder named by a 409, or null for any other failure. */\n const holderFromConflict = (err: unknown): LockHolder | null => {\n const f = readFailure(err);\n if (!f || f.status !== 409) return null;\n return readLockHolder(f.data);\n };\n\n /**\n * What a FAILED claim means.\n *\n * A 409 naming a holder is the real answer: someone else is in here, so ask\n * the operator what they want to do. **Anything else fails OPEN** — an\n * unreachable backend, a 500, an older backend with no such endpoint (404), a\n * 403 — because this lock exists to be polite, and taking away the ability to\n * edit because a courtesy call failed would be a far worse bug than the one it\n * prevents. The version check still protects the work either way.\n *\n * The 404 case is not hypothetical: a client can ship BEFORE the backend that\n * serves `edit-lock`, and between the two deploys every claim 404s and\n * everybody must simply keep working.\n */\n const accessAfterClaimFailure = (err: unknown): EditAccess => {\n const holder = holderFromConflict(err);\n return holder ? { kind: 'blocked', holder } : { kind: 'editing' };\n };\n\n /**\n * What a failed **take-over** means, given where we were.\n *\n * NOT the same question as a failed first claim, and answering it with\n * `accessAfterClaimFailure` was a bug: that one fails OPEN, which is right\n * when nobody is known to hold the document, and badly wrong here. The\n * operator can only reach a take-over from a state that NAMES a holder, so\n * failing open would unlock the canvas, drop the banner, and leave two people\n * editing with one lock — while the person actually holding it is never told.\n *\n * So a take-over that did not land keeps the state it started from: the\n * document is still theirs, the banner still says so, and the button is still\n * there to press again. Only a 200 grants it.\n *\n * A 409 is a different, still-live holder (somebody took it in the gap) —\n * re-point the banner at whoever that now is.\n */\n const accessAfterTakeoverFailure = (err: unknown, current: EditAccess): EditAccess => {\n const holder = holderFromConflict(err);\n return holder ? { kind: 'blocked', holder } : current;\n };\n\n /**\n * What a failed HEARTBEAT means, given where we were.\n *\n * A 409 means somebody took the document: say so at once, because the\n * alternative is the operator editing into the void for half an hour. Any\n * other failure — a dropped connection, a sleeping laptop's first beat on\n * waking — keeps the current state: losing the document to a network blip\n * would be the same work-destroying surprise from the other direction.\n *\n * A rejected SESSION is neither of those, and reading it as a blip is what\n * `isAuthRejection` exists to stop. The ACCESS answer is still \"keep the\n * current state\" — see that function for why the beat, not the access, is the\n * thing that has to change.\n */\n const accessAfterHeartbeatFailure = (err: unknown, current: EditAccess): EditAccess => {\n const holder = holderFromConflict(err);\n return holder ? { kind: 'takenover', holder } : current;\n };\n\n /**\n * Is this failure the SESSION being refused rather than a request going wrong?\n *\n * The distinction earns its own function because the two look identical to\n * `accessAfterHeartbeatFailure` and are not identical at all. A blip is over by\n * the next beat. A 401 is not: the token this editor is sending has expired, so\n * every future beat carries the same rejected credential and is refused for the\n * same reason, thirty seconds apart, for as long as the tab stays open.\n * Production ran exactly that for eleven pages over two weeks, one session\n * reaching 2,730 consecutive rejections in 23.6 hours.\n *\n * What it must NOT do is take the document away. Failing open is right and\n * stays: the save-time version check is what actually protects the work, and\n * refusing to let somebody edit because a courtesy call was refused is the\n * worse bug. Granting access and continuing to BEAT are separable, which is the\n * whole insight — the beat stops, the access does not move, and the editor\n * recovers on its own the moment the host bridges a fresh token, because that\n * restarts the claim from the top.\n */\n const isAuthRejection = (err: unknown): boolean => {\n const f = readFailure(err);\n return f !== null && (f.status === 401 || f.status === 403);\n };\n\n /**\n * Read a refused save. Returns null for any other failure, so a network error\n * is never dressed up as a colleague's overwrite.\n */\n const readSaveConflict = (err: unknown): SaveConflict | null => {\n const f = readFailure(err);\n if (!f || f.status !== 409) return null;\n if (!f.data || typeof f.data !== 'object') return null;\n const body = f.data as Record<string, unknown>;\n if (body.code !== conflictCode) return null;\n return {\n savedBy: typeof body.saved_by === 'string' ? body.saved_by : '',\n // Compared with `=== true`: the wire value must be a real boolean. DRF\n // coerces exception-detail leaves to strings, and \"False\" is truthy here —\n // which would tell you that you overwrote your own document when a\n // colleague did. Both backends assign the payload directly to avoid it.\n savedByYou: body.saved_by_you === true,\n secondsAgo: typeof body.seconds_ago === 'number' ? body.seconds_ago : 0,\n };\n };\n\n /**\n * Build the editor's `beat`: claim the document, then keep it.\n *\n * ONE function serves the first claim, a takeover and every heartbeat, because\n * a claim made anywhere else left the taker holding a lock nothing refreshed.\n * The moment a claim GRANTS this editor the document, any write that was held\n * only because it did not have it yet goes out (card 1096) — that is the\n * release the `checking` hold never had.\n *\n * WHICH FAILURE QUESTION IS ASKED IS DECIDED BY THE `takeover` ARGUMENT, not by\n * the state the caller happens to be in. The storefront's version keyed it on\n * `previous.kind === 'checking'`, and because its take-over path set `checking`\n * before re-claiming, a take-over that FAILED was read as a first claim and\n * failed OPEN — two people editing, one lock, and the real holder never told.\n * That is the campaign side's `accessAfterTakeoverFailure` bug, in the other\n * editor, reachable from a button. A caller must therefore NOT move to\n * `checking` before a take-over: the state it was pressed from is the state a\n * failure falls back to, and it is already non-writable, so writes stay held.\n *\n * The gate itself is untouched: a 409 naming a holder still blocks, and a held\n * write is never released into a document somebody else has.\n */\n const makeClaimBeat = (deps: ClaimBeatDeps): ((takeover: boolean) => Promise<void>) => {\n // Per claim: a second interval would beat twice as often for as long as the\n // document stayed open.\n let heartbeatStarted = false;\n return async (takeover: boolean): Promise<void> => {\n let previous: EditAccess;\n let next: EditAccess;\n try {\n const lock = await deps.claim(takeover);\n if (deps.cancelled()) return;\n previous = deps.currentAccess();\n next = { kind: 'editing' };\n // The backend owns the interval, so it can never drift out of step with\n // the expiry it is paired with.\n if (!heartbeatStarted && lock.heartbeat_seconds > 0) {\n heartbeatStarted = true;\n deps.startHeartbeat(lock.heartbeat_seconds * 1000);\n }\n } catch (err) {\n if (deps.cancelled()) return;\n previous = deps.currentAccess();\n // Three different questions, and reading one as another is how somebody\n // loses an afternoon — see the note above this function.\n next = takeover\n ? accessAfterTakeoverFailure(err, previous)\n : previous.kind === 'checking'\n ? accessAfterClaimFailure(err)\n : accessAfterHeartbeatFailure(err, previous);\n // The caller has already asked for a fresh token and retried once by the\n // time this throws, so a still-refused session will refuse every future\n // beat for the same reason. Stop, rather than re-send a credential the\n // server has answered on. Deliberately AFTER the access decision and with\n // no bearing on it: the document is not taken away.\n if (isAuthRejection(err)) deps.stopHeartbeat();\n }\n deps.setAccess(next);\n // Follows the ACCESS, not the happy path: a claim that FAILS OPEN grants\n // the document just as much as one that succeeds, and the write held for it\n // is this editor's to send either way.\n if (shouldReleaseHeldWrite(previous, next, deps.heldWrite())) deps.releaseHeldWrite();\n };\n };\n\n return {\n accessAfterClaimFailure,\n accessAfterTakeoverFailure,\n accessAfterHeartbeatFailure,\n isAuthRejection,\n readSaveConflict,\n makeClaimBeat,\n };\n}\n"]}
@@ -0,0 +1,176 @@
1
+ import * as react from 'react';
2
+ import { CSSProperties, ReactNode } from 'react';
3
+
4
+ /** One row of the menu.
5
+ *
6
+ * Carried over unchanged from `efficient-admin-portal`'s `RowContextMenuItem`
7
+ * (card 66039), because the campaign designer's registry is typed against it and
8
+ * what the menus OFFER is not this card's to move. A `label` of `'—'` renders a
9
+ * separator; `header: true` renders a caption, and the FIRST such caption names
10
+ * the menu for assistive tech. */
11
+ interface EditorContextMenuItem {
12
+ label: string;
13
+ onClick: () => void;
14
+ danger?: boolean;
15
+ header?: boolean;
16
+ /** A SUBMENU: this item opens a flyout of these instead of acting itself.
17
+ * One level only — a child carrying its own `children` is drawn as a plain
18
+ * item, which is all any caller has needed and keeps the flyout from becoming
19
+ * a tree nobody can navigate with a mouse. */
20
+ children?: EditorContextMenuItem[];
21
+ /** Draw a tick beside this item — for a submenu whose entries are STATES
22
+ * rather than actions, so it can say which one you are already on. */
23
+ checked?: boolean;
24
+ /**
25
+ * A glyph for the row, drawn before the label.
26
+ *
27
+ * ⚠️ A TYPE SLOT, NOT A LOOKUP. The box renders what it is handed and does
28
+ * not know what a glyph means — it must never import the icon set, or the
29
+ * leaf stops being a leaf (`tests/menu-leaf-safety.test.ts` (a) fails if it
30
+ * tries). Hosts pass an element from `editor-shell/icons`, whose four
31
+ * menu glyphs — copy, paste, move-up, move-down — shipped in 0.30.0 for
32
+ * exactly this row.
33
+ *
34
+ * OMIT IT AND NOTHING IS DRAWN — not an empty span. That is what keeps the
35
+ * admin portal's three shipped surfaces byte-identical, and it is pinned by
36
+ * `tests/menu-layer.test.tsx` (g).
37
+ */
38
+ icon?: ReactNode;
39
+ /**
40
+ * The keyboard shortcut, drawn right-aligned after the label (the `.k`
41
+ * column of the approved drawing).
42
+ *
43
+ * Display text, not a binding — the box neither registers nor honours it;
44
+ * the host owns the key handling. `aria-hidden`, like the tick and the
45
+ * chevron: "Duplicate ⌘D" read aloud is worse than "Duplicate".
46
+ *
47
+ * OMIT IT AND NOTHING IS DRAWN. Same guard as `icon`.
48
+ */
49
+ shortcut?: string;
50
+ }
51
+ /**
52
+ * What the box is dressed in.
53
+ *
54
+ * ⚠️ A SKIN IS TOTAL. Pass one and it replaces {@link ES_MENU_SKIN} outright —
55
+ * nothing from the default is merged in underneath. That is not tidiness, it is
56
+ * the only arrangement that keeps the admin portal's three shipped render sites
57
+ * looking the way they do:
58
+ *
59
+ * · their look is Tailwind classes, and Tailwind v4 scans the project root and
60
+ * NOT `node_modules` (`efficient-admin-portal/src/index.css:13-22` says so in
61
+ * its own words, and adds `@source` lines for react-os-shell because of it);
62
+ * · so those class strings have to keep living in that project's own `src/`,
63
+ * where its build still sees them — they cannot move into this package;
64
+ * · and a merge would append a class of ours to a caller's string, which is
65
+ * the one thing that stops the rendered markup being byte-identical.
66
+ *
67
+ * A slot left empty is left empty. The default fills all of them.
68
+ */
69
+ interface EditorContextMenuSkin {
70
+ /** The box. */
71
+ menu?: string;
72
+ /** The FLYOUT box. Falls back to {@link menu} when a skin gives only one —
73
+ * they are the same surface in the default look, but not everywhere: the
74
+ * admin portal's box carries `overflow-y-auto` (it takes a `maxHeight`) and
75
+ * its flyout does not. Collapsing the two would quietly change the markup of
76
+ * a surface already in front of users. */
77
+ menuSub?: string;
78
+ /** A plain row. */
79
+ item?: string;
80
+ /** A row that destroys something. */
81
+ itemDanger?: string;
82
+ /** The `'—'` separator. */
83
+ divider?: string;
84
+ /** A `header: true` caption. */
85
+ header?: string;
86
+ /** The label span of a row that has something beside it — a chevron, a tick.
87
+ * A row that is only a label renders bare text and takes no class at all,
88
+ * which is what the two grid callers have always drawn. */
89
+ label?: string;
90
+ /** The label span inside a FLYOUT. Falls back to {@link label} when a skin
91
+ * gives only one — the admin portal wants them different, because a state
92
+ * name in a flyout can be long enough to need truncating and a row in the box
93
+ * never is. */
94
+ labelSub?: string;
95
+ /** The glyph column, when a row carries an `icon`. */
96
+ icon?: string;
97
+ /** The right-aligned shortcut column, when a row carries a `shortcut`. */
98
+ shortcut?: string;
99
+ /** The `›` on a row that opens a flyout. */
100
+ chevron?: string;
101
+ /** The tick column of a flyout whose entries are states. */
102
+ tick?: string;
103
+ /** Merged into the box's own inline style, under the placement this component
104
+ * computes — so a caller can hand it a surface (the admin passes
105
+ * `glassStyle()` from react-os-shell) without being able to move it. */
106
+ surface?: CSSProperties;
107
+ /** Merged into each row's inline style. */
108
+ itemStyle?: CSSProperties;
109
+ }
110
+ interface EditorContextMenuProps {
111
+ /** Where the pointer was. The box is placed from here and then kept on
112
+ * screen — see the note on the component. */
113
+ x: number;
114
+ y: number;
115
+ items: EditorContextMenuItem[];
116
+ onClose: () => void;
117
+ /** Cap the box's height; it scrolls past that. */
118
+ maxHeight?: number;
119
+ /** Defaults to {@link ES_MENU_SKIN}, the storefront editor's approved look. */
120
+ skin?: EditorContextMenuSkin;
121
+ /**
122
+ * Where to portal to. Defaults to `document.body`.
123
+ *
124
+ * A host that OVERRIDES `--es-*` on its own editor root should pass that root
125
+ * instead: the default skin re-declares the token defaults on the box (see the
126
+ * component's note), which is right for a box hanging off `<body>` and wrong
127
+ * for one that should inherit a re-themed editor's values.
128
+ */
129
+ portalTo?: HTMLElement | null;
130
+ }
131
+
132
+ /**
133
+ * The right-click menu box, shared by both editors (card 66400.d).
134
+ *
135
+ * MOVED HERE, NOT WRITTEN HERE. This is `efficient-admin-portal`'s
136
+ * `src/components/RowContextMenu.tsx` — portal placement, viewport edge-flip,
137
+ * a one-level submenu with ticks, focus move-in and restore, Escape and
138
+ * outside-click — which had been in front of real users on three surfaces
139
+ * (price sheets, the goods-issue dialog, the campaign canvas) before the
140
+ * storefront editor needed the same affordance. The alternative was those same
141
+ * 230 lines written a second time.
142
+ *
143
+ * ONE THING CHANGED IN THE MOVE, and it is the reason the move is worth doing:
144
+ * the box no longer knows what it looks like. The admin version hard-coded ONE
145
+ * editor's surface — `glassStyle()` from react-os-shell, plus that portal's
146
+ * Tailwind palette — into the only box both editors were ever going to share.
147
+ * Here the look is a {@link EditorContextMenuSkin}, the default is the
148
+ * storefront's approved one, and the admin passes its own strings in. So this
149
+ * module imports nothing but React (`tests/menu-leaf-safety.test.ts`), and the
150
+ * admin's rendered markup is byte-identical to what shipped
151
+ * (`tests/menu-layer.test.tsx` (e)).
152
+ *
153
+ * A CLIENT LEAF, like the gold layer and unlike the rail: it portals, it
154
+ * measures and it moves focus, so it has hooks and touches the DOM. A Next-16
155
+ * server component may import the module and must render it inside the host's
156
+ * client boundary.
157
+ */
158
+ declare function EditorContextMenu({ x, y, items, onClose, maxHeight, skin, portalTo, }: EditorContextMenuProps): react.ReactPortal | null;
159
+
160
+ /**
161
+ * The default skin — the storefront editor's approved look, in classes that
162
+ * `editor-shell/menu.css` dresses.
163
+ *
164
+ * ⚠️ `es-editor` IS LOAD-BEARING AND IS NOT DECORATION. `shell.css` declares
165
+ * every `--es-*` on `.es-editor`, deliberately never on `:root`, so importing it
166
+ * themes an editor root and leaks nothing into the host page — the campaign
167
+ * designer renders INSIDE the admin DOM. The box is portalled to
168
+ * `document.body`, which is OUTSIDE that root, so without the class here every
169
+ * `var(--es-*)` in `menu.css` resolves to nothing: the background collapses to
170
+ * transparent and the border to `currentColor`. The result is markup that is
171
+ * present, answers every query a test can ask, and cannot be seen. Pinned by
172
+ * `tests/menu-layer.test.tsx` (f).
173
+ */
174
+ declare const ES_MENU_SKIN: EditorContextMenuSkin;
175
+
176
+ export { ES_MENU_SKIN, EditorContextMenu, type EditorContextMenuItem, type EditorContextMenuProps, type EditorContextMenuSkin };
@@ -0,0 +1,202 @@
1
+ import { useRef, useState, useLayoutEffect, useEffect } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
4
+
5
+ // src/menu/EditorContextMenu.tsx
6
+
7
+ // src/menu/skin.ts
8
+ var ES_MENU_SKIN = {
9
+ menu: "es-editor es-menu",
10
+ menuSub: "es-editor es-menu",
11
+ item: "es-menu-item",
12
+ itemDanger: "es-menu-item is-danger",
13
+ divider: "es-menu-divider",
14
+ header: "es-menu-header",
15
+ icon: "es-menu-icon",
16
+ shortcut: "es-menu-shortcut",
17
+ label: "es-menu-label",
18
+ labelSub: "es-menu-label es-menu-label-sub",
19
+ chevron: "es-menu-chevron",
20
+ tick: "es-menu-tick"
21
+ };
22
+ function EditorContextMenu({
23
+ x,
24
+ y,
25
+ items,
26
+ onClose,
27
+ maxHeight,
28
+ skin,
29
+ portalTo
30
+ }) {
31
+ const menuRef = useRef(null);
32
+ const subRef = useRef(null);
33
+ const [sub, setSub] = useState(null);
34
+ const [at, setAt] = useState({ x, y });
35
+ const [openedAt, setOpenedAt] = useState({ x, y });
36
+ if (openedAt.x !== x || openedAt.y !== y) {
37
+ setOpenedAt({ x, y });
38
+ setAt({ x, y });
39
+ }
40
+ useLayoutEffect(() => {
41
+ const el = menuRef.current;
42
+ if (!el) return;
43
+ const { width, height } = el.getBoundingClientRect();
44
+ const margin = 8;
45
+ const room = { w: window.innerWidth, h: window.innerHeight };
46
+ let nextY = y;
47
+ if (y + height > room.h - margin) {
48
+ nextY = y - height >= margin ? y - height : Math.max(margin, room.h - height - margin);
49
+ }
50
+ let nextX = x;
51
+ if (x + width > room.w - margin) {
52
+ nextX = x - width >= margin ? x - width : Math.max(margin, room.w - width - margin);
53
+ }
54
+ if (nextX !== at.x || nextY !== at.y) setAt({ x: nextX, y: nextY });
55
+ }, [x, y, items, at.x, at.y]);
56
+ useEffect(() => {
57
+ const returnTo = document.activeElement;
58
+ menuRef.current?.querySelector('[role="menuitem"]')?.focus();
59
+ return () => returnTo?.focus?.();
60
+ }, []);
61
+ useEffect(() => {
62
+ const handler = (e) => {
63
+ const target = e.target;
64
+ if (subRef.current?.contains(target)) return;
65
+ if (menuRef.current && !menuRef.current.contains(target)) onClose();
66
+ };
67
+ const escHandler = (e) => {
68
+ if (e.key === "Escape") onClose();
69
+ };
70
+ const armed = setTimeout(() => {
71
+ window.addEventListener("mousedown", handler);
72
+ window.addEventListener("contextmenu", handler);
73
+ window.addEventListener("keydown", escHandler);
74
+ }, 0);
75
+ return () => {
76
+ clearTimeout(armed);
77
+ window.removeEventListener("mousedown", handler);
78
+ window.removeEventListener("contextmenu", handler);
79
+ window.removeEventListener("keydown", escHandler);
80
+ };
81
+ }, [onClose]);
82
+ if (items.length === 0) return null;
83
+ const s = skin ?? ES_MENU_SKIN;
84
+ const itemClass = (item) => item.danger ? s.itemDanger : s.item;
85
+ const Tick = ({ on }) => /* @__PURE__ */ jsx("span", { className: s.tick, "aria-hidden": "true", children: on ? "\u2713" : "" });
86
+ const Icon = ({ of: item }) => item.icon == null ? null : /* @__PURE__ */ jsx("span", { className: s.icon, "aria-hidden": "true", children: item.icon });
87
+ const Shortcut = ({ of: item }) => item.shortcut == null ? null : /* @__PURE__ */ jsx("span", { className: s.shortcut, "aria-hidden": "true", children: item.shortcut });
88
+ const decorated = (item) => item.icon != null || item.shortcut != null;
89
+ const openSub = (item, i, el) => {
90
+ if (!item.children?.length) {
91
+ setSub(null);
92
+ return;
93
+ }
94
+ const r = el.getBoundingClientRect();
95
+ const box = menuRef.current;
96
+ const floor = box ? parseFloat(getComputedStyle(box).minWidth) : NaN;
97
+ const width = Number.isFinite(floor) && floor > 0 ? floor : box?.getBoundingClientRect().width ?? 0;
98
+ const left = r.right + width > window.innerWidth - 8 ? r.left - width : r.right;
99
+ setSub({ i, x: Math.max(8, left), y: r.top });
100
+ };
101
+ const open = sub ? items[sub.i] : null;
102
+ const surface = (extra) => ({
103
+ ...s.surface,
104
+ ...extra
105
+ });
106
+ return createPortal(
107
+ /* @__PURE__ */ jsxs(Fragment, { children: [
108
+ /* @__PURE__ */ jsx(
109
+ "div",
110
+ {
111
+ ref: menuRef,
112
+ "data-es-menu": "",
113
+ role: "menu",
114
+ "aria-label": items.find((i) => i.header)?.label,
115
+ className: s.menu,
116
+ style: surface({ left: at.x, top: at.y, maxHeight: maxHeight || void 0 }),
117
+ children: items.map((item, i) => item.label === "\u2014" ? (
118
+ // `role="separator"`, because a bare <div> inside a `menu` is a
119
+ // generic child that assistive tech reads as part of the list.
120
+ /* @__PURE__ */ jsx("div", { role: "separator", className: s.divider }, i)
121
+ ) : item.header ? /* @__PURE__ */ jsx("div", { className: s.header, children: item.label }, i) : item.children?.length ? /* @__PURE__ */ jsxs(
122
+ "button",
123
+ {
124
+ type: "button",
125
+ role: "menuitem",
126
+ "aria-haspopup": "menu",
127
+ "aria-expanded": sub?.i === i,
128
+ onMouseEnter: (e) => openSub(item, i, e.currentTarget),
129
+ onFocus: (e) => openSub(item, i, e.currentTarget),
130
+ onClick: (e) => openSub(item, i, e.currentTarget),
131
+ className: itemClass(item),
132
+ style: s.itemStyle,
133
+ children: [
134
+ /* @__PURE__ */ jsx(Icon, { of: item }),
135
+ /* @__PURE__ */ jsx("span", { className: s.label, children: item.label }),
136
+ /* @__PURE__ */ jsx(Shortcut, { of: item }),
137
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: s.chevron, children: "\u203A" })
138
+ ]
139
+ },
140
+ i
141
+ ) : /* @__PURE__ */ jsx(
142
+ "button",
143
+ {
144
+ type: "button",
145
+ role: "menuitem",
146
+ onMouseEnter: () => setSub(null),
147
+ onClick: () => {
148
+ item.onClick();
149
+ onClose();
150
+ },
151
+ className: itemClass(item),
152
+ style: s.itemStyle,
153
+ children: decorated(item) ? /* @__PURE__ */ jsxs(Fragment, { children: [
154
+ /* @__PURE__ */ jsx(Icon, { of: item }),
155
+ /* @__PURE__ */ jsx("span", { className: s.label, children: item.label }),
156
+ /* @__PURE__ */ jsx(Shortcut, { of: item })
157
+ ] }) : item.label
158
+ },
159
+ i
160
+ ))
161
+ }
162
+ ),
163
+ open?.children?.length && sub ? /* @__PURE__ */ jsx(
164
+ "div",
165
+ {
166
+ ref: subRef,
167
+ "data-es-menu": "",
168
+ role: "menu",
169
+ "aria-label": open.label,
170
+ className: s.menuSub ?? s.menu,
171
+ style: surface({ left: sub.x, top: sub.y }),
172
+ children: open.children.map((child, ci) => /* @__PURE__ */ jsxs(
173
+ "button",
174
+ {
175
+ type: "button",
176
+ role: "menuitemradio",
177
+ "aria-checked": Boolean(child.checked),
178
+ onClick: () => {
179
+ child.onClick();
180
+ onClose();
181
+ },
182
+ className: itemClass(child),
183
+ style: s.itemStyle,
184
+ children: [
185
+ /* @__PURE__ */ jsx(Tick, { on: Boolean(child.checked) }),
186
+ /* @__PURE__ */ jsx(Icon, { of: child }),
187
+ /* @__PURE__ */ jsx("span", { className: s.labelSub ?? s.label, children: child.label }),
188
+ /* @__PURE__ */ jsx(Shortcut, { of: child })
189
+ ]
190
+ },
191
+ ci
192
+ ))
193
+ }
194
+ ) : null
195
+ ] }),
196
+ portalTo ?? document.body
197
+ );
198
+ }
199
+
200
+ export { ES_MENU_SKIN, EditorContextMenu };
201
+ //# sourceMappingURL=index.js.map
202
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/menu/skin.ts","../../src/menu/EditorContextMenu.tsx"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { EditorContextMenuSkin } from './types';\n\n/**\n * The default skin — the storefront editor's approved look, in classes that\n * `editor-shell/menu.css` dresses.\n *\n * ⚠️ `es-editor` IS LOAD-BEARING AND IS NOT DECORATION. `shell.css` declares\n * every `--es-*` on `.es-editor`, deliberately never on `:root`, so importing it\n * themes an editor root and leaks nothing into the host page — the campaign\n * designer renders INSIDE the admin DOM. The box is portalled to\n * `document.body`, which is OUTSIDE that root, so without the class here every\n * `var(--es-*)` in `menu.css` resolves to nothing: the background collapses to\n * transparent and the border to `currentColor`. The result is markup that is\n * present, answers every query a test can ask, and cannot be seen. Pinned by\n * `tests/menu-layer.test.tsx` (f).\n */\nexport const ES_MENU_SKIN: EditorContextMenuSkin = {\n menu: 'es-editor es-menu',\n menuSub: 'es-editor es-menu',\n item: 'es-menu-item',\n itemDanger: 'es-menu-item is-danger',\n divider: 'es-menu-divider',\n header: 'es-menu-header',\n icon: 'es-menu-icon',\n shortcut: 'es-menu-shortcut',\n label: 'es-menu-label',\n labelSub: 'es-menu-label es-menu-label-sub',\n chevron: 'es-menu-chevron',\n tick: 'es-menu-tick',\n};\n","import { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { ES_MENU_SKIN } from './skin';\nimport type { EditorContextMenuItem, EditorContextMenuProps } from './types';\n\n/**\n * The right-click menu box, shared by both editors (card 66400.d).\n *\n * MOVED HERE, NOT WRITTEN HERE. This is `efficient-admin-portal`'s\n * `src/components/RowContextMenu.tsx` — portal placement, viewport edge-flip,\n * a one-level submenu with ticks, focus move-in and restore, Escape and\n * outside-click — which had been in front of real users on three surfaces\n * (price sheets, the goods-issue dialog, the campaign canvas) before the\n * storefront editor needed the same affordance. The alternative was those same\n * 230 lines written a second time.\n *\n * ONE THING CHANGED IN THE MOVE, and it is the reason the move is worth doing:\n * the box no longer knows what it looks like. The admin version hard-coded ONE\n * editor's surface — `glassStyle()` from react-os-shell, plus that portal's\n * Tailwind palette — into the only box both editors were ever going to share.\n * Here the look is a {@link EditorContextMenuSkin}, the default is the\n * storefront's approved one, and the admin passes its own strings in. So this\n * module imports nothing but React (`tests/menu-leaf-safety.test.ts`), and the\n * admin's rendered markup is byte-identical to what shipped\n * (`tests/menu-layer.test.tsx` (e)).\n *\n * A CLIENT LEAF, like the gold layer and unlike the rail: it portals, it\n * measures and it moves focus, so it has hooks and touches the DOM. A Next-16\n * server component may import the module and must render it inside the host's\n * client boundary.\n */\nexport function EditorContextMenu({\n x, y, items, onClose, maxHeight, skin, portalTo,\n}: EditorContextMenuProps) {\n const menuRef = useRef<HTMLDivElement>(null);\n // The open submenu — which item it belongs to, and where its flyout sits.\n const subRef = useRef<HTMLDivElement>(null);\n const [sub, setSub] = useState<{ i: number; x: number; y: number } | null>(null);\n // Where the box ACTUALLY lands, once it has been measured — see `useLayoutEffect`.\n const [at, setAt] = useState({ x, y });\n // Re-seed from the pointer whenever the menu is opened somewhere new, so the\n // measurement below starts from the real gesture rather than from where the\n // last one ended up. Done as a render-time adjustment keyed on the transition\n // (React's sanctioned alternative to a setState-in-effect).\n const [openedAt, setOpenedAt] = useState({ x, y });\n if (openedAt.x !== x || openedAt.y !== y) {\n setOpenedAt({ x, y });\n setAt({ x, y });\n }\n\n /**\n * Keep the box on screen.\n *\n * `x`/`y` are where the pointer was, and a box drawn straight from there runs\n * off the bottom whenever the click was within its own height of the viewport\n * edge — measured in the campaign designer at a 560px-tall window: it\n * overflowed by 131px and its LAST item, `Delete`, was entirely off screen\n * with no way to reach it.\n *\n * Flip rather than merely clamp: sliding the box up until it fits would park\n * it under the pointer, so the item beneath the cursor is no longer the one\n * the gesture aimed at. Opening UPWARDS keeps that relationship, which is what\n * every native menu does. Only then clamp, for the case where the box is\n * taller than the viewport on both sides.\n *\n * Measured in a layout effect (before paint) so it is never seen in the wrong\n * place, and re-run per open because the item list changes its height.\n */\n useLayoutEffect(() => {\n const el = menuRef.current;\n if (!el) return;\n const { width, height } = el.getBoundingClientRect();\n const margin = 8;\n const room = { w: window.innerWidth, h: window.innerHeight };\n let nextY = y;\n if (y + height > room.h - margin) {\n // Above the pointer if it fits there, else pinned to the bottom edge.\n nextY = y - height >= margin ? y - height : Math.max(margin, room.h - height - margin);\n }\n // The same for the right edge, which a box opened near it would otherwise\n // overflow — cheap to do here and the identical class of bug.\n let nextX = x;\n if (x + width > room.w - margin) {\n nextX = x - width >= margin ? x - width : Math.max(margin, room.w - width - margin);\n }\n if (nextX !== at.x || nextY !== at.y) setAt({ x: nextX, y: nextY });\n }, [x, y, items, at.x, at.y]);\n\n /**\n * Move focus INTO the box when it opens, and put it back when it closes.\n *\n * Without this the menu is openable from the keyboard and then unusable: the\n * box is portalled to the end of its container, so focus is left behind on\n * whatever was right-clicked and Tab walks the rest of the page before\n * reaching the items. Restoring on close matters just as much — Escape from a\n * menu that dropped focus on the floor leaves a keyboard user at the top of\n * the document, having lost the row they were working on.\n */\n useEffect(() => {\n const returnTo = document.activeElement as HTMLElement | null;\n menuRef.current?.querySelector<HTMLElement>('[role=\"menuitem\"]')?.focus();\n return () => returnTo?.focus?.();\n }, []);\n\n useEffect(() => {\n const handler = (e: MouseEvent) => {\n const target = e.target as Node;\n // The flyout is a SEPARATE portal, so \"outside the menu box\" would other-\n // wise include it — and this fires on mousedown, a beat before the child's\n // own click, which would unmount the flyout out from under the gesture.\n if (subRef.current?.contains(target)) return;\n if (menuRef.current && !menuRef.current.contains(target)) onClose();\n };\n const escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };\n // Use a timeout so the opening right-click doesn't immediately close it.\n const armed = setTimeout(() => {\n window.addEventListener('mousedown', handler);\n window.addEventListener('contextmenu', handler);\n window.addEventListener('keydown', escHandler);\n }, 0);\n return () => {\n clearTimeout(armed);\n window.removeEventListener('mousedown', handler);\n window.removeEventListener('contextmenu', handler);\n window.removeEventListener('keydown', escHandler);\n };\n }, [onClose]);\n\n if (items.length === 0) return null;\n\n // TOTAL, not merged — see the note on EditorContextMenuSkin. A caller's string\n // is what renders, with nothing of ours appended to it.\n const s = skin ?? ES_MENU_SKIN;\n const itemClass = (item: EditorContextMenuItem) => (item.danger ? s.itemDanger : s.item);\n\n /** A tick column, present on every row of a flyout that uses one, so the\n * labels line up whether or not the row is the current state. */\n const Tick = ({ on }: { on: boolean }) => (\n <span className={s.tick} aria-hidden=\"true\">{on ? '✓' : ''}</span>\n );\n\n /** The glyph before the label, and the shortcut after it — the `.ci` and `.k`\n * columns of the approved drawing.\n *\n * ⚠️ DRAWN ONLY WHEN GIVEN. An item carrying neither renders the bare label\n * it always did — not a pair of empty spans. That is the whole reason the\n * admin portal's three shipped surfaces come out byte-identical, and it is\n * what `tests/menu-layer.test.tsx` (g) pins. Both are `aria-hidden`, like the\n * tick and the chevron: the label already carries the meaning, and\n * \"Duplicate ⌘D\" read aloud is worse than \"Duplicate\". */\n const Icon = ({ of: item }: { of: EditorContextMenuItem }) =>\n item.icon == null ? null : <span className={s.icon} aria-hidden=\"true\">{item.icon}</span>;\n\n const Shortcut = ({ of: item }: { of: EditorContextMenuItem }) =>\n item.shortcut == null ? null : <span className={s.shortcut} aria-hidden=\"true\">{item.shortcut}</span>;\n\n /** Whether a row needs a label SPAN at all. A plain row with no decoration is\n * bare text inside its button, exactly as it has always been. */\n const decorated = (item: EditorContextMenuItem) =>\n item.icon != null || item.shortcut != null;\n\n /** Open `item`'s flyout beside its row. Measured now rather than positioned in\n * flow, because the flyout is portalled separately — the box scrolls\n * (`overflow-y: auto` for `maxHeight`) and a CSS overflow that is not\n * `visible` on one axis clips the other too, so an in-flow flyout would be\n * cut off at the box's edge. */\n const openSub = (item: EditorContextMenuItem, i: number, el: HTMLElement) => {\n if (!item.children?.length) {\n setSub(null);\n return;\n }\n const r = el.getBoundingClientRect();\n /* How wide the flyout will be, MEASURED rather than typed.\n *\n * It wears the same skin as the box, so its floor is the box's floor — read\n * off the live box instead of restated here. This was the literal `220` for\n * one release, with a comment claiming it tracked `--es-menu-min-w`; when\n * the fork ruled that floor down to 186 the stylesheet followed and this did\n * not, and a comment that says \"derived\" over a number that is typed is\n * exactly the line nobody re-reads. Measuring removes the second copy\n * instead of guarding it.\n *\n * It is also the only version that is right for BOTH skins: the default's\n * floor comes from `--es-menu-min-w`, and the admin portal's from its own\n * `min-w-[220px]`. A constant would have to be wrong for one of them.\n *\n * Falls back to what the box actually measures when a skin sets no floor at\n * all — `min-width` is then `auto`/`0px` and parses to nothing useful. */\n const box = menuRef.current;\n const floor = box ? parseFloat(getComputedStyle(box).minWidth) : NaN;\n const width = Number.isFinite(floor) && floor > 0\n ? floor\n : box?.getBoundingClientRect().width ?? 0;\n // Flip to the left when there is no room to the right, the same rule the box\n // itself follows for the viewport edge.\n const left = r.right + width > window.innerWidth - 8 ? r.left - width : r.right;\n setSub({ i, x: Math.max(8, left), y: r.top });\n };\n\n const open = sub ? items[sub.i] : null;\n const surface = (extra: { left: number; top: number; maxHeight?: number }) => ({\n ...s.surface,\n ...extra,\n });\n\n return createPortal(\n <>\n {/* A real `menu` role, named by its own caption row. Assistive tech was\n given a bare <div> of buttons before — announced one at a time with no\n hint they belonged together, and nothing saying WHAT they act on. The\n name comes from the first `header` item rather than a new prop so there\n is still exactly one place a caller writes that name. */}\n <div\n ref={menuRef}\n data-es-menu=\"\"\n role=\"menu\"\n aria-label={items.find((i) => i.header)?.label}\n className={s.menu}\n style={surface({ left: at.x, top: at.y, maxHeight: maxHeight || undefined })}\n >\n {items.map((item, i) => item.label === '—' ? (\n // `role=\"separator\"`, because a bare <div> inside a `menu` is a\n // generic child that assistive tech reads as part of the list.\n <div key={i} role=\"separator\" className={s.divider} />\n ) : item.header ? (\n <div key={i} className={s.header}>{item.label}</div>\n ) : item.children?.length ? (\n <button\n key={i}\n type=\"button\"\n role=\"menuitem\"\n aria-haspopup=\"menu\"\n aria-expanded={sub?.i === i}\n onMouseEnter={(e) => openSub(item, i, e.currentTarget)}\n onFocus={(e) => openSub(item, i, e.currentTarget)}\n onClick={(e) => openSub(item, i, e.currentTarget)}\n className={itemClass(item)}\n style={s.itemStyle}\n >\n <Icon of={item} />\n <span className={s.label}>{item.label}</span>\n <Shortcut of={item} />\n <span aria-hidden=\"true\" className={s.chevron}>›</span>\n </button>\n ) : (\n <button\n key={i}\n type=\"button\"\n role=\"menuitem\"\n onMouseEnter={() => setSub(null)}\n onClick={() => { item.onClick(); onClose(); }}\n className={itemClass(item)}\n style={s.itemStyle}\n >\n {decorated(item) ? (\n <>\n <Icon of={item} />\n <span className={s.label}>{item.label}</span>\n <Shortcut of={item} />\n </>\n ) : item.label}\n </button>\n ))}\n </div>\n {open?.children?.length && sub ? (\n <div\n ref={subRef}\n data-es-menu=\"\"\n role=\"menu\"\n aria-label={open.label}\n className={s.menuSub ?? s.menu}\n style={surface({ left: sub.x, top: sub.y })}\n >\n {open.children.map((child, ci) => (\n <button\n key={ci}\n type=\"button\"\n role=\"menuitemradio\"\n aria-checked={Boolean(child.checked)}\n onClick={() => { child.onClick(); onClose(); }}\n className={itemClass(child)}\n style={s.itemStyle}\n >\n <Tick on={Boolean(child.checked)} />\n <Icon of={child} />\n <span className={s.labelSub ?? s.label}>{child.label}</span>\n <Shortcut of={child} />\n </button>\n ))}\n </div>\n ) : null}\n </>,\n portalTo ?? document.body,\n );\n}\n"]}
@@ -0,0 +1,232 @@
1
+ /*
2
+ * editor-shell/menu.css — the right-click menu box (card 66400.d).
3
+ *
4
+ * THE VALUES ARE NOT OURS. They are the storefront editor's shipped
5
+ * `.sf-blockmenu` / `.sf-blockmenu-item` — `efficient-shop/components/storefront-editor.css`,
6
+ * read at line 1165 — which is the look card 66400.d names for the menu Lewis
7
+ * approved on 2026-08-31. Each `--sf-*` there is already an alias of the
8
+ * matching `--es-*` here (card 66024), so the look is expressed with no new
9
+ * value except the three noted below. Change one only to track a deliberate
10
+ * change THERE. Pinned by `tests/menu-box-css.test.ts`.
11
+ *
12
+ * ⚠️ THREE VALUES ARE UNDER A LIVE QUESTION, and each is behind ONE var so
13
+ * settling it is a three-line change. The drawing Lewis approved that same day
14
+ * (`mockups/editor-add-and-context-v1.html`, its `.ctx` rule) is a FLOATING
15
+ * menu, where `.sf-blockmenu` is an in-flow picker, and it holds different
16
+ * numbers for exactly the three things that difference would move:
17
+ *
18
+ * .sf-blockmenu (shipped) .ctx (the drawing)
19
+ * radius 8px 9px
20
+ * shadow 0 6px 18px rgba(0,0,0,.08) 0 10px 28px rgba(0,0,0,.16)
21
+ * min-width 220px (the admin box's own) 186px
22
+ *
23
+ * This sheet ships `.sf-blockmenu`'s, per the card's own constraint. The
24
+ * question is with the fork that owns the design.
25
+ *
26
+ * LIGHT only — neither editing surface has a dark mode (owner, 2026-08-06), so
27
+ * there is no dark counterpart block here. Do not add one.
28
+ *
29
+ * Import once from a client entry / global stylesheet:
30
+ * import 'editor-shell/menu.css';
31
+ */
32
+
33
+ /* ── this sheet's own vars ──────────────────────────────────────────────────
34
+ ON `.es-editor`, the chrome root, following `rail.css` (which owns
35
+ `--es-rail-*`) and `panel.css` (`--es-row-gap`): the sheet that owns the
36
+ rules owns its tokens, and `shell.css` stays a verbatim mirror of the
37
+ storefront editor's `--sf-*` block. None of these is a shell token.
38
+
39
+ ⚠️ The box itself names `es-editor` (see `skin.ts`) — it is portalled to
40
+ `document.body`, outside the editor tree, so this is also where it picks these
41
+ up. Read that note before moving them. */
42
+ .es-editor {
43
+ /* HOW FAR THE BOX FLOATS OFF THE PAGE BEHIND IT.
44
+ The drawing's value. `.sf-blockmenu` — the shop's shipped Add-block picker
45
+ — carries `0 6px 18px rgba(0,0,0,.08)`, and this is deliberately heavier:
46
+ that picker sits IN FLOW inside a panel, while this box FLOATS over
47
+ content, and a floating box has to separate itself from whatever is under
48
+ it. The drawing's own header cites `.sf-blockmenu`, so its author had that
49
+ value open and moved off it on purpose.
50
+ This is the CONTEXT half of the split described on `--es-menu-hover-bg`:
51
+ it follows the situation, not the neighbour. */
52
+ --es-menu-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
53
+
54
+ /* HOW SOFT ITS CORNERS ARE. The drawing's 9px, one more than the in-flow
55
+ picker's 8 — the same floating-vs-in-flow distinction. */
56
+ --es-menu-radius: 9px;
57
+
58
+ /* THE NARROWEST THE BOX MAY GET — a floor, not a width.
59
+ A menu that shrank to its longest label would change width with its
60
+ contents, so a merchant's pointer would land on a different row depending
61
+ on which label happened to be longest. The drawing's 186px. It is also the
62
+ width the flyout measures against when it decides to flip left. */
63
+ --es-menu-min-w: 186px;
64
+
65
+ /* WHAT A ROW DOES UNDER THE POINTER — the one thing a merchant watches move.
66
+ ⚠️ THIS ONE DOES NOT FOLLOW THE DRAWING, AND THAT IS DELIBERATE. The
67
+ drawing's `.ctx` washes the row GREY and leaves the text alone. This washes
68
+ the accent tint and turns the text blue, which is `.sf-blockmenu` — the Add
69
+ menu, one keystroke away in the same editor.
70
+
71
+ Owner ruling, 2026-09-02, recorded as a dated amendment in the
72
+ `owner-design-rulings` skill ("the right-click menu hovers BLUE, not the
73
+ drawing's grey"): it was put to him rather than decided, because two menus
74
+ in one editor highlighting differently is a rule-zero failure. His word was
75
+ blue.
76
+
77
+ ⛔ DO NOT "RESTORE" THE DRAWING'S GREY. The split from the three values
78
+ above is not an inconsistency, it is the rule:
79
+
80
+ hover interaction grammar -> follows the NEIGHBOUR
81
+ shadow / radius / context -> follows the SITUATION
82
+ min-width (this box floats; the Add
83
+ menu sits in flow)
84
+
85
+ Still behind a var, because that is what made asking him cost nothing. */
86
+ --es-menu-hover-bg: var(--es-accent-tint);
87
+ --es-menu-hover-fg: var(--es-accent);
88
+
89
+ /* A ROW THAT DESTROYS SOMETHING.
90
+ ⚠️ The drawing is the ONLY reference for this one — `.sf-blockmenu` has no
91
+ danger row at all, so nothing is being overridden here. Its `#b91c1c` is
92
+ exactly `--es-danger-strong`, so it is read from the token layer rather
93
+ than copied as a literal. */
94
+ --es-menu-danger-fg: var(--es-danger-strong);
95
+
96
+ /* THE SHORTCUT COLUMN'S INK — quieter than a label, quieter than `--es-muted`,
97
+ because a shortcut is a reminder and never the thing being read.
98
+ ⚠️ The one value here with no shell token behind it: the drawing's own
99
+ `#b6bcc4`, which is lighter than `--es-disabled` (#9ca3af). Named here
100
+ rather than reaching for a token that is not this colour. */
101
+ --es-menu-shortcut-fg: #b6bcc4;
102
+ }
103
+
104
+
105
+ /* ── the box ────────────────────────────────────────────────────────────────
106
+ FIXED and lifted on purpose: it hangs OVER the panel it was opened from
107
+ rather than inside it, so a left panel dragged to its 220px minimum — which
108
+ clips its own overflow — cannot cut it off. Measured in Chromium at both
109
+ panel widths; see `tests/fixtures/menu-clipping.html`. */
110
+ .es-menu {
111
+ position: fixed;
112
+ z-index: 9999;
113
+ box-sizing: border-box;
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: 1px;
117
+ min-width: var(--es-menu-min-w);
118
+ /* A box that could grow past the viewport would be unreachable on its far
119
+ side whatever the edge-flip did. */
120
+ max-width: calc(100vw - 16px);
121
+ padding: 4px;
122
+ border: 1px solid var(--es-line);
123
+ border-radius: var(--es-menu-radius);
124
+ background: var(--es-panel);
125
+ box-shadow: var(--es-menu-shadow);
126
+ font-family: var(--es-font);
127
+ /* For `maxHeight`. The flyout is portalled separately BECAUSE of this: a CSS
128
+ overflow that is not `visible` on one axis clips the other too, so an
129
+ in-flow flyout would be cut off at this box's edge. */
130
+ overflow-y: auto;
131
+ overscroll-behavior: contain;
132
+ }
133
+
134
+ /* ── a row ──────────────────────────────────────────────────────────────── */
135
+ .es-menu-item {
136
+ display: flex;
137
+ align-items: center;
138
+ gap: 8px;
139
+ width: 100%;
140
+ padding: 7px 9px;
141
+ border: none;
142
+ border-radius: 6px;
143
+ background: transparent;
144
+ color: var(--es-text);
145
+ font: inherit;
146
+ font-size: 12.5px;
147
+ text-align: left;
148
+ cursor: pointer;
149
+ }
150
+
151
+ /* The one thing a merchant watches move — see `--es-menu-hover-*` above for
152
+ which reference this follows and why it is behind a var. */
153
+ .es-menu-item:hover {
154
+ background: var(--es-menu-hover-bg);
155
+ color: var(--es-menu-hover-fg);
156
+ }
157
+
158
+ .es-menu-item:focus-visible {
159
+ outline: 2px solid var(--es-accent-ring);
160
+ outline-offset: -2px;
161
+ }
162
+
163
+ .es-menu-item.is-danger {
164
+ color: var(--es-menu-danger-fg);
165
+ }
166
+ /* The drawing gives a danger row no hover of its own: it takes the same grey as
167
+ every other row and KEEPS its red, rather than washing red as well. Stated as
168
+ its own rule because `.es-menu-item:hover` would otherwise repaint the text. */
169
+ .es-menu-item.is-danger:hover {
170
+ background: var(--es-menu-hover-bg);
171
+ color: var(--es-menu-danger-fg);
172
+ }
173
+
174
+ /* ── the parts of a row ─────────────────────────────────────────────────── */
175
+ .es-menu-label {
176
+ flex: 1;
177
+ }
178
+ /* A state name in a flyout can be long; a row in the box never is. */
179
+ .es-menu-label-sub {
180
+ overflow: hidden;
181
+ text-overflow: ellipsis;
182
+ white-space: nowrap;
183
+ }
184
+
185
+ .es-menu-chevron {
186
+ color: var(--es-icon);
187
+ }
188
+
189
+ /* The drawing's `.ci` — a fixed 16px column so labels line up whether or not a
190
+ row has a glyph, exactly as the tick column does in a flyout. */
191
+ .es-menu-icon {
192
+ flex: none;
193
+ width: 16px;
194
+ text-align: center;
195
+ color: var(--es-muted);
196
+ font-size: 11px;
197
+ }
198
+
199
+ /* The drawing's `.k`. `margin-left: auto` is what right-aligns it, so it works
200
+ whether or not the row also carries a chevron. */
201
+ .es-menu-shortcut {
202
+ margin-left: auto;
203
+ color: var(--es-menu-shortcut-fg);
204
+ font-size: 11px;
205
+ }
206
+
207
+ /* Present on every row of a flyout that uses one, so the labels line up whether
208
+ or not the row is the state you are already on. */
209
+ .es-menu-tick {
210
+ flex: none;
211
+ width: 12px;
212
+ color: var(--es-accent);
213
+ }
214
+
215
+ /* ── a caption, and a separator ─────────────────────────────────────────── */
216
+ .es-menu-header {
217
+ padding: 6px 9px 4px;
218
+ color: var(--es-muted);
219
+ font-size: 10px;
220
+ font-weight: 500;
221
+ letter-spacing: 0.06em;
222
+ text-transform: uppercase;
223
+ }
224
+
225
+ /* AIR, NOT A LINE (card 66350). The owner has asked for divider hairlines to
226
+ go, more than once, and `tests/panel-no-divider-lines.test.ts` sweeps every
227
+ stylesheet this package ships for a one-sided border — this one included. A
228
+ `'—'` item still separates; it does it with a gap. With the box's own 1px
229
+ `gap` either side, that is 7px between groups against 1px between rows. */
230
+ .es-menu-divider {
231
+ height: 5px;
232
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "editor-shell",
3
- "version": "0.30.0",
4
- "description": "Shared editor-chrome primitives for the EFFICIENT editors: EditorRail (a Next-16-safe left icon-rail leaf), the shell token layer, and GoldTextInput \u2014 type in place and watch the markup formatting appear as you type.",
3
+ "version": "0.32.0",
4
+ "description": "Shared editor-chrome primitives for the EFFICIENT editors: EditorRail (a Next-16-safe left icon-rail leaf), the shell token layer, and GoldTextInput type in place and watch the markup formatting appear as you type.",
5
5
  "license": "MIT",
6
6
  "author": "Lewis Liu",
7
7
  "homepage": "https://github.com/Lewislhy/editor-shell#readme",
@@ -35,6 +35,12 @@
35
35
  "import": "./dist/icons/index.js",
36
36
  "default": "./dist/icons/index.js"
37
37
  },
38
+ "./menu": {
39
+ "types": "./dist/menu/index.d.ts",
40
+ "import": "./dist/menu/index.js",
41
+ "default": "./dist/menu/index.js"
42
+ },
43
+ "./menu.css": "./dist/menu/menu.css",
38
44
  "./panel": {
39
45
  "types": "./dist/panel/index.d.ts",
40
46
  "import": "./dist/panel/index.js",
@@ -48,6 +54,11 @@
48
54
  "default": "./dist/shell/index.js"
49
55
  },
50
56
  "./shell.css": "./dist/shell/shell.css",
57
+ "./edit-lock": {
58
+ "types": "./dist/edit-lock/index.d.ts",
59
+ "import": "./dist/edit-lock/index.js",
60
+ "default": "./dist/edit-lock/index.js"
61
+ },
51
62
  "./gold": {
52
63
  "types": "./dist/gold/index.d.ts",
53
64
  "import": "./dist/gold/index.js",
@@ -83,7 +94,7 @@
83
94
  "typescript": "^5.3.0"
84
95
  },
85
96
  "scripts": {
86
- "build": "tsup && cp src/rail/rail.css dist/rail/rail.css && cp src/shell/shell.css dist/shell/shell.css && cp src/gold/gold.css dist/gold/gold.css && cp src/panel/panel.css dist/panel/panel.css",
97
+ "build": "tsup && cp src/rail/rail.css dist/rail/rail.css && cp src/shell/shell.css dist/shell/shell.css && cp src/gold/gold.css dist/gold/gold.css && cp src/panel/panel.css dist/panel/panel.css && cp src/menu/menu.css dist/menu/menu.css",
87
98
  "dev": "tsup --watch",
88
99
  "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
89
100
  "test": "node scripts/test.mjs",
@@ -95,6 +106,7 @@
95
106
  "editor",
96
107
  "ui-shell",
97
108
  "rail",
109
+ "menu",
98
110
  "toolbar",
99
111
  "next",
100
112
  "rsc",