@1agh/maude 0.31.0 → 0.32.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.
@@ -132,13 +132,30 @@ export class AcpBridge {
132
132
  if (!adapterEntry) {
133
133
  throw new Error('The Claude agent bridge is not installed in this build.');
134
134
  }
135
- if (!resolveClaudePath()) {
135
+ const claudePath = resolveClaudePath();
136
+ if (!claudePath) {
136
137
  throw new Error("Claude Code isn't connected — run `claude` in a terminal and `/login`.");
137
138
  }
138
139
 
139
140
  // DDR-123 guardrail #1 — strip ANTHROPIC_API_KEY so the child stays on the
140
141
  // user's subscription. This is the whole compliance story; do not weaken it.
141
142
  const env = scrubAgentEnv(process.env);
143
+ // DDR-123 guardrail #2 — pin the adapter to the user's OWN `claude` CLI.
144
+ // `claude-agent-acp`'s `claudeCliPath()` honors CLAUDE_CODE_EXECUTABLE and
145
+ // ONLY otherwise falls back to the ~210 MB native Claude binary shipped as a
146
+ // platform-specific OPTIONAL dep of @anthropic-ai/claude-agent-sdk. The
147
+ // desktop bundle deliberately stages just the adapter's JS closure (not that
148
+ // native binary — see apps/desktop/scripts/stage-resources.mjs), so without
149
+ // this pin the packaged adapter would throw "native binary not found". Driving
150
+ // the user's installed CLI is also the documented intent: it keeps the turn on
151
+ // their subscription rather than the SDK's embedded runtime.
152
+ env.CLAUDE_CODE_EXECUTABLE = claudePath;
153
+ // Least-privilege: the adapter child never talks to the dev-server's GitHub
154
+ // token bridge (only apps/studio/github/token.ts does), so drop the loopback
155
+ // keychain-bridge handle from its env. Keeps a hijacked-PATH `claude` (which
156
+ // would require pre-existing RCE) from reading the user's GitHub token.
157
+ delete env.MAUDE_TOKEN_ENDPOINT;
158
+ delete env.MAUDE_TOKEN_KEY;
142
159
  // Model + effort selection — config, NOT credentials, so they're added back.
143
160
  this.activeModel = this.desiredModel;
144
161
  this.activeEffort = this.desiredEffort;
@@ -18,6 +18,7 @@ import DiffView from './panels/DiffView.jsx';
18
18
  import GitPanel from './panels/GitPanel.jsx';
19
19
  import IdentityBar from './panels/IdentityBar.jsx';
20
20
  import OnboardingWizard from './panels/OnboardingWizard.jsx';
21
+ import { ReadinessDialog } from './panels/ReadinessList.jsx';
21
22
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
22
23
  import { appIsFirstRun, isNativeApp, onUpdateReady, restartToUpdate } from './github.js';
23
24
  import { COLLAB_TOUR } from './tour/collab-tour.js';
@@ -2249,6 +2250,7 @@ function HelpDropdown({ onAction, onClose }) {
2249
2250
  // Publish → Pull cycle — a non-technical, native-app concern. A web-studio
2250
2251
  // dev already knows git, so it's hidden there (DDR-119).
2251
2252
  ...(isNativeApp() ? [{ id: 'collab-tour', label: 'How sharing works' }] : []),
2253
+ ...(isNativeApp() ? [{ id: 'readiness', label: 'Check AI editing readiness…' }] : []),
2252
2254
  { id: 'whatsnew', label: "What's new" },
2253
2255
  ]}
2254
2256
  />
@@ -2367,6 +2369,7 @@ function Menubar({
2367
2369
  onTogglePresent,
2368
2370
  postToActiveCanvas,
2369
2371
  onOpenWhatsNew,
2372
+ onOpenReadiness,
2370
2373
  whatsNewCount,
2371
2374
  artboardCount = 0,
2372
2375
  presence = null,
@@ -2630,6 +2633,7 @@ function Menubar({
2630
2633
  else if (id === 'help') onOpenHelp?.();
2631
2634
  else if (id === 'tour') onStartTour?.();
2632
2635
  else if (id === 'collab-tour') onStartCollabTour?.();
2636
+ else if (id === 'readiness') onOpenReadiness?.();
2633
2637
  else if (id === 'whatsnew') onOpenWhatsNew?.();
2634
2638
  }}
2635
2639
  onClose={() => setOpenMenu(null)}
@@ -5578,6 +5582,7 @@ function App() {
5578
5582
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
5579
5583
  const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
5580
5584
  const [helpOpen, setHelpOpen] = useState(false);
5585
+ const [readinessOpen, setReadinessOpen] = useState(false);
5581
5586
  // ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
5582
5587
  // Help modal (F1), which keeps commands & flows.
5583
5588
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -7138,6 +7143,7 @@ function App() {
7138
7143
  presentMode={presentMode}
7139
7144
  onTogglePresent={togglePresent}
7140
7145
  postToActiveCanvas={postToActiveCanvas}
7146
+ onOpenReadiness={() => setReadinessOpen(true)}
7141
7147
  onOpenWhatsNew={whatsNew.openPanel}
7142
7148
  whatsNewCount={whatsNew.unseen.length}
7143
7149
  artboardCount={activeArtboards}
@@ -7430,6 +7436,7 @@ function App() {
7430
7436
  }}
7431
7437
  />
7432
7438
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
7439
+ <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
7433
7440
  {usageNudge && !tourSteps && !collabNudge && (
7434
7441
  <div className="mdcc-tour-nudge" role="status" aria-live="polite">
7435
7442
  <div className="mdcc-tour-nudge__body">
@@ -23,6 +23,7 @@ import {
23
23
 
24
24
  import { createAcpConnection, makeAcpAdapter } from './acp-runtime.js';
25
25
  import { Markdown } from './chat-markdown.jsx';
26
+ import ReadinessList, { useReadiness } from './ReadinessList.jsx';
26
27
 
27
28
  // ── inline icons (separate panel files carry their own, like GitPanel) ──
28
29
  const Spark = ({ size = 16 }) => (
@@ -330,16 +331,18 @@ function Composer({ activeCanvas, model, setModel, effort, setEffort }) {
330
331
  );
331
332
  }
332
333
 
333
- function NotConnected({ reason, claudeMissing }) {
334
+ function NotConnected({ reason, claudeMissing, readiness, readinessLoading, onRecheck }) {
334
335
  return (
335
336
  <div className="chat-disabled">
336
337
  <span className="chat-disabled-mark">
337
338
  <Spark size={28} />
338
339
  </span>
339
- <div className="chat-disabled-title">Claude Code isn't connected</div>
340
+ <div className="chat-disabled-title">AI editing isn't ready yet</div>
340
341
  <div className="chat-disabled-sub">
341
342
  {reason ? <p>{reason}</p> : null}
342
- {claudeMissing ? (
343
+ {readiness ? (
344
+ <p>AI editing pairs with a Claude Code you have installed. Here's what it still needs:</p>
345
+ ) : claudeMissing ? (
343
346
  <p>
344
347
  Install it with <code>npm i -g @anthropic-ai/claude-code</code>, then run{' '}
345
348
  <code>claude</code> and <code>/login</code> in a terminal.
@@ -350,6 +353,9 @@ function NotConnected({ reason, claudeMissing }) {
350
353
  </p>
351
354
  )}
352
355
  </div>
356
+ {readiness ? (
357
+ <ReadinessList report={readiness} loading={readinessLoading} refresh={onRecheck} />
358
+ ) : null}
353
359
  <div className="chat-trust">
354
360
  <div className="chat-trust-row">
355
361
  <Check /> Runs on your Pro/Max subscription
@@ -481,30 +487,44 @@ export default function ChatPanel({
481
487
 
482
488
  // Availability is global (is claude installed) — a single probe, no connection.
483
489
  const [status, setStatus] = useState({ available: null, reason: undefined, claudeMissing: false });
484
- useEffect(() => {
485
- let alive = true;
486
- fetch('/_api/acp/status')
487
- .then((r) => r.json())
488
- .then((d) => {
489
- if (alive)
490
+ const probeStatus = useCallback(
491
+ () =>
492
+ fetch('/_api/acp/status')
493
+ .then((r) => r.json())
494
+ .then((d) =>
490
495
  setStatus({
491
496
  available: d.available,
492
497
  reason: d.reason,
493
498
  claudeMissing: !!d.adapterEntry && !d.claudePath,
494
- });
495
- })
496
- .catch(() => {
497
- if (alive)
499
+ })
500
+ )
501
+ .catch(() =>
498
502
  setStatus({
499
503
  available: false,
500
504
  reason: 'Could not reach the Claude bridge.',
501
505
  claudeMissing: false,
502
- });
503
- });
504
- return () => {
505
- alive = false;
506
- };
507
- }, []);
506
+ })
507
+ ),
508
+ []
509
+ );
510
+ useEffect(() => {
511
+ probeStatus();
512
+ }, [probeStatus]);
513
+
514
+ // DDR-128 — when not connected, surface the full readiness breakdown (claude ·
515
+ // maude · plugins) in the not-connected explainer. Only probes when actually
516
+ // disconnected; Re-check re-probes BOTH the readiness report and the bridge
517
+ // status, so installing the missing pieces and re-checking can unlock the panel
518
+ // without reopening it (the persistent re-check surface).
519
+ const {
520
+ report: readiness,
521
+ loading: readinessLoading,
522
+ refresh: refreshReadiness,
523
+ } = useReadiness(status.available === false);
524
+ const recheck = useCallback(() => {
525
+ refreshReadiness();
526
+ return probeStatus();
527
+ }, [refreshReadiness, probeStatus]);
508
528
 
509
529
  // Recents (for the switcher).
510
530
  const [chats, setChats] = useState([]);
@@ -741,7 +761,13 @@ export default function ChatPanel({
741
761
  ) : null}
742
762
  <div className="st-rp-body st-rp-body--chat">
743
763
  {status.available === false ? (
744
- <NotConnected reason={status.reason} claudeMissing={status.claudeMissing} />
764
+ <NotConnected
765
+ reason={status.reason}
766
+ claudeMissing={status.claudeMissing}
767
+ readiness={readiness}
768
+ readinessLoading={readinessLoading}
769
+ onRecheck={recheck}
770
+ />
745
771
  ) : (
746
772
  openChatIds.map((id) => {
747
773
  const conn = connsRef.current.get(id);
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { useEffect, useRef, useState } from 'react';
14
14
 
15
+ import ReadinessList, { useReadiness } from './ReadinessList.jsx';
15
16
  import {
16
17
  cloneRepo,
17
18
  createProject,
@@ -143,6 +144,39 @@ function CrashOptIn() {
143
144
  );
144
145
  }
145
146
 
147
+ // AI-editing readiness (Phase 33 / DDR-128). NON-BLOCKING: the bundled core
148
+ // (canvas browser, version history, sharing) works with zero install, so this is a
149
+ // quiet, collapsible strip — never a gate. It surfaces what AI editing additionally
150
+ // needs (a paired Claude Code with the maude plugins + the `maude` CLI) so a missing
151
+ // piece is visible instead of a silent no-op when the user later types /design:edit.
152
+ // Stays silent until the probe answers (endpoint unreachable → render nothing).
153
+ function AiReadiness() {
154
+ const { report, loading, refresh } = useReadiness();
155
+ if (!report) return null;
156
+ const ready = report.ready;
157
+ return (
158
+ <details className="ob-readiness">
159
+ <summary className="ob-readiness-sum">
160
+ <span className={`ob-readiness-dot ob-readiness-dot--${ready ? 'ok' : 'warn'}`} aria-hidden="true" />
161
+ <span className="ob-readiness-lede">
162
+ {ready ? 'AI editing is ready' : 'AI editing needs a couple of things'}
163
+ </span>
164
+ <span className="ob-readiness-hint">
165
+ {ready ? 'optional — view details' : 'optional · the rest of Maude works without it'}
166
+ </span>
167
+ </summary>
168
+ <div className="ob-readiness-body">
169
+ <p className="ob-readiness-note">
170
+ Everything else — the canvas browser, version history, sharing — works right now. AI
171
+ editing additionally pairs with a Claude Code you already have installed and runs on your
172
+ own Pro/Max subscription.
173
+ </p>
174
+ <ReadinessList report={report} loading={loading} refresh={refresh} />
175
+ </div>
176
+ </details>
177
+ );
178
+ }
179
+
146
180
  // ── A · Welcome (door picker) ────────────────────────────────────────────────
147
181
  function Welcome({ onGithub, onLocal, onHub, signing }) {
148
182
  return (
@@ -181,6 +215,7 @@ function Welcome({ onGithub, onLocal, onHub, signing }) {
181
215
  </button>
182
216
  </div>
183
217
  <p className="ob-foot-note">Maude never touches the terminal. Everything here happens in the app.</p>
218
+ <AiReadiness />
184
219
  <CrashOptIn />
185
220
  </main>
186
221
  );
@@ -0,0 +1,189 @@
1
+ // First-open AI-editing readiness list (DDR-128). Reads GET /_api/preflight and
2
+ // renders one row per dependency (claude · maude · plugins · agent-browser) with a
3
+ // status glyph, a one-line detail, and a copy-paste remediation when something's
4
+ // missing. Detect-and-guide only — it never installs or mutates anything.
5
+ //
6
+ // Shared by the onboarding wizard (a non-blocking strip) and the ChatPanel
7
+ // not-connected explainer (where a user actually hits the wall). Both also use it
8
+ // as the persistent re-check surface — `refresh()` re-probes without a reinstall.
9
+
10
+ import { useCallback, useEffect, useState } from 'react';
11
+
12
+ /** Fetch + cache the readiness report. `refresh()` re-probes (the re-check button). */
13
+ export function useReadiness(enabled = true) {
14
+ const [report, setReport] = useState(null); // { ready, items } | null
15
+ const [loading, setLoading] = useState(enabled);
16
+
17
+ const refresh = useCallback(() => {
18
+ setLoading(true);
19
+ return fetch('/_api/preflight')
20
+ .then((r) => r.json())
21
+ .then((d) => {
22
+ setReport(d && Array.isArray(d.items) ? d : null);
23
+ setLoading(false);
24
+ return d;
25
+ })
26
+ .catch(() => {
27
+ setReport(null);
28
+ setLoading(false);
29
+ return null;
30
+ });
31
+ }, []);
32
+
33
+ useEffect(() => {
34
+ if (!enabled) return undefined;
35
+ let alive = true;
36
+ fetch('/_api/preflight')
37
+ .then((r) => r.json())
38
+ .then((d) => alive && (setReport(d && Array.isArray(d.items) ? d : null), setLoading(false)))
39
+ .catch(() => alive && (setReport(null), setLoading(false)));
40
+ return () => {
41
+ alive = false;
42
+ };
43
+ }, [enabled]);
44
+
45
+ return { report, loading, refresh };
46
+ }
47
+
48
+ function StatusIcon({ status }) {
49
+ if (status === 'present')
50
+ return (
51
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
52
+ <polyline points="3 8.4 6.4 11.8 13 4.4" />
53
+ </svg>
54
+ );
55
+ if (status === 'unknown')
56
+ return (
57
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
58
+ <path d="M6 6a2 2 0 1 1 2.6 1.9c-.4.2-.6.5-.6 1V10" />
59
+ <circle cx="8" cy="12.4" r="0.6" fill="currentColor" stroke="none" />
60
+ </svg>
61
+ );
62
+ return (
63
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
64
+ <circle cx="8" cy="8" r="5.5" />
65
+ <line x1="8" y1="5" x2="8" y2="8.6" />
66
+ </svg>
67
+ );
68
+ }
69
+
70
+ /** Render `code`-fenced spans inside a string as <code>, the rest as text. */
71
+ function Inline({ text }) {
72
+ const parts = [];
73
+ const re = /`([^`]+)`/g;
74
+ let last = 0;
75
+ let m;
76
+ while ((m = re.exec(text)) !== null) {
77
+ if (m.index > last) parts.push(text.slice(last, m.index));
78
+ parts.push(<code key={parts.length}>{m[1]}</code>);
79
+ last = m.index + m[0].length;
80
+ }
81
+ if (last < text.length) parts.push(text.slice(last));
82
+ return <>{parts}</>;
83
+ }
84
+
85
+ function Row({ item }) {
86
+ const [copied, setCopied] = useState(false);
87
+ const copyFix = () => {
88
+ const cmds = item.remediation?.replace(/`/g, '') ?? '';
89
+ navigator.clipboard?.writeText(cmds).then(
90
+ () => {
91
+ setCopied(true);
92
+ setTimeout(() => setCopied(false), 1500);
93
+ },
94
+ () => {}
95
+ );
96
+ };
97
+ return (
98
+ <li className={`rdy-row rdy-row--${item.status}${item.required ? '' : ' rdy-row--opt'}`}>
99
+ <span className="rdy-ic" aria-hidden="true">
100
+ <StatusIcon status={item.status} />
101
+ </span>
102
+ <span className="rdy-tx">
103
+ <span className="rdy-label">{item.label}</span>
104
+ <span className="rdy-detail">
105
+ <Inline text={item.detail} />
106
+ </span>
107
+ {item.remediation ? (
108
+ <span className="rdy-fix">
109
+ <span className="rdy-fix-tx">
110
+ <Inline text={item.remediation} />
111
+ </span>
112
+ <button type="button" className="rdy-copy" onClick={copyFix} aria-label={`Copy the fix for ${item.label}`}>
113
+ {copied ? 'Copied' : 'Copy'}
114
+ </button>
115
+ </span>
116
+ ) : null}
117
+ </span>
118
+ </li>
119
+ );
120
+ }
121
+
122
+ export default function ReadinessList({ report, loading, refresh }) {
123
+ return (
124
+ <div className="rdy">
125
+ <ul className="rdy-list">
126
+ {report?.items?.map((it) => (
127
+ <Row key={it.id} item={it} />
128
+ ))}
129
+ </ul>
130
+ {refresh ? (
131
+ <div className="rdy-foot">
132
+ <button type="button" className="btn btn--ghost btn--sm rdy-recheck" onClick={refresh} disabled={loading}>
133
+ {loading ? 'Checking…' : 'Re-check'}
134
+ </button>
135
+ </div>
136
+ ) : null}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ // Standalone readiness modal reachable any time from Help ▸ Check AI editing
142
+ // readiness… (DDR-128, T6 — the persistent re-check surface, not gated on
143
+ // first-run or a disconnected chat). Reuses the shared help-modal chrome (backdrop
144
+ // + header + body) so it matches the What's New / Help dialogs, and the same
145
+ // ReadinessList. Self-contained: probes only while open (the hook's `enabled` gate),
146
+ // re-probes via the list's Re-check button.
147
+ export function ReadinessDialog({ open, onClose }) {
148
+ const { report, loading, refresh } = useReadiness(open);
149
+ useEffect(() => {
150
+ if (!open) return undefined;
151
+ const onKey = (e) => e.key === 'Escape' && onClose();
152
+ window.addEventListener('keydown', onKey);
153
+ return () => window.removeEventListener('keydown', onKey);
154
+ }, [open, onClose]);
155
+ if (!open) return null;
156
+ return (
157
+ <div
158
+ className="help-modal-backdrop"
159
+ role="presentation"
160
+ onMouseDown={(e) => {
161
+ if (e.target === e.currentTarget) onClose();
162
+ }}
163
+ >
164
+ <div className="help-modal rdy-modal" role="dialog" aria-modal="true" aria-labelledby="rdy-modal-title">
165
+ <header className="help-modal-hd">
166
+ <span className="title" id="rdy-modal-title">
167
+ Check AI editing readiness
168
+ </span>
169
+ <button type="button" className="help-modal-close" aria-label="Close (Esc)" onClick={onClose}>
170
+ ×
171
+ </button>
172
+ </header>
173
+ <div className="help-modal-body">
174
+ <p className="rdy-modal-note">
175
+ The canvas browser, version history, and sharing work with no setup. AI editing
176
+ additionally drives a Claude Code you have installed — here's what it needs:
177
+ </p>
178
+ {report ? (
179
+ <ReadinessList report={report} loading={loading} refresh={refresh} />
180
+ ) : (
181
+ <p className="rdy-modal-note">
182
+ {loading ? 'Checking…' : "Couldn't reach the readiness probe."}
183
+ </p>
184
+ )}
185
+ </div>
186
+ </div>
187
+ </div>
188
+ );
189
+ }
@@ -1617,6 +1617,18 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1617
1617
  .ob-crash-optin { display: flex; align-items: flex-start; gap: var(--space-2); max-width: 560px; margin: var(--space-3) 0 0; color: var(--fg-2); font-size: var(--type-xs); line-height: var(--lh-md); cursor: pointer; text-align: left; }
1618
1618
  .ob-crash-optin input { margin-top: 2px; flex: 0 0 auto; accent-color: var(--accent); cursor: pointer; }
1619
1619
 
1620
+ /* AI-editing readiness strip (Phase 33 / DDR-128) — non-blocking, collapsible. */
1621
+ .ob-readiness { max-width: 560px; margin: var(--space-3) 0 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-1); text-align: left; }
1622
+ .ob-readiness-sum { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-3); cursor: pointer; list-style: none; font-size: var(--type-sm); color: var(--fg-1); }
1623
+ .ob-readiness-sum::-webkit-details-marker { display: none; }
1624
+ .ob-readiness-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; }
1625
+ .ob-readiness-dot--ok { background: var(--status-success); }
1626
+ .ob-readiness-dot--warn { background: var(--status-warning, var(--status-info)); }
1627
+ .ob-readiness-lede { font-weight: 600; color: var(--fg-0); }
1628
+ .ob-readiness-hint { margin-left: auto; color: var(--fg-3); font-size: var(--type-xs); }
1629
+ .ob-readiness-body { padding: 0 var(--space-3) var(--space-3); display: flex; flex-direction: column; gap: var(--space-3); }
1630
+ .ob-readiness-note { margin: 0; color: var(--fg-2); font-size: var(--type-xs); line-height: var(--lh-md); }
1631
+
1620
1632
  .ob-create { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--space-4); width: 100%; max-width: 720px; text-align: left; padding: var(--space-5); border: 1px solid color-mix(in oklab, var(--accent) 45%, transparent); border-radius: var(--radius-lg); background: linear-gradient(180deg, var(--accent-tint) 0%, transparent 80%), var(--bg-1); cursor: pointer; color: inherit; transition: border-color var(--dur-soft) var(--ease-out); }
1621
1633
  .ob-create:hover { border-color: var(--accent); }
1622
1634
  .ob-create:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@@ -769,3 +769,53 @@
769
769
  }
770
770
  }
771
771
  }
772
+
773
+ /* DDR-128 — AI-editing readiness list (ReadinessList.jsx). Shared by the onboarding
774
+ readiness strip and the ChatPanel not-connected explainer. Detect-and-guide:
775
+ present rows reassure, missing rows carry a copy-paste fix, optional rows stay
776
+ muted (never alarming). */
777
+ @layer components {
778
+ .rdy { display: flex; flex-direction: column; gap: var(--space-3); }
779
+ .rdy-list {
780
+ list-style: none; margin: 0; padding: 0;
781
+ display: flex; flex-direction: column; gap: var(--space-2);
782
+ }
783
+ .rdy-row {
784
+ display: flex; gap: var(--space-2); align-items: flex-start;
785
+ padding: var(--space-2) var(--space-3);
786
+ border: 1px solid var(--border-subtle); border-radius: var(--radius-sm);
787
+ background: var(--bg-2); text-align: left;
788
+ }
789
+ .rdy-ic { flex-shrink: 0; margin-top: 1px; }
790
+ .rdy-row--present .rdy-ic { color: var(--status-success); }
791
+ .rdy-row--missing .rdy-ic { color: var(--status-error); }
792
+ .rdy-row--unknown .rdy-ic { color: var(--status-info); }
793
+ /* optional, missing → muted, not alarming */
794
+ .rdy-row--opt.rdy-row--missing .rdy-ic { color: var(--fg-3); }
795
+ .rdy-tx { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
796
+ .rdy-label { font-size: var(--type-sm); font-weight: 600; color: var(--fg-0); }
797
+ .rdy-detail { font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-xs); }
798
+ .rdy-fix {
799
+ margin-top: var(--space-2); display: flex; gap: var(--space-2);
800
+ align-items: flex-start;
801
+ font-size: var(--type-xs); color: var(--fg-1); line-height: var(--lh-xs);
802
+ }
803
+ .rdy-fix-tx { min-width: 0; }
804
+ .rdy code {
805
+ font-family: var(--font-mono); font-size: var(--type-xs);
806
+ color: var(--fg-0); background: var(--bg-3);
807
+ padding: 1px 5px; border-radius: var(--radius-xs);
808
+ overflow-wrap: anywhere;
809
+ }
810
+ .rdy-copy {
811
+ flex-shrink: 0; cursor: pointer;
812
+ font-size: var(--type-xs); color: var(--fg-2);
813
+ background: transparent; border: 1px solid var(--border-subtle);
814
+ border-radius: var(--radius-xs); padding: 1px 6px;
815
+ }
816
+ .rdy-copy:hover { color: var(--fg-0); background: var(--bg-3); }
817
+ .rdy-foot { display: flex; justify-content: flex-end; }
818
+ /* Help ▸ Check AI editing readiness… modal (reuses .help-modal chrome). */
819
+ .rdy-modal { max-width: 520px; }
820
+ .rdy-modal-note { margin: 0 0 var(--space-3); color: var(--fg-2); font-size: var(--type-sm); line-height: var(--lh-md); }
821
+ }