@1agh/maude 0.15.0 → 0.16.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.
@@ -1208,64 +1208,21 @@ function Gallery({ title, items, onOpen, kind }) {
1208
1208
 
1209
1209
  // ---------- Comment composer / viewer ----------
1210
1210
 
1211
- function CommentBar({ activePath, selected, comments, focusedId, draft, setDraft, onSubmit, onCancel, onResolve, onReopen, onDelete, onFocusPin }) {
1211
+ function CommentBar({ activePath, comments }) {
1212
+ // Phase 6 — the shell-side composer + chip strip + focused-row chrome were
1213
+ // removed. The iframe overlay (comments-overlay.tsx) owns composer + pin
1214
+ // bubbles + thread popover. BottomBar shrinks to a live open-count summary
1215
+ // so the shell still surfaces total review activity at a glance.
1212
1216
  if (!activePath) return null;
1213
- const focused = focusedId ? comments.find(c => c.id === focusedId) : null;
1214
1217
  const openComments = (comments || []).filter(c => c.status !== 'resolved');
1218
+ if (openComments.length === 0) return null;
1215
1219
  return (
1216
1220
  <div className="comment-bar">
1217
- {draft && draft.file === activePath && (
1218
- <div className="composer">
1219
- <div className="composer-head">
1220
- <span className="cb-label">Comment on</span>
1221
- <code className="composer-selector" title={(draft.dom_path || []).join(' > ')}>{draft.selector || '(canvas)'}</code>
1222
- </div>
1223
- <textarea
1224
- autoFocus
1225
- className="composer-textarea"
1226
- value={draft.text}
1227
- placeholder="What should change here? (⌘↵ save · Esc cancel)"
1228
- onChange={e => setDraft({ ...draft, text: e.target.value })}
1229
- onKeyDown={e => {
1230
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); }
1231
- else if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
1232
- }}
1233
- rows={4}
1234
- />
1235
- <div className="composer-actions">
1236
- <button className="cb-secondary" onClick={onCancel}>Cancel</button>
1237
- <button className="cb-primary" disabled={!draft.text.trim()} onClick={onSubmit}>Save · ⌘↵</button>
1238
- </div>
1239
- </div>
1240
- )}
1241
-
1242
- {focused && (
1243
- <div className="cb-row focused">
1244
- <span className="cb-pinno">#{(comments || []).filter(c => c.selector).findIndex(c => c.id === focused.id) + 1}</span>
1245
- <span className="cb-text">{focused.text}</span>
1246
- <span className="cb-target" title={focused.dom_path ? focused.dom_path.join(' > ') : ''}>
1247
- <code>{focused.selector || '—'}</code>
1248
- </span>
1249
- {focused.status === 'resolved'
1250
- ? <button className="cb-secondary" onClick={() => onReopen(focused.id)}>Reopen</button>
1251
- : <button className="cb-primary" onClick={() => onResolve(focused.id)}>✓ Resolve</button>}
1252
- <button className="cb-secondary" onClick={() => onDelete(focused.id)}>Delete</button>
1253
- </div>
1254
- )}
1255
-
1256
- {!draft && !focused && openComments.length > 0 && (
1257
- <div className="cb-row strip">
1258
- <span className="cb-label">{openComments.length} open comment{openComments.length === 1 ? '' : 's'}</span>
1259
- <div className="cb-pin-strip">
1260
- {openComments.slice(0, 12).map((c, i) => (
1261
- <button key={c.id} className="cb-pin-chip" title={c.text} onClick={() => onFocusPin(c.id)}>
1262
- {i + 1}
1263
- </button>
1264
- ))}
1265
- {openComments.length > 12 && <span className="cb-more">+{openComments.length - 12}</span>}
1266
- </div>
1267
- </div>
1268
- )}
1221
+ <div className="cb-row strip">
1222
+ <span className="cb-label">
1223
+ {openComments.length} open comment{openComments.length === 1 ? '' : 's'}
1224
+ </span>
1225
+ </div>
1269
1226
  </div>
1270
1227
  );
1271
1228
  }
