@1agh/maude 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/commands/init.mjs +7 -1
  117. package/cli/lib/gitignore-block.mjs +1 -0
  118. package/cli/lib/pkg-root.mjs +107 -0
  119. package/cli/lib/pkg-root.test.mjs +79 -0
  120. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  121. package/cli/lib/update-check.mjs +8 -0
  122. package/package.json +12 -10
  123. package/plugins/design/dependencies.json +18 -0
  124. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  125. package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
@@ -12,11 +12,18 @@
12
12
  // (app.jsx broadcasts the updated edit down to the canvas). Reuses the shell's
13
13
  // HSV `ColorPicker` when injected; falls back to a native color input.
14
14
  //
15
+ // Controls come from the shared inspector-controls library
16
+ // (feature-inspector-controls-redesign) so bounded adjustments render as REAL
17
+ // sliders (track/thumb, keyboard-accessible) linked to an exact numeric field,
18
+ // and every row carries a single control (the old DUOTONE / Pattern rows doubled
19
+ // up two controls and overflowed the 304px panel).
20
+ //
15
21
  // Sections + ranges mirror photo/schema.ts exactly:
16
22
  // Adjustments (brightness/contrast/saturation/exposure −1..1, hue −180..180,
17
23
  // sepia/grayscale/invert 0..1) · Duotone · Grain · Pattern · Mask · Background.
18
24
 
19
25
  import { useCallback, useEffect, useRef, useState } from 'react';
26
+ import { ColorField, PanelSection, Select, SliderField, Toggle } from './inspector-controls.jsx';
20
27
 
21
28
  const PATTERN_TYPES = ['dots', 'grid', 'lines', 'diagonal', 'crosshatch'];
22
29
  const PATTERN_BLENDS = ['normal', 'multiply', 'screen', 'overlay', 'soft-light'];
