@1agh/maude 0.36.0 → 0.36.1

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.
@@ -47,6 +47,12 @@ import {
47
47
  useViewportControllerContext,
48
48
  type ViewportControllerHandle,
49
49
  } from './canvas-lib.tsx';
50
+ import {
51
+ buildEditSourceRecord,
52
+ type EditSourceApplyFn,
53
+ type EditSourceOp,
54
+ type EditSourcePayload,
55
+ } from './commands/edit-source-command.ts';
50
56
  import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
51
57
  import {
52
58
  ContextMenuProvider,
@@ -92,7 +98,7 @@ import {
92
98
  useSelectionSet,
93
99
  } from './use-selection-set.tsx';
94
100
  import { useToolMode } from './use-tool-mode.tsx';
95
- import { useUndoStack } from './use-undo-stack.tsx';
101
+ import { useUndoSinks, useUndoStack } from './use-undo-stack.tsx';
96
102
 
97
103
  // ─────────────────────────────────────────────────────────────────────────────
98
104
  // Styles — halos render as `position: fixed` siblings of the canvas. Reading
@@ -1372,6 +1378,28 @@ function CanvasRouter({
1372
1378
  const annotSel = useAnnotationSelection();
1373
1379
  const ctxMenu = useContextMenu();
1374
1380
  const undoStack = useUndoStack();
1381
+ const undoSinks = useUndoSinks();
1382
+ // Latest undo stack, read inside long-lived listeners (text-edit dblclick)
1383
+ // without making them a hook dependency — avoids re-binding on every undo op.
1384
+ const undoStackRef = useRef(undoStack);
1385
+ undoStackRef.current = undoStack;
1386
+
1387
+ // Phase: inline-edit undo (DDR-103/104 follow-up). Wire the `editSourceApplyFn`
1388
+ // sink so an `edit-source` command's do()/undo() reaches the main-origin-only
1389
+ // `/_api/edit-*` write — which the untrusted canvas iframe can't call (DDR-054).
1390
+ // It posts `dgn:'apply-edit'` to the parent shell, which performs the write.
1391
+ useEffect(() => {
1392
+ const applyFn: EditSourceApplyFn = (apply) => {
1393
+ try {
1394
+ window.parent.postMessage({ dgn: 'apply-edit', ...apply }, '*');
1395
+ } catch {
1396
+ /* detached / cross-origin teardown — nothing to apply */
1397
+ }
1398
+ };
1399
+ undoSinks.setSink('editSourceApplyFn', applyFn);
1400
+ return () => undoSinks.setSink('editSourceApplyFn', undefined);
1401
+ }, [undoSinks]);
1402
+
1375
1403
  // Shell View-menu zoom bridge (dgn:'zoom') — same controller the zoom pill uses.
1376
1404
  const zoomController = useViewportControllerContext();
1377
1405
  // Shell View-menu chrome-visibility bridge (dgn:'view-chrome') — minimap /
@@ -1520,6 +1548,35 @@ function CanvasRouter({
1520
1548
  }
1521
1549
  return;
1522
1550
  }
1551
+ // Inline-edit undo (DDR-103/104 follow-up). The shell's inspector posts
1552
+ // this AFTER it has applied a CSS / attribute edit (`/_api/edit-css` /
1553
+ // `/_api/edit-attr`), so we RECORD it onto the in-canvas undo stack
1554
+ // (append without re-running do() — the edit already landed) and Cmd+Z
1555
+ // can invert it. Inline TEXT edits originate in this iframe and record
1556
+ // themselves directly (see commitEdit). PARENT-ORIGIN ONLY (DDR-054):
1557
+ // a hostile canvas self-post must not be able to plant fake undo entries.
1558
+ if (m.dgn === 'record-edit') {
1559
+ if (e.source !== window.parent) return;
1560
+ const p = (m as { payload?: Partial<EditSourcePayload> }).payload;
1561
+ if (
1562
+ p &&
1563
+ (p.op === 'css' || p.op === 'text' || p.op === 'attr') &&
1564
+ typeof p.canvas === 'string' &&
1565
+ typeof p.id === 'string'
1566
+ ) {
1567
+ undoStack.record(
1568
+ buildEditSourceRecord({
1569
+ op: p.op as EditSourceOp,
1570
+ canvas: p.canvas,
1571
+ id: p.id,
1572
+ key: typeof p.key === 'string' ? p.key : '',
1573
+ before: typeof p.before === 'string' ? p.before : null,
1574
+ after: typeof p.after === 'string' ? p.after : null,
1575
+ })
1576
+ );
1577
+ }
1578
+ return;
1579
+ }
1523
1580
  if (m.dgn === 'request-layers') {
1524
1581
  postLayersTree((m as { artboardId?: string }).artboardId ?? null);
1525
1582
  return;
@@ -1605,11 +1662,25 @@ function CanvasRouter({
1605
1662
  if (text === original.trim()) return;
1606
1663
  const cdId = elx.getAttribute('data-cd-id');
1607
1664
  if (!cdId) return;
1665
+ const file = deriveFile();
1608
1666
  try {
1609
- window.parent.postMessage({ dgn: 'edit-text', id: cdId, file: deriveFile(), text }, '*');
1667
+ window.parent.postMessage({ dgn: 'edit-text', id: cdId, file, text }, '*');
1610
1668
  } catch {
1611
1669
  /* detached / cross-origin */
1612
1670
  }
1671
+ // Record onto the in-canvas undo stack so Cmd+Z reverts the rewrite. The
1672
+ // edit already posted above (record, don't re-run do()). before/after are
1673
+ // the trimmed bodies the edit-text endpoint persists.
1674
+ undoStackRef.current.record(
1675
+ buildEditSourceRecord({
1676
+ op: 'text',
1677
+ canvas: file,
1678
+ id: cdId,
1679
+ key: '',
1680
+ before: original.trim(),
1681
+ after: text,
1682
+ })
1683
+ );
1613
1684
  }
1614
1685
  const onDbl = (e: MouseEvent): void => {
1615
1686
  if (editing) return;
@@ -4156,7 +4156,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
4156
4156
  );
4157
4157
  }
4158
4158
 
4159
- function CssKnobs({ el, cfg, onOptimistic }) {
4159
+ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4160
4160
  const editable = !!el.id;
4161
4161
  const computed = el.computed || {};
4162
4162
  // Phase 12.3 — optimistic local overlay over the selection's authored / custom
@@ -4248,47 +4248,88 @@ function CssKnobs({ el, cfg, onOptimistic }) {
4248
4248
  value,
4249
4249
  });
4250
4250
  };
4251
+ // Record an inline edit onto the canvas undo stack (Cmd+Z). The edit has
4252
+ // already POSTed `/_api/edit-*`; the canvas iframe APPENDS the record (no
4253
+ // re-run). `before`/`after` null = the prop/attr was/becomes unset.
4254
+ const record = (op, key, before, after) => {
4255
+ onRecordEdit?.({
4256
+ op,
4257
+ canvas: el.file,
4258
+ id: el.id,
4259
+ key,
4260
+ before: before == null || before === '' ? null : before,
4261
+ after: after == null || after === '' ? null : after,
4262
+ });
4263
+ };
4251
4264
  const commit = (property, raw) => {
4252
4265
  const value = (raw || '').trim();
4253
4266
  if (!editable || !value) return;
4254
- if (value === (authored[property] ?? '').trim()) return; // no-op
4267
+ const before = authored[property] ?? null;
4268
+ if (value === (before ?? '').trim()) return; // no-op
4255
4269
  optimistic(property, value);
4256
4270
  setA(property, value); // reflect in the panel immediately (no reload → no reselect)
4257
4271
  post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
4272
+ record('css', property, before, value);
4258
4273
  };
4259
4274
  // A custom CSS property (Advanced) — same write, but the panel surfaces it from
4260
4275
  // the customStyles map, so overlay THERE.
4261
4276
  const commitCustom = (property, raw) => {
4262
4277
  const value = (raw || '').trim();
4263
- if (!editable || !property.trim() || !value) return;
4264
- optimistic(property.trim(), value);
4265
- setC(property.trim(), value);
4266
- post('/_api/edit-css', { canvas: el.file, id: el.id, property: property.trim(), value }, property.trim());
4278
+ const prop = property.trim();
4279
+ if (!editable || !prop || !value) return;
4280
+ const before = customStyles[prop] ?? null;
4281
+ optimistic(prop, value);
4282
+ setC(prop, value);
4283
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property: prop, value }, prop);
4284
+ record('css', prop, before, value);
4267
4285
  };
4268
4286
  const commitAttr = (attr, raw) => {
4269
4287
  const a = (attr || '').trim();
4270
4288
  const value = (raw || '').trim();
4271
4289
  if (!editable || !a || !value) return;
4290
+ const before = attrs[a] ?? null;
4272
4291
  setT(a, value);
4273
4292
  post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
4293
+ record('attr', a, before, value);
4274
4294
  };
4275
4295
  // Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
4276
4296
  const reset = (property) => {
4277
4297
  if (!editable) return;
4298
+ const before = authored[property] ?? null;
4278
4299
  optimistic(property, null);
4279
4300
  setA(property, null);
4280
4301
  post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
4302
+ record('css', property, before, null);
4281
4303
  };
4282
4304
  const resetCustom = (property) => {
4283
4305
  if (!editable) return;
4306
+ const before = customStyles[property] ?? null;
4284
4307
  optimistic(property, null);
4285
4308
  setC(property, null);
4286
4309
  post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
4310
+ record('css', property, before, null);
4287
4311
  };
4288
4312
  const resetAttr = (attr) => {
4289
4313
  if (!editable) return;
4314
+ const before = attrs[attr] ?? null;
4290
4315
  setT(attr, null);
4291
4316
  post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
4317
+ record('attr', attr, before, null);
4318
+ };
4319
+ // Cmd+Z / Cmd+Shift+Z (or Cmd+Y) inside the inspector forwards to the canvas
4320
+ // undo stack — Figma-parity: a property field reverts the last DOCUMENT edit,
4321
+ // not field text. Without this, an edit committed with focus still in the
4322
+ // inspector couldn't be undone (the iframe's own keydown never sees the key).
4323
+ const onKnobKeyDown = (e) => {
4324
+ if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
4325
+ const k = e.key.toLowerCase();
4326
+ if (k === 'z') {
4327
+ e.preventDefault();
4328
+ onUndoRedo?.(e.shiftKey ? 'redo' : 'undo');
4329
+ } else if (k === 'y') {
4330
+ e.preventDefault();
4331
+ onUndoRedo?.('redo');
4332
+ }
4292
4333
  };
4293
4334
  // Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
4294
4335
  // change its value. Live preview via optimistic apply on every move (no source
@@ -4726,7 +4767,7 @@ function CssKnobs({ el, cfg, onOptimistic }) {
4726
4767
  const attrRows = Object.entries(attrs);
4727
4768
 
4728
4769
  return (
4729
- <div className="st-cp" key={el.id} data-tour="css-panel">
4770
+ <div className="st-cp" key={el.id} data-tour="css-panel" onKeyDown={onKnobKeyDown}>
4730
4771
  <div className="st-cp-id">
4731
4772
  <span className="st-cp-idtag">
4732
4773
  {el.tag || 'element'}
@@ -5272,6 +5313,8 @@ function InspectorPanel({
5272
5313
  onHoverLayer,
5273
5314
  cfg,
5274
5315
  onOptimistic,
5316
+ onRecordEdit,
5317
+ onUndoRedo,
5275
5318
  tab: tabProp,
5276
5319
  onTabChange,
5277
5320
  width,
@@ -5445,7 +5488,13 @@ function InspectorPanel({
5445
5488
  )}
5446
5489
  </>
5447
5490
  ) : (
5448
- <CssKnobs el={el} cfg={cfg} onOptimistic={onOptimistic} />
5491
+ <CssKnobs
5492
+ el={el}
5493
+ cfg={cfg}
5494
+ onOptimistic={onOptimistic}
5495
+ onRecordEdit={onRecordEdit}
5496
+ onUndoRedo={onUndoRedo}
5497
+ />
5449
5498
  )}
5450
5499
  </div>
5451
5500
  </aside>
@@ -6538,6 +6587,47 @@ function App() {
6538
6587
  if (!j.ok) console.warn('[edit-text]', j.error || 'failed');
6539
6588
  })
6540
6589
  .catch(() => {});
6590
+ } else if (m.dgn === 'apply-edit' && m.id && (m.op === 'css' || m.op === 'text' || m.op === 'attr')) {
6591
+ // Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
6592
+ // `edit-source` command can't call the main-origin-only `/_api/edit-*`
6593
+ // routes (DDR-054), so it asks us to re-apply the before/after value.
6594
+ // `value` null = reset (remove the inline prop / attr). For CSS we also
6595
+ // optimistically repaint so the revert shows before the HMR reload.
6596
+ const op = m.op;
6597
+ const value = typeof m.value === 'string' ? m.value : null;
6598
+ let url;
6599
+ let body;
6600
+ if (op === 'css') {
6601
+ url = '/_api/edit-css';
6602
+ body =
6603
+ value == null
6604
+ ? { canvas: m.canvas, id: m.id, property: m.key, reset: true }
6605
+ : { canvas: m.canvas, id: m.id, property: m.key, value };
6606
+ applyOptimisticStyle({ id: m.id, prop: m.key, value });
6607
+ } else if (op === 'attr') {
6608
+ url = '/_api/edit-attr';
6609
+ body =
6610
+ value == null
6611
+ ? { canvas: m.canvas, id: m.id, attr: m.key, reset: true }
6612
+ : { canvas: m.canvas, id: m.id, attr: m.key, value };
6613
+ } else {
6614
+ url = '/_api/edit-text';
6615
+ body = { canvas: m.canvas, id: m.id, text: value ?? '' };
6616
+ }
6617
+ editApplyChainRef.current = editApplyChainRef.current
6618
+ .catch(() => {})
6619
+ .then(() =>
6620
+ fetch(url, {
6621
+ method: 'POST',
6622
+ headers: { 'content-type': 'application/json' },
6623
+ body: JSON.stringify(body),
6624
+ })
6625
+ .then((r) => r.json().catch(() => ({})))
6626
+ .then((j) => {
6627
+ if (!j.ok) console.warn('[apply-edit]', op, j.error || 'failed');
6628
+ })
6629
+ .catch(() => {})
6630
+ );
6541
6631
  } else if (m.dgn === 'layers-tree') {
6542
6632
  // Phase 12 Task 4 — browsable layers tree for the active artboard.
6543
6633
  setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
@@ -6791,6 +6881,28 @@ function App() {
6791
6881
  [activePath]
6792
6882
  );
6793
6883
 
6884
+ // Inline-edit undo (DDR-103/104 follow-up). The inspector calls this after it
6885
+ // POSTs `/_api/edit-css` / `/_api/edit-attr`, so the canvas iframe records the
6886
+ // edit on its undo stack and Cmd+Z can invert it. The iframe gates this to
6887
+ // parent-origin posts (DDR-054). See `commands/edit-source-command.ts`.
6888
+ const recordSourceEdit = useCallback(
6889
+ (payload) => {
6890
+ if (!activePath || activePath === SYSTEM_TAB || !payload) return;
6891
+ const el = iframesRef.current.get(activePath);
6892
+ if (el && el.contentWindow) {
6893
+ try {
6894
+ el.contentWindow.postMessage({ dgn: 'record-edit', payload }, '*');
6895
+ } catch {}
6896
+ }
6897
+ },
6898
+ [activePath]
6899
+ );
6900
+
6901
+ // Serializes `apply-edit` source writes from canvas undo/redo so a rapid
6902
+ // multi-Cmd+Z on the same property lands on disk in dispatch order (the
6903
+ // iframe sink is fire-and-forget, so without this the POSTs could race).
6904
+ const editApplyChainRef = useRef(Promise.resolve());
6905
+
6794
6906
  const resolveComment = useCallback((id) => {
6795
6907
  wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
6796
6908
  }, []);
@@ -6859,6 +6971,19 @@ function App() {
6859
6971
  setPaletteOpen((v) => !v);
6860
6972
  return;
6861
6973
  }
6974
+ // Cmd+Z / Cmd+Shift+Z / Cmd+Y — forward to the active canvas's undo stack
6975
+ // when focus is in the shell chrome (not a text field, not the canvas
6976
+ // iframe). Inside the canvas iframe the canvas owns Cmd+Z; inside an editable
6977
+ // field native undo wins (the inspector's CssKnobs forwards on its own). This
6978
+ // makes inspector CSS / inline text / attr edits undoable from anywhere.
6979
+ if (meta && !e.altKey && (e.key === 'z' || e.key === 'Z' || e.key === 'y' || e.key === 'Y')) {
6980
+ if (!inEditable && !inCanvasIframe && activePath && activePath !== SYSTEM_TAB) {
6981
+ e.preventDefault();
6982
+ const redo = e.key === 'y' || e.key === 'Y' || e.shiftKey;
6983
+ postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
6984
+ return;
6985
+ }
6986
+ }
6862
6987
  // Cmd+Shift+R — refresh the FILES tree (re-read /_index-data). The fs-watch
6863
6988
  // → canvas-list-update auto-refresh can miss events in the compiled desktop
6864
6989
  // sidecar (recursive fs.watch is unreliable in a bun --compile binary), and
@@ -7387,6 +7512,8 @@ function App() {
7387
7512
  onTabChange={setInspectorTab}
7388
7513
  onClose={() => setInspectorOpen(false)}
7389
7514
  onOptimistic={applyOptimisticStyle}
7515
+ onRecordEdit={recordSourceEdit}
7516
+ onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
7390
7517
  layersTree={layersTree}
7391
7518
  onSelectLayer={(n) =>
7392
7519
  postToActiveCanvas({
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @file commands/edit-source-command.ts — undo entry for inline source edits
3
+ * @scope apps/studio/commands/edit-source-command.ts
4
+ * @purpose Reversible record of a single in-canvas direct edit that writes
5
+ * the canvas `.tsx` source: an inspector CSS commit/reset
6
+ * (`/_api/edit-css`), an inline text rewrite (`/_api/edit-text`), or
7
+ * a custom HTML-attribute edit (`/_api/edit-attr`). Before this
8
+ * command existed these edits were covered by NEITHER the Cmd+Z
9
+ * undo stack NOR the `_history` snapshot stack, so they were
10
+ * unrecoverable in-app (the reported bug). See the undo/redo
11
+ * coverage RCA: `.ai/logs/rca/issue-undo-redo-coverage-gaps.md`.
12
+ *
13
+ * Why a single `edit-source` kind for all three ops. Each is invertible
14
+ * through the SAME endpoint by re-applying the prior value — `editCss(prop,
15
+ * before)` undoes `editCss(prop, after)`; `null` means "remove the inline
16
+ * prop / attr" (the reset path) or "field was unset, undo by reset". Storing
17
+ * the op + before/after as a flat record keeps one builder + one sink.
18
+ *
19
+ * Origin split (DDR-054). This command runs INSIDE the untrusted canvas iframe
20
+ * (where the undo stack lives), but `/_api/edit-*` are main-origin-only — the
21
+ * iframe cannot fetch them. So `do()` / `undo()` route through the injected
22
+ * `editSourceApplyFn` sink, which posts `dgn:'apply-edit'` to the parent shell;
23
+ * the shell performs the privileged write. Records are built per iframe mount
24
+ * from a serializable `CommandRecord` (DDR-050 rev 2) so the stack survives the
25
+ * `mode:'module'` HMR reload that the source write triggers.
26
+ */
27
+
28
+ import type { CommandRecord, EditCommand } from '../undo-stack.ts';
29
+ import { registerCommand } from '../undo-stack.ts';
30
+
31
+ /** Which main-origin edit route this record re-applies through. */
32
+ export type EditSourceOp = 'css' | 'text' | 'attr';
33
+
34
+ export interface EditSourcePayload {
35
+ op: EditSourceOp;
36
+ /** Canvas file path (repo- or designRoot-relative — same string the edit POST used). */
37
+ canvas: string;
38
+ /** `data-cd-id` of the target element. */
39
+ id: string;
40
+ /**
41
+ * CSS property name (`op:'css'`) or HTML attribute name (`op:'attr'`).
42
+ * Empty string for `op:'text'` (the whole text node is the target).
43
+ */
44
+ key: string;
45
+ /** Value before the edit. `null` = the prop/attr was unset (undo ⇒ reset). */
46
+ before: string | null;
47
+ /** Value after the edit. `null` = the edit removed the prop/attr (the reset path). */
48
+ after: string | null;
49
+ }
50
+
51
+ /**
52
+ * One application of an inline edit. The production binding (canvas-shell.tsx)
53
+ * posts `dgn:'apply-edit'` to the parent shell; tests pass a spy. `value` null
54
+ * means "reset" — remove the inline prop / attr (for `op:'text'` a null value
55
+ * never occurs, text always has a body).
56
+ */
57
+ export type EditSourceApplyFn = (apply: {
58
+ op: EditSourceOp;
59
+ canvas: string;
60
+ id: string;
61
+ key: string;
62
+ value: string | null;
63
+ }) => void | Promise<void>;
64
+
65
+ export const EDIT_SOURCE_KIND = 'edit-source';
66
+
67
+ export interface EditSourceCommandInit {
68
+ payload: EditSourcePayload;
69
+ applyFn: EditSourceApplyFn;
70
+ label?: string;
71
+ }
72
+
73
+ export function createEditSourceCommand(init: EditSourceCommandInit): EditCommand {
74
+ const { payload, applyFn } = init;
75
+ const apply = (value: string | null) =>
76
+ applyFn({ op: payload.op, canvas: payload.canvas, id: payload.id, key: payload.key, value });
77
+ return {
78
+ kind: EDIT_SOURCE_KIND,
79
+ label: init.label ?? defaultLabel(payload),
80
+ async do() {
81
+ await apply(payload.after);
82
+ },
83
+ async undo() {
84
+ await apply(payload.before);
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Build a persistable record. The caller (the shell's CssKnobs for css/attr,
91
+ * canvas-shell's text commit for text) has already applied the edit once, so
92
+ * this record is pushed onto the stack via `useUndoStack().record()` — which
93
+ * appends WITHOUT re-running `do()` — not via `push()`.
94
+ */
95
+ export function buildEditSourceRecord(
96
+ payload: EditSourcePayload
97
+ ): CommandRecord<EditSourcePayload> {
98
+ return { kind: EDIT_SOURCE_KIND, label: defaultLabel(payload), payload };
99
+ }
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ // Registry
103
+
104
+ registerCommand<EditSourcePayload>(EDIT_SOURCE_KIND, (record, sinks) => {
105
+ const applyFn = sinks.editSourceApplyFn as EditSourceApplyFn | undefined;
106
+ if (!applyFn) return null;
107
+ return createEditSourceCommand({ payload: record.payload, applyFn, label: record.label });
108
+ });
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // Internals
112
+
113
+ function defaultLabel(p: EditSourcePayload): string {
114
+ if (p.op === 'text') return 'edit text';
115
+ const verb = p.after == null ? 'reset' : 'edit';
116
+ const what = p.op === 'attr' ? `@${p.key}` : p.key;
117
+ return `${verb} ${what}`;
118
+ }