@@ -1278,7 +1235,7 @@ function StatusBarSlot({ label, children, className = '' }) {
1278
1235
  );
1279
1236
  }
1280
1237
 
1281
- function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected, onAddComment, hasDraft }) {
1238
+ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected }) {
1282
1239
  const isSystem = activePath === SYSTEM_TAB;
1283
1240
  const text = selected && selected.selector
1284
1241
  ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
@@ -1297,9 +1254,6 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
1297
1254
  <StatusBarSlot label="Selected element" className="sb-selected">
1298
1255
  <span className="sb-dot" aria-hidden="true">●</span>
1299
1256
  <span className="sb-sel-text" title={title}>{text}</span>
1300
- {!hasDraft && (
1301
- <button type="button" className="sb-add-comment" onClick={onAddComment} title="Add comment on selected element (⌘⇧+click in canvas)">+ comment</button>
1302
- )}
1303
1257
  <button type="button" className="sb-clear-sel" onClick={onClearSelected} title="Clear (Esc inside iframe)" aria-label="Clear selection">×</button>
1304
1258
  </StatusBarSlot>
1305
1259
  )}
@@ -1454,7 +1408,8 @@ function App() {
1454
1408
  return () => { cancelled = true; };
1455
1409
  }, []);
1456
1410
  const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
1457
- const [draft, setDraft] = useState(null); // { file, selector, dom_path, bounds, tag, classes, html, text }
1411
+ // Phase 6 the in-iframe composer owns drafting; the shell no longer holds
1412
+ // a `draft` state. Mutations route through postMessage → WS instead.
1458
1413
  const [focusedCommentId, setFocusedCommentId] = useState(null);
1459
1414
  const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
1460
1415
  const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
@@ -1609,7 +1564,6 @@ function App() {
1609
1564
  });
1610
1565
  setActivePath(path);
1611
1566
  setFocusedCommentId(null);
1612
- setDraft(null);
1613
1567
  }, []);
1614
1568
 
1615
1569
  const openSystem = useCallback(() => {
@@ -1672,30 +1626,6 @@ function App() {
1672
1626
  try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
1673
1627
  }, [activePath, commentsByFile]);
1674
1628
 
1675
- // ----- Comment composer helpers -----
1676
- // Declared BEFORE the inbound-message useEffect that references them — under
1677
- // ES build (no var-style hoisting) these are real TDZ violations otherwise.
1678
- const startDraftFor = useCallback((sel) => {
1679
- const file = (sel && sel.file) || activePath;
1680
- if (!file || file === SYSTEM_TAB) return;
1681
- setDraft({
1682
- file,
1683
- selector: sel?.selector || '',
1684
- dom_path: sel?.dom_path || [],
1685
- tag: sel?.tag || '',
1686
- classes: sel?.classes || '',
1687
- bounds: sel?.bounds || null,
1688
- html: sel?.html || '',
1689
- text: '',
1690
- });
1691
- setFocusedCommentId(null);
1692
- }, [activePath]);
1693
-
1694
- const startDraftFromSelection = useCallback(() => {
1695
- if (!selected || !selected.selector) return;
1696
- startDraftFor(selected);
1697
- }, [selected, startDraftFor]);
1698
-
1699
1629
  // ----- Inbound messages from iframes -----
1700
1630
  useEffect(() => {
1701
1631
  function onMessage(e) {
@@ -1728,13 +1658,40 @@ function App() {
1728
1658
  wsSend({ type: 'clear-select' });
1729
1659
  setSelected(null);
1730
1660
  } else if (m.dgn === 'comment-compose' && m.selection) {
1731
- // Canvas C-tool / right-click "Add comment" converge here. The shell
1732
- // opens a composer for the target.
1733
- startDraftFor(m.selection);
1734
- } else if (m.dgn === 'comment-shortcut') {
1735
- // Carry-over for any legacy `.html` mock or external embed that
1736
- // still posts this. Canvas-shell uses `comment-compose` directly.
1737
- startDraftFromSelection();
1661
+ // Phase 6 the iframe overlay owns the composer surface now. The
1662
+ // shell just mirrors `selected` so the StatusBar / sidebar still
1663
+ // reflect the target, and skips the legacy `startDraftFor` path that
1664
+ // opened the shell-side composer. Legacy `.html` mocks (no
1665
+ // canvas-shell mount) fall through to the same path; they lose the
1666
+ // shell composer in this phase. Acceptable per Phase 6 scope.
1667
+ setSelected(m.selection);
1668
+ } else if (m.dgn === 'comment-submit' && m.payload && typeof m.payload.text === 'string') {
1669
+ // Phase 6 — iframe overlay finished composing. Relay through the
1670
+ // existing WS `comments-add` channel; server-side persistence +
1671
+ // broadcast back are identical to the legacy shell-composer flow.
1672
+ const p = m.payload;
1673
+ const txt = String(p.text).trim();
1674
+ if (txt) {
1675
+ wsSend({
1676
+ type: 'comments-add',
1677
+ payload: {
1678
+ file: p.file,
1679
+ selector: p.selector,
1680
+ dom_path: p.dom_path,
1681
+ tag: p.tag,
1682
+ classes: p.classes,
1683
+ bounds: p.bounds,
1684
+ html_excerpt: p.html_excerpt,
1685
+ text: txt,
1686
+ },
1687
+ });
1688
+ }
1689
+ } else if (m.dgn === 'comment-patch' && m.id && m.patch && typeof m.patch === 'object') {
1690
+ // Phase 6 — thread popover routes resolve / reopen through here.
1691
+ wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
1692
+ } else if (m.dgn === 'comment-delete' && m.id) {
1693
+ wsSend({ type: 'comments-delete', id: m.id });
1694
+ setFocusedCommentId(prev => (prev === m.id ? null : prev));
1738
1695
  } else if (m.dgn === 'comment-click' && m.id) {
1739
1696
  setFocusedCommentId(m.id);
1740
1697
  } else if (m.dgn === 'loaded' && m.file) {
@@ -1751,7 +1708,7 @@ function App() {
1751
1708
  }
1752
1709
  window.addEventListener('message', onMessage);
1753
1710
  return () => window.removeEventListener('message', onMessage);
1754
- }, [commentsByFile, focusedCommentId, startDraftFromSelection, startDraftFor]);
1711
+ }, [commentsByFile, focusedCommentId]);
1755
1712
 
1756
1713
  // Tell the active canvas iframe to drop any persistent selection (canvas
1757
1714
  // SelectionSet) — used when the comment composer closes via submit /
@@ -1765,27 +1722,6 @@ function App() {
1765
1722
  }
1766
1723
  }, [activePath]);
1767
1724
 
1768
- const submitDraft = useCallback(() => {
1769
- if (!draft || !draft.text.trim()) return;
1770
- wsSend({ type: 'comments-add', payload: {
1771
- file: draft.file,
1772
- selector: draft.selector,
1773
- dom_path: draft.dom_path,
1774
- tag: draft.tag,
1775
- classes: draft.classes,
1776
- bounds: draft.bounds,
1777
- html_excerpt: draft.html,
1778
- text: draft.text.trim(),
1779
- }});
1780
- setDraft(null);
1781
- clearActiveCanvasSelection();
1782
- }, [draft, clearActiveCanvasSelection]);
1783
-
1784
- const cancelDraft = useCallback(() => {
1785
- setDraft(null);
1786
- clearActiveCanvasSelection();
1787
- }, [clearActiveCanvasSelection]);
1788
-
1789
1725
  const resolveComment = useCallback((id) => {
1790
1726
  wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
1791
1727
  }, []);