@@ -55,122 +62,103 @@ function prune(edit) {
55
62
  // ── small inline-styled primitives ──────────────────────────────────────────
56
63
 
57
64
  // Theme tokens — the REAL shell tokens (client/styles/1-tokens.css), defined
58
- // for both `:root`/`[data-theme="light"]` and `[data-theme="dark"]`. Earlier
59
- // this used invented `--st-*` names (--st-fg/--st-border/--st-btn-bg/--st-mono)
60
- // that were never actually declared anywhere in the stylesheet, so every rule
61
- // silently fell back to its hardcoded dark-mode default — the panel looked
62
- // fine in dark mode purely by coincidence and was low-contrast/washed out in
63
- // light mode.
65
+ // for both `:root`/`[data-theme="light"]` and `[data-theme="dark"]`.
64
66
  const S = {
65
- body: { padding: '10px 12px', fontSize: 12, color: 'var(--fg-0)', overflowY: 'auto' },
66
- sec: { marginBottom: 14 },
67
+ body: { fontSize: 12, color: 'var(--fg-0)', overflowY: 'auto' },
68
+ sec: { marginBottom: 'var(--space-4)' },
67
69
  secHead: {
68
70
  display: 'flex',
69
71
  alignItems: 'center',
70
72
  justifyContent: 'space-between',
71
73
  fontSize: 11,
72
74
  textTransform: 'uppercase',
73
- letterSpacing: '.06em',
75
+ letterSpacing: 'var(--tracking-wide)',
74
76
  color: 'var(--fg-2)',
75
77
  margin: '4px 0 8px',
76
78
  },
77
- row: { display: 'flex', alignItems: 'center', gap: 8, margin: '5px 0' },
78
- label: { flex: '0 0 78px', fontSize: 11, color: 'var(--fg-1)' },
79
- range: { flex: 1, minWidth: 0 },
80
- num: {
81
- flex: '0 0 42px',
82
- textAlign: 'right',
79
+ row: { display: 'flex', alignItems: 'center', gap: 8, margin: 'var(--space-2) 0' },
80
+ label: { flex: '0 0 72px', fontSize: 11, color: 'var(--fg-1)' },
81
+ tip: {
82
+ flex: 1,
83
83
  fontFamily: 'var(--font-mono, ui-monospace, monospace)',
84
- fontSize: 11,
84
+ fontSize: 10,
85
85
  color: 'var(--fg-2)',
86
+ textAlign: 'left',
86
87
  },
87
- btn: {
88
- font: 'inherit',
89
- fontSize: 11,
90
- padding: '5px 10px',
91
- borderRadius: 6,
92
- border: '1px solid var(--border-default)',
93
- background: 'var(--bg-3)',
94
- color: 'var(--fg-0)',
95
- cursor: 'pointer',
96
- },
88
+ // handoff — tips go BELOW the control (help text), not inline right, aligned
89
+ // under the control column (past the 72px label).
90
+ tipBelow: { marginLeft: 80, marginTop: 2, marginBottom: 'var(--space-2)', fontSize: 10, color: 'var(--fg-2)', lineHeight: 1.4 },
97
91
  reset: {
98
92
  font: 'inherit',
99
93
  fontSize: 10,
100
94
  padding: '1px 6px',
101
- borderRadius: 4,
95
+ borderRadius: 'var(--radius-xs)',
102
96
  border: '1px solid transparent',
103
97
  background: 'transparent',
104
98
  color: 'var(--fg-2)',
105
99
  cursor: 'pointer',
106
100
  },
107
- select: {
108
- font: 'inherit',
109
- fontSize: 11,
110
- padding: '3px 6px',
111
- borderRadius: 5,
112
- border: '1px solid var(--border-default)',
113
- background: 'var(--bg-3)',
114
- color: 'var(--fg-0)',
115
- },
116
101
  };
117
102
 
118
- function Section({ title, right, children }) {
103
+ // handoff photo sections are now the shared collapsable PanelSection (caret +
104
+ // height animation), with the enable Toggle in the header's `right` slot.
105
+ function Section({ title, right, onReset, children }) {
119
106
  return (
120
- <section style={S.sec}>
121
- <div style={S.secHead}>
122
- <span>{title}</span>
123
- {right}
124
- </div>
107
+ <PanelSection title={title} right={right} onReset={onReset}>
125
108
  {children}
126
- </section>
127
- );
128
- }
129
-
130
- function Slider({ label, value, min, max, step = 0.01, unit = '', onChange, onCommit }) {
131
- const v = value ?? (min < 0 ? 0 : min);
132
- return (
133
- <div style={S.row}>
134
- <span style={S.label}>{label}</span>
135
- <input
136
- type="range"
137
- style={S.range}
138
- min={min}
139
- max={max}
140
- step={step}
141
- value={v}
142
- onChange={(e) => onChange(Number(e.target.value))}
143
- onPointerUp={onCommit}
144
- onKeyUp={onCommit}
145
- aria-label={label}
146
- />
147
- <span style={S.num}>
148
- {step >= 1 ? Math.round(v) : v.toFixed(2)}
149
- {unit}
150
- </span>
151
- </div>
109
+ </PanelSection>
152
110
  );
153
111
  }
154
112
 
155
- function Toggle({ checked, onChange, label }) {
113
+ // One label + one control per row — the fix for the doubled-up DUOTONE / Pattern
114
+ // rows that overflowed the panel. SliderField/Select fill the control side.
115
+ function Row({ label, tip, children }) {
156
116
  return (
157
- <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer' }}>
158
- <input type="checkbox" checked={!!checked} onChange={(e) => onChange(e.target.checked)} />
159
- {label}
160
- </label>
117
+ <>
118
+ <div style={S.row}>
119
+ <span style={S.label}>{label}</span>
120
+ {children}
121
+ </div>
122
+ {tip ? <div style={S.tipBelow}>{tip}</div> : null}
123
+ </>
161
124
  );
162
125
  }
163
126
 
164
- function ColorSwatch({ value, fallback, ColorPicker, onApply }) {
127
+ // Compact colour field for the photo panel — the shared ColorField shell (swatch
128
+ // flush prefix + value) whose swatch opens a popover holding the injected HSV
129
+ // ColorPicker (falls back to a native colour input). 1:1 with the CSS panel's
130
+ // colour control, just with the pixel-level picker instead of the token popover.
131
+ function PhotoColorField({ value, fallback, label, ColorPicker, onApply }) {
132
+ const [open, setOpen] = useState(false);
133
+ const wrapRef = useRef(null);
165
134
  const hex = value || fallback;
166
- if (ColorPicker) return <ColorPicker seed={hex} onApply={onApply} />;
135
+ useEffect(() => {
136
+ if (!open) return undefined;
137
+ const onDoc = (e) => {
138
+ if (!wrapRef.current?.contains(e.target)) setOpen(false);
139
+ };
140
+ document.addEventListener('pointerdown', onDoc, true);
141
+ return () => document.removeEventListener('pointerdown', onDoc, true);
142
+ }, [open]);
167
143
  return (
168
- <input
169
- type="color"
170
- value={hex}
171
- onChange={(e) => onApply(e.target.value)}
172
- style={{ width: 28, height: 20, padding: 0, border: 'none', background: 'none' }}
173
- aria-label="color"
144
+ <ColorField
145
+ swatch={
146
+ <span className="st-cp-tokwrap" ref={wrapRef}>
147
+ <button type="button" className="st-cp-cf-sw" style={{ background: hex }} aria-haspopup="dialog" aria-expanded={open} aria-label={label} title={hex} onClick={() => setOpen((o) => !o)} />
148
+ {open ? (
149
+ <div className="st-cp-pop" role="dialog" aria-label={label} style={{ padding: 'var(--space-2)' }}>
150
+ {ColorPicker ? (
151
+ <ColorPicker seed={hex} label={label} onApply={onApply} />
152
+ ) : (
153
+ <input type="color" value={hex} aria-label={label} onChange={(e) => onApply(e.target.value)} style={{ width: '100%', height: 32 }} />
154
+ )}
155
+ </div>
156
+ ) : null}
157
+ </span>
158
+ }
159
+ displayValue={(hex || '').replace('#', '')}
160
+ ariaLabel={label}
161
+ onValue={(v) => onApply(v.startsWith('#') ? v : `#${v.replace(/[^0-9a-fA-F]/g, '').slice(0, 6)}`)}
174
162
  />
175
163
  );
176
164
  }
@@ -185,12 +173,15 @@ function ColorSwatch({ value, fallback, ColorPicker, onApply }) {
185
173
  * @param {Function} [p.onEdit] (edit) => void — live-preview broadcast.
186
174
  * @param {Function} [p.onRemoveBackground] async (asset) => { maskAsset } | null.
187
175
  * @param {Function} [p.onRecordEdit] (before, after) => void — undo integration.
176
+ * @param {Function} [p.StIcon] Injected shell icon component (optional).
188
177
  */
189
- export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBackground, onRecordEdit }) {
178
+ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBackground, onRecordEdit, StIcon }) {
190
179
  const [edit, setEditState] = useState(() => clone(initialEdit));
191
180
  const [loading, setLoading] = useState(!initialEdit);
192
181
  const [saveState, setSaveState] = useState('idle'); // idle | saving | saved | error
193
182
  const [bgBusy, setBgBusy] = useState(false);
183
+ // sr-only live-region text for the background-removal busy state (Task 19).
184
+ const [bgAnnounce, setBgAnnounce] = useState('');
194
185
  const putTimer = useRef(null);
195
186
  const editRef = useRef(edit);
196
187
  editRef.current = edit;
@@ -262,6 +253,29 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
262
253
  }, { commit });
263
254
 
264
255
  const resetAdjustments = () => mutate((e) => delete e.adjustments, { commit: true });
256
+ // handoff — per-section reset (⟲): remove the section → back to its defaults.
257
+ const clearSection = (section) => mutate((e) => { delete e[section]; }, { commit: true });
258
+
259
+ // Shared by both the initial "Remove Background" and the "redo" button —
260
+ // drives the busy state + the sr-only live-region announcement (Task 19).
261
+ const runRemoveBackground = async () => {
262
+ if (!onRemoveBackground) return;
263
+ setBgBusy(true);
264
+ setBgAnnounce('Removing background…');
265
+ try {
266
+ const res = await onRemoveBackground(asset);
267
+ if (res?.maskAsset) {
268
+ setSection('backgroundRemoved', { enabled: true, maskAsset: res.maskAsset }, true);
269
+ setBgAnnounce('Background removed');
270
+ } else {
271
+ setBgAnnounce('Background removal failed');
272
+ }
273
+ } catch {
274
+ setBgAnnounce('Background removal failed');
275
+ } finally {
276
+ setBgBusy(false);
277
+ }
278
+ };
265
279
 
266
280
  if (loading) return <div style={{ ...S.body, opacity: 0.6 }}>Loading photo edit…</div>;
267
281
 
@@ -274,49 +288,46 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
274
288
 
275
289
  return (
276
290
  <div style={S.body} data-testid="photo-knobs">
277
- <div style={{ ...S.secHead, marginTop: 0, color: 'var(--fg-3)', textTransform: 'none', letterSpacing: 0 }}>
291
+ <div style={{ ...S.secHead, marginTop: 0, color: 'var(--fg-2)', textTransform: 'none', letterSpacing: 0 }}>
278
292
  <span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: 10 }}>{asset}</span>
279
293
  <span style={{ fontSize: 10, color: saveState === 'error' ? 'var(--status-error)' : undefined }}>
280
294
  {saveState === 'saving' ? 'saving…' : saveState === 'error' ? '⚠ save failed' : saveState === 'saved' ? 'saved' : ''}
281
295
  </span>
282
296
  </div>
283
297
 
284
- <Section
285
- title="Adjustments"
286
- right={
287
- <button type="button" style={S.reset} onClick={resetAdjustments} title="Reset adjustments">
288
- reset
289
- </button>
290
- }
291
- >
298
+ <Section title="Adjustments" onReset={resetAdjustments}>
292
299
  {ADJUSTMENTS.map((a) => (
293
- <Slider
294
- key={a.key}
295
- label={a.label}
296
- value={adj[a.key]}
297
- min={a.min}
298
- max={a.max}
299
- step={a.step ?? 0.01}
300
- unit={a.unit ?? ''}
301
- onChange={(v) => setAdj(a.key, v)}
302
- onCommit={() => setAdj(a.key, adj[a.key] ?? 0, true)}
303
- />
300
+ <Row key={a.key} label={a.label}>
301
+ <SliderField
302
+ value={adj[a.key]}
303
+ min={a.min}
304
+ max={a.max}
305
+ step={a.step ?? 0.01}
306
+ unit={a.unit ?? ''}
307
+ ariaLabel={a.label}
308
+ onInput={(v) => setAdj(a.key, v)}
309
+ onCommit={(v) => setAdj(a.key, v, true)}
310
+ />
311
+ </Row>
304
312
  ))}
305
313
  </Section>
306
314
 
307
315
  <Section
308
316
  title="Duotone"
309
317
  right={<Toggle checked={duo.enabled} onChange={(v) => setSection('duotone', { enabled: v }, true)} label="on" />}
318
+ onReset={() => clearSection('duotone')}
310
319
  >
311
320
  {duo.enabled && (
312
321
  <>
313
- <div style={S.row}>
314
- <span style={S.label}>Shadow</span>
315
- <ColorSwatch value={duo.colorA} fallback="#111111" ColorPicker={ColorPicker} onApply={(hex) => setSection('duotone', { colorA: hex }, true)} />
316
- <span style={S.label}>Highlight</span>
317
- <ColorSwatch value={duo.colorB} fallback="#ffffff" ColorPicker={ColorPicker} onApply={(hex) => setSection('duotone', { colorB: hex }, true)} />
318
- </div>
319
- <Slider label="Intensity" value={duo.intensity ?? 1} min={0} max={1} onChange={(v) => setSection('duotone', { intensity: v })} onCommit={() => setSection('duotone', { intensity: duo.intensity ?? 1 }, true)} />
322
+ <Row label="Shadow">
323
+ <PhotoColorField value={duo.colorA} fallback="#111111" label="Shadow" ColorPicker={ColorPicker} onApply={(hex) => setSection('duotone', { colorA: hex }, true)} />
324
+ </Row>
325
+ <Row label="Highlight">
326
+ <PhotoColorField value={duo.colorB} fallback="#ffffff" label="Highlight" ColorPicker={ColorPicker} onApply={(hex) => setSection('duotone', { colorB: hex }, true)} />
327
+ </Row>
328
+ <Row label="Intensity">
329
+ <SliderField value={duo.intensity ?? 1} min={0} max={1} ariaLabel="Duotone intensity" onInput={(v) => setSection('duotone', { intensity: v })} onCommit={(v) => setSection('duotone', { intensity: v }, true)} />
330
+ </Row>
320
331
  </>
321
332
  )}
322
333
  </Section>
@@ -324,11 +335,20 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
324
335
  <Section
325
336
  title="Grain"
326
337
  right={<Toggle checked={grain.enabled} onChange={(v) => setSection('grain', { enabled: v }, true)} label="on" />}
338
+ onReset={() => clearSection('grain')}
327
339
  >
328
340
  {grain.enabled && (
329
341
  <>
330
- <Slider label="Amount" value={grain.amount ?? 0.4} min={0} max={1} onChange={(v) => setSection('grain', { amount: v })} onCommit={() => setSection('grain', { amount: grain.amount ?? 0.4 }, true)} />
331
- <Slider label="Size" value={grain.size ?? 1} min={1} max={8} step={0.5} onChange={(v) => setSection('grain', { size: v })} onCommit={() => setSection('grain', { size: grain.size ?? 1 }, true)} />
342
+ <Row label="Amount">
343
+ <SliderField value={grain.amount ?? 0.4} min={0} max={1} ariaLabel="Grain amount" onInput={(v) => setSection('grain', { amount: v })} onCommit={(v) => setSection('grain', { amount: v }, true)} />
344
+ </Row>
345
+ <Row label="Size">
346
+ {/* Task 7 (feature-inspector-controls-redesign) — range reconciled
347
+ against photo/schema.ts's clamp (num(errors, g, 'size', 1, 32));
348
+ the panel used to cap at 8, silently hiding the top 3/4 of what
349
+ the server would actually accept. */}
350
+ <SliderField value={grain.size ?? 1} min={1} max={32} step={1} ariaLabel="Grain size" onInput={(v) => setSection('grain', { size: v })} onCommit={(v) => setSection('grain', { size: v }, true)} />
351
+ </Row>
332
352
  </>
333
353
  )}
334
354
  </Section>
@@ -336,56 +356,44 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
336
356
  <Section
337
357
  title="Pattern"
338
358
  right={<Toggle checked={pat.enabled} onChange={(v) => setSection('pattern', { enabled: v }, true)} label="on" />}
359
+ onReset={() => clearSection('pattern')}
339
360
  >
340
361
  {pat.enabled && (
341
362
  <>
342
- <div style={S.row}>
343
- <span style={S.label}>Type</span>
344
- <select style={S.select} value={pat.type || 'dots'} onChange={(e) => setSection('pattern', { type: e.target.value }, true)}>
345
- {PATTERN_TYPES.map((t) => (
346
- <option key={t} value={t}>
347
- {t}
348
- </option>
349
- ))}
350
- </select>
351
- <span style={S.label}>Blend</span>
352
- <select style={S.select} value={pat.blend || 'normal'} onChange={(e) => setSection('pattern', { blend: e.target.value }, true)}>
353
- {PATTERN_BLENDS.map((b) => (
354
- <option key={b} value={b}>
355
- {b}
356
- </option>
357
- ))}
358
- </select>
359
- </div>
360
- <div style={S.row}>
361
- <span style={S.label}>Color</span>
362
- <ColorSwatch value={pat.color} fallback="#ffffff" ColorPicker={ColorPicker} onApply={(hex) => setSection('pattern', { color: hex }, true)} />
363
- <span style={{ ...S.num, flex: 1, textAlign: 'left', color: 'var(--fg-3)' }}>tip: dark + multiply</span>
364
- </div>
365
- <Slider label="Scale" value={pat.scale ?? 1} min={0.25} max={4} step={0.25} onChange={(v) => setSection('pattern', { scale: v })} onCommit={() => setSection('pattern', { scale: pat.scale ?? 1 }, true)} />
366
- <Slider label="Opacity" value={pat.opacity ?? 0.5} min={0} max={1} onChange={(v) => setSection('pattern', { opacity: v })} onCommit={() => setSection('pattern', { opacity: pat.opacity ?? 0.5 }, true)} />
363
+ <Row label="Type">
364
+ <Select value={pat.type || 'dots'} options={PATTERN_TYPES} ariaLabel="Pattern type" onChange={(v) => setSection('pattern', { type: v }, true)} />
365
+ </Row>
366
+ <Row label="Blend">
367
+ <Select value={pat.blend || 'normal'} options={PATTERN_BLENDS} ariaLabel="Pattern blend" onChange={(v) => setSection('pattern', { blend: v }, true)} />
368
+ </Row>
369
+ <Row label="Color" tip="tip: dark colour + Multiply blend reads best">
370
+ <PhotoColorField value={pat.color} fallback="#ffffff" label="Pattern color" ColorPicker={ColorPicker} onApply={(hex) => setSection('pattern', { color: hex }, true)} />
371
+ </Row>
372
+ <Row label="Scale">
373
+ {/* Task 7 reconciled against schema.ts's clamp (num(errors, p,
374
+ 'scale', 0.1, 16)); the panel used to cap at 4. */}
375
+ <SliderField value={pat.scale ?? 1} min={0.1} max={16} step={0.1} ariaLabel="Pattern scale" onInput={(v) => setSection('pattern', { scale: v })} onCommit={(v) => setSection('pattern', { scale: v }, true)} />
376
+ </Row>
377
+ <Row label="Opacity">
378
+ <SliderField value={pat.opacity ?? 0.5} min={0} max={1} ariaLabel="Pattern opacity" onInput={(v) => setSection('pattern', { opacity: v })} onCommit={(v) => setSection('pattern', { opacity: v }, true)} />
379
+ </Row>
367
380
  </>
368
381
  )}
369
382
  </Section>
370
383
 
371
- <Section title="Mask">
372
- <div style={S.row}>
373
- <span style={S.label}>Preset</span>
374
- <select style={S.select} value={mask.preset || 'none'} onChange={(e) => setSection('mask', { preset: e.target.value }, true)}>
375
- {MASK_PRESETS.map((m) => (
376
- <option key={m} value={m}>
377
- {m}
378
- </option>
379
- ))}
380
- </select>
381
- </div>
384
+ <Section title="Mask" onReset={() => clearSection('mask')}>
385
+ <Row label="Preset">
386
+ <Select value={mask.preset || 'none'} options={MASK_PRESETS} ariaLabel="Mask preset" onChange={(v) => setSection('mask', { preset: v }, true)} />
387
+ </Row>
382
388
  {mask.preset && mask.preset !== 'none' && (
383
- <Slider label="Strength" value={mask.strength ?? 0.6} min={0} max={1} onChange={(v) => setSection('mask', { strength: v })} onCommit={() => setSection('mask', { strength: mask.strength ?? 0.6 }, true)} />
389
+ <Row label="Strength">
390
+ <SliderField value={mask.strength ?? 0.6} min={0} max={1} ariaLabel="Mask strength" onInput={(v) => setSection('mask', { strength: v })} onCommit={(v) => setSection('mask', { strength: v }, true)} />
391
+ </Row>
384
392
  )}
385
393
  </Section>
386
394
 
387
- <Section title="Background">
388
- <div style={{ ...S.row, gap: 10 }}>
395
+ <Section title="Background" onReset={() => clearSection('backgroundRemoved')}>
396
+ <div style={{ ...S.row, gap: 10 }} aria-busy={bgBusy || undefined}>
389
397
  {bg.maskAsset ? (
390
398
  <>
391
399
  <Toggle checked={bg.enabled} onChange={(v) => setSection('backgroundRemoved', { enabled: v }, true)} label="applied" />
@@ -394,16 +402,7 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
394
402
  style={{ ...S.reset, opacity: bgBusy ? 0.6 : 1 }}
395
403
  disabled={bgBusy || !onRemoveBackground}
396
404
  title="Run background removal again"
397
- onClick={async () => {
398
- if (!onRemoveBackground) return;
399
- setBgBusy(true);
400
- try {
401
- const res = await onRemoveBackground(asset);
402
- if (res?.maskAsset) setSection('backgroundRemoved', { enabled: true, maskAsset: res.maskAsset }, true);
403
- } finally {
404
- setBgBusy(false);
405
- }
406
- }}
405
+ onClick={() => runRemoveBackground()}
407
406
  >
408
407
  {bgBusy ? 'removing…' : 'redo'}
409
408
  </button>
@@ -411,24 +410,20 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
411
410
  ) : (
412
411
  <button
413
412
  type="button"
414
- style={{ ...S.btn, opacity: bgBusy ? 0.6 : 1 }}
413
+ className="st-btn"
414
+ style={{ opacity: bgBusy ? 0.6 : 1 }}
415
415
  disabled={bgBusy || !onRemoveBackground}
416
416
  data-testid="photo-remove-bg"
417
- onClick={async () => {
418
- if (!onRemoveBackground) return;
419
- setBgBusy(true);
420
- try {
421
- const res = await onRemoveBackground(asset);
422
- if (res?.maskAsset) setSection('backgroundRemoved', { enabled: true, maskAsset: res.maskAsset }, true);
423
- } finally {
424
- setBgBusy(false);
425
- }
426
- }}
417
+ onClick={() => runRemoveBackground()}
427
418
  >
428
- {bgBusy ? 'Removing…' : '✦ Remove Background'}
419
+ {StIcon ? <StIcon name="sparkle" size={14} /> : null}
420
+ {bgBusy ? 'Removing…' : 'Remove Background'}
429
421
  </button>
430
422
  )}
431
423
  </div>
424
+ <span aria-live="polite" className="sr-only">
425
+ {bgAnnounce}
426
+ </span>
432
427
  </Section>
433
428
  </div>
434
429
  );