@@ -1797,16 +1733,6 @@ function App() {
1797
1733
  setFocusedCommentId(prev => (prev === id ? null : prev));
1798
1734
  }, []);
1799
1735
 
1800
- const focusPinFromBar = useCallback((id) => {
1801
- setFocusedCommentId(id);
1802
- if (activePath && activePath !== SYSTEM_TAB) {
1803
- const el = iframesRef.current.get(activePath);
1804
- if (el && el.contentWindow) {
1805
- try { el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*'); } catch {}
1806
- }
1807
- }
1808
- }, [activePath]);
1809
-
1810
1736
  // Jump from right-sidebar list to a comment: open file tab if needed, focus pin.
1811
1737
  // The iframe may be freshly mounted; the loaded handler also re-sends focus if focusedCommentId matches.
1812
1738
  const jumpToComment = useCallback((file, id) => {
@@ -1918,15 +1844,15 @@ function App() {
1918
1844
  setHelpOpen(true);
1919
1845
  return;
1920
1846
  }
1921
- // Esc — close composer (in addition to its own textarea handler) or clear focused pin
1847
+ // Esc — clear focused pin. The in-place composer (Phase 6) and thread
1848
+ // popover handle their own Esc inside the iframe.
1922
1849
  if (e.key === 'Escape') {
1923
- if (draft) { setDraft(null); clearActiveCanvasSelection(); return; }
1924
1850
  if (focusedCommentId) { setFocusedCommentId(null); return; }
1925
1851
  }
1926
1852
  }
1927
1853
  window.addEventListener('keydown', onKey);
1928
1854
  return () => window.removeEventListener('keydown', onKey);
1929
- }, [reloadActive, selected, activePath, startDraftFromSelection, draft, focusedCommentId, sidebarOpen, openSystem, closeTab, clearActiveCanvasSelection]);
1855
+ }, [reloadActive, selected, activePath, focusedCommentId, sidebarOpen, openSystem, closeTab, clearActiveCanvasSelection]);
1930
1856
 
1931
1857
  const registerIframe = useCallback((path, el) => {
1932
1858
  if (el) iframesRef.current.set(path, el);
@@ -1979,20 +1905,7 @@ function App() {
1979
1905
  cfg={cfg}
1980
1906
  />
1981
1907
  {activePath && activePath !== SYSTEM_TAB && (
1982
- <CommentBar
1983
- activePath={activePath}
1984
- selected={selected}
1985
- comments={activeFileComments}
1986
- focusedId={focusedCommentId}
1987
- draft={draft && draft.file === activePath ? draft : null}
1988
- setDraft={setDraft}
1989
- onSubmit={submitDraft}
1990
- onCancel={cancelDraft}
1991
- onResolve={resolveComment}
1992
- onReopen={reopenComment}
1993
- onDelete={deleteComment}
1994
- onFocusPin={focusPinFromBar}
1995
- />
1908
+ <CommentBar activePath={activePath} comments={activeFileComments} />
1996
1909
  )}
1997
1910
  <StatusBar
1998
1911
  activePath={activePath}
@@ -2002,8 +1915,6 @@ function App() {
2002
1915
  theme={theme}
2003
1916
  onToggleTheme={toggleTheme}
2004
1917
  onClearSelected={clearSelected}
2005
- onAddComment={startDraftFromSelection}
2006
- hasDraft={!!(draft && draft.file === activePath)}
2007
1918
  />
2008
1919
  </div>
2009
1920
  {commentsPanelOpen && (
@@ -0,0 +1,381 @@
1
+ /* Phase 6 — in-place comments overlay
2
+ *
3
+ * Lives inside the canvas iframe; portaled into `.dc-world` so CSS zoom on the
4
+ * world plane scales the entire layer (pins + composer + thread popover)
5
+ * uniformly with the artboards.
6
+ *
7
+ * DS-token only. No raw hex literals, no glow shadows, no rounded > 4px, no
8
+ * gradients. The design-system-guard subagent enforces this on PR — keep edits
9
+ * inside the token vocabulary.
10
+ */
11
+
12
+ .cm-layer {
13
+ position: fixed;
14
+ inset: 0;
15
+ pointer-events: none;
16
+ /* Pins / composer / thread sit ABOVE the SelectionHalos chrome (z-index 5
17
+ * in canvas-shell HALO_CSS). Rendered as a sibling of `.dc-canvas` (NOT
18
+ * portaled into `.dc-world`) so we share the root stacking context with
19
+ * the halos — z-index here directly competes with theirs. Bonus: pins stay
20
+ * fixed 24px regardless of canvas zoom, FigJam-style. */
21
+ z-index: 10;
22
+ }
23
+
24
+ /* ─── Pin badge ───────────────────────────────────────────────────────────── */
25
+ .cm-pin {
26
+ position: absolute;
27
+ width: 24px;
28
+ height: 24px;
29
+ padding: 0;
30
+ margin: 0;
31
+ background: var(--accent, #d63b1f);
32
+ color: var(--accent-fg, #faf6ef);
33
+ border: 1px solid var(--border-strong, #2a2520);
34
+ border-radius: 0;
35
+ font: 600 var(--type-xs, 11px) / 1 var(--font-mono, ui-monospace, monospace);
36
+ letter-spacing: var(--tracking-sku, 0.04em);
37
+ display: grid;
38
+ place-items: center;
39
+ cursor: pointer;
40
+ pointer-events: auto;
41
+ user-select: none;
42
+ transition: background var(--dur-flip, 120ms) var(--ease-out, ease-out);
43
+ will-change: transform;
44
+ }
45
+
46
+ .cm-pin:hover,
47
+ .cm-pin[aria-expanded='true'] {
48
+ background: var(--accent-hover, color-mix(in oklab, var(--accent, #d63b1f) 90%, white));
49
+ }
50
+
51
+ .cm-pin:focus-visible {
52
+ outline: none;
53
+ box-shadow: var(--shadow-focus, 0 0 0 2px var(--accent, #d63b1f));
54
+ }
55
+
56
+ .cm-pin[data-resolved='true'] {
57
+ background: var(--bg-3, #1c1916);
58
+ color: var(--fg-2, #6e6660);
59
+ opacity: 0.6;
60
+ }
61
+
62
+ .cm-pin[data-focused='true'] {
63
+ /* Subtle ring — DS hard-edges discipline: no glow, just a hairline outline. */
64
+ outline: 1px solid var(--fg-0, #2a2520);
65
+ outline-offset: 1px;
66
+ }
67
+
68
+ /* ─── Composer card ───────────────────────────────────────────────────────── */
69
+ .cm-composer {
70
+ position: absolute;
71
+ width: 300px;
72
+ background: var(--bg-1, #faf6ef);
73
+ border: var(--rule-thin, 1px solid #2a2520);
74
+ border-radius: var(--radius-sm, 2px);
75
+ padding: var(--space-4, 12px);
76
+ box-shadow: none;
77
+ font: var(--type-sm, 13px) / var(--lh-sm, 1.45) var(--font-mono, ui-monospace, monospace);
78
+ color: var(--fg-0, #2a2520);
79
+ pointer-events: auto;
80
+ z-index: 4;
81
+ }
82
+
83
+ .cm-composer__head {
84
+ font-size: var(--type-xs, 11px);
85
+ color: var(--fg-2, #6e6660);
86
+ letter-spacing: var(--tracking-eyebrow, 0.08em);
87
+ text-transform: uppercase;
88
+ margin: 0 0 var(--space-2, 4px) 0;
89
+ display: flex;
90
+ align-items: center;
91
+ gap: var(--space-2, 4px);
92
+ }
93
+
94
+ .cm-composer__selector {
95
+ color: var(--fg-1, #4a4540);
96
+ background: var(--mono-cell-bg, color-mix(in oklab, var(--bg-1, #faf6ef) 80%, var(--accent, #d63b1f) 6%));
97
+ padding: 0 var(--space-2, 4px);
98
+ border: 1px solid var(--mono-rule, var(--border-subtle, #c8bfb4));
99
+ font-size: var(--type-xs, 11px);
100
+ letter-spacing: 0;
101
+ text-transform: none;
102
+ }
103
+
104
+ .cm-composer__textarea {
105
+ width: 100%;
106
+ min-height: 72px;
107
+ resize: vertical;
108
+ background: var(--bg-0, #ffffff);
109
+ color: var(--fg-0, #2a2520);
110
+ border: var(--rule-thin, 1px solid #2a2520);
111
+ border-radius: 0;
112
+ padding: var(--space-3, 8px);
113
+ font: inherit;
114
+ box-sizing: border-box;
115
+ }
116
+
117
+ .cm-composer__textarea:focus-visible {
118
+ outline: none;
119
+ border-color: var(--accent, #d63b1f);
120
+ box-shadow: var(--shadow-focus, 0 0 0 2px var(--accent, #d63b1f));
121
+ }
122
+
123
+ .cm-composer__actions {
124
+ display: flex;
125
+ gap: var(--space-2, 4px);
126
+ justify-content: flex-end;
127
+ margin-top: var(--space-3, 8px);
128
+ }
129
+
130
+ .cm-btn {
131
+ font: 500 var(--type-xs, 11px) / 1.4 var(--font-mono, ui-monospace, monospace);
132
+ letter-spacing: var(--tracking-eyebrow, 0.08em);
133
+ text-transform: uppercase;
134
+ padding: var(--space-2, 4px) var(--space-3, 8px);
135
+ border: var(--rule-thin, 1px solid #2a2520);
136
+ border-radius: 0;
137
+ cursor: pointer;
138
+ background: transparent;
139
+ color: var(--fg-0, #2a2520);
140
+ transition: background var(--dur-flip, 120ms) var(--ease-out, ease-out);
141
+ }
142
+
143
+ .cm-btn:hover {
144
+ background: var(--bg-2, color-mix(in oklab, var(--bg-1, #faf6ef) 80%, var(--fg-0, #2a2520) 6%));
145
+ }
146
+
147
+ .cm-btn--primary {
148
+ background: var(--accent, #d63b1f);
149
+ color: var(--accent-fg, #faf6ef);
150
+ border-color: var(--accent, #d63b1f);
151
+ }
152
+
153
+ .cm-btn--primary:hover {
154
+ background: var(--accent-hover, color-mix(in oklab, var(--accent, #d63b1f) 90%, white));
155
+ }
156
+
157
+ .cm-btn:disabled {
158
+ opacity: 0.5;
159
+ cursor: not-allowed;
160
+ }
161
+
162
+ .cm-btn:focus-visible {
163
+ outline: none;
164
+ box-shadow: var(--shadow-focus, 0 0 0 2px var(--accent, #d63b1f));
165
+ }
166
+
167
+ /* ─── Thread popover ──────────────────────────────────────────────────────── */
168
+ .cm-thread {
169
+ position: absolute;
170
+ width: 340px;
171
+ max-height: 60vh;
172
+ overflow: auto;
173
+ background: var(--bg-2, color-mix(in oklab, var(--bg-1, #faf6ef) 80%, var(--fg-0, #2a2520) 6%));
174
+ border: var(--rule-thin, 1px solid #2a2520);
175
+ border-radius: var(--radius-sm, 2px);
176
+ padding: var(--space-4, 12px);
177
+ box-shadow: none;
178
+ font: var(--type-sm, 13px) / var(--lh-sm, 1.45) var(--font-mono, ui-monospace, monospace);
179
+ color: var(--fg-0, #2a2520);
180
+ pointer-events: auto;
181
+ z-index: 5;
182
+ }
183
+
184
+ .cm-thread__head {
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: var(--space-2, 4px);
188
+ padding-bottom: var(--space-3, 8px);
189
+ border-bottom: var(--rule-thin, 1px solid #2a2520);
190
+ }
191
+
192
+ .cm-thread__head-row {
193
+ display: flex;
194
+ justify-content: space-between;
195
+ align-items: baseline;
196
+ gap: var(--space-2, 4px);
197
+ }
198
+
199
+ .cm-thread__close {
200
+ background: transparent;
201
+ color: var(--fg-2, #6e6660);
202
+ border: var(--rule-thin, 1px solid #2a2520);
203
+ border-radius: 0;
204
+ padding: 0 var(--space-2, 4px);
205
+ font: 600 var(--type-xs, 11px) / 1.2 var(--font-mono, ui-monospace, monospace);
206
+ cursor: pointer;
207
+ align-self: flex-start;
208
+ margin-left: var(--space-2, 4px);
209
+ transition: background var(--dur-flip, 120ms) var(--ease-out, ease-out);
210
+ }
211
+
212
+ .cm-thread__close:hover {
213
+ background: var(--bg-3, #1c1916);
214
+ color: var(--fg-0, #2a2520);
215
+ }
216
+
217
+ .cm-thread__close:focus-visible {
218
+ outline: none;
219
+ box-shadow: var(--shadow-focus, 0 0 0 2px var(--accent, #d63b1f));
220
+ }
221
+
222
+ .cm-thread__author {
223
+ color: var(--fg-0, #2a2520);
224
+ font-weight: 600;
225
+ }
226
+
227
+ .cm-thread__time {
228
+ color: var(--fg-2, #6e6660);
229
+ font-size: var(--type-xs, 11px);
230
+ }
231
+
232
+ .cm-thread__selector {
233
+ display: inline-block;
234
+ color: var(--fg-2, #6e6660);
235
+ font-size: var(--type-xs, 11px);
236
+ border: 1px solid var(--mono-rule, var(--border-subtle, #c8bfb4));
237
+ padding: 0 var(--space-2, 4px);
238
+ background: var(--mono-cell-bg, color-mix(in oklab, var(--bg-1, #faf6ef) 80%, var(--accent, #d63b1f) 6%));
239
+ align-self: flex-start;
240
+ max-width: 100%;
241
+ overflow: hidden;
242
+ text-overflow: ellipsis;
243
+ white-space: nowrap;
244
+ }
245
+
246
+ .cm-thread__body {
247
+ padding: var(--space-3, 8px) 0;
248
+ white-space: pre-wrap;
249
+ word-break: break-word;
250
+ }
251
+
252
+ .cm-thread__body strong,
253
+ .cm-thread__reply-body strong {
254
+ /* @mention tokens — bold, accent color. Inserted by `renderBodyWithMentions`. */
255
+ color: var(--accent, #d63b1f);
256
+ font-weight: 600;
257
+ }
258
+
259
+ .cm-thread__reply {
260
+ padding: var(--space-3, 8px) 0;
261
+ border-top: 1px solid var(--border-subtle, #c8bfb4);
262
+ }
263
+
264
+ .cm-thread__reply-head {
265
+ display: flex;
266
+ align-items: baseline;
267
+ gap: var(--space-2, 4px);
268
+ margin-bottom: var(--space-2, 4px);
269
+ }
270
+
271
+ .cm-thread__reply-author {
272
+ color: var(--fg-1, #4a4540);
273
+ font-weight: 600;
274
+ }
275
+
276
+ .cm-thread__reply-time {
277
+ color: var(--fg-3, var(--fg-2, #6e6660));
278
+ font-size: var(--type-xs, 11px);
279
+ }
280
+
281
+ .cm-thread__reply-body {
282
+ white-space: pre-wrap;
283
+ word-break: break-word;
284
+ }
285
+
286
+ .cm-thread__reply-form {
287
+ padding-top: var(--space-3, 8px);
288
+ border-top: var(--rule-thin, 1px solid #2a2520);
289
+ display: flex;
290
+ flex-direction: column;
291
+ gap: var(--space-2, 4px);
292
+ }
293
+
294
+ .cm-thread__reply-textarea {
295
+ width: 100%;
296
+ min-height: 56px;
297
+ resize: vertical;
298
+ background: var(--bg-0, #ffffff);
299
+ color: var(--fg-0, #2a2520);
300
+ border: var(--rule-thin, 1px solid #2a2520);
301
+ border-radius: 0;
302
+ padding: var(--space-3, 8px);
303
+ font: inherit;
304
+ box-sizing: border-box;
305
+ }
306
+
307
+ .cm-thread__reply-textarea:focus-visible {
308
+ outline: none;
309
+ border-color: var(--accent, #d63b1f);
310
+ box-shadow: var(--shadow-focus, 0 0 0 2px var(--accent, #d63b1f));
311
+ }
312
+
313
+ .cm-thread__reply-actions {
314
+ display: flex;
315
+ justify-content: flex-end;
316
+ gap: var(--space-2, 4px);
317
+ }
318
+
319
+ .cm-thread__actions {
320
+ display: flex;
321
+ gap: var(--space-2, 4px);
322
+ padding-top: var(--space-3, 8px);
323
+ margin-top: var(--space-3, 8px);
324
+ border-top: var(--rule-thin, 1px solid #2a2520);
325
+ }
326
+
327
+ .cm-thread__actions .cm-btn--danger {
328
+ margin-left: auto;
329
+ color: var(--status-error, #c0392b);
330
+ border-color: var(--status-error, #c0392b);
331
+ }
332
+
333
+ .cm-thread__actions .cm-btn--danger:hover {
334
+ background: color-mix(in oklab, var(--status-error, #c0392b) 12%, transparent);
335
+ }
336
+
337
+ /* ─── @mention autocomplete ──────────────────────────────────────────────── */
338
+ .cm-mention-popup {
339
+ position: absolute;
340
+ background: var(--bg-2, color-mix(in oklab, var(--bg-1, #faf6ef) 80%, var(--fg-0, #2a2520) 6%));
341
+ border: var(--rule-thin, 1px solid #2a2520);
342
+ border-radius: 0;
343
+ font: var(--type-xs, 11px) / var(--lh-xs, 1.3) var(--font-mono, ui-monospace, monospace);
344
+ color: var(--fg-0, #2a2520);
345
+ max-height: 180px;
346
+ min-width: 160px;
347
+ overflow: auto;
348
+ box-shadow: none;
349
+ pointer-events: auto;
350
+ z-index: 6;
351
+ list-style: none;
352
+ padding: 0;
353
+ margin: 0;
354
+ }
355
+
356
+ .cm-mention-popup__item {
357
+ padding: var(--space-2, 4px) var(--space-3, 8px);
358
+ cursor: pointer;
359
+ display: flex;
360
+ justify-content: space-between;
361
+ gap: var(--space-3, 8px);
362
+ }
363
+
364
+ .cm-mention-popup__item[aria-selected='true'] {
365
+ background: var(--accent, #d63b1f);
366
+ color: var(--accent-fg, #faf6ef);
367
+ }
368
+
369
+ .cm-mention-popup__name {
370
+ font-weight: 600;
371
+ }
372
+
373
+ .cm-mention-popup__email {
374
+ color: var(--fg-2, #6e6660);
375
+ font-size: var(--type-xs, 11px);
376
+ }
377
+
378
+ .cm-mention-popup__item[aria-selected='true'] .cm-mention-popup__email {
379
+ color: var(--accent-fg, #faf6ef);
380
+ opacity: 0.85;
381
+ }