@1agh/maude 0.51.1 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/api.ts +389 -14
- package/apps/studio/bin/_video-playwright.mjs +15 -6
- package/apps/studio/canvas-edit.ts +141 -20
- package/apps/studio/canvas-lib.tsx +61 -0
- package/apps/studio/client/app.jsx +824 -121
- package/apps/studio/client/panels/ClipInspector.jsx +409 -0
- package/apps/studio/client/panels/CloudBar.jsx +12 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
- package/apps/studio/client/panels/timeline-media-cache.js +274 -0
- package/apps/studio/client/panels/timeline-parse.js +46 -1
- package/apps/studio/client/panels/timeline-scale.js +64 -0
- package/apps/studio/client/report-bug.jsx +15 -15
- package/apps/studio/client/styles/3-shell-maude.css +446 -3
- package/apps/studio/client/styles/4-components-maude.css +55 -0
- package/apps/studio/client/styles/4-components.css +0 -83
- package/apps/studio/clip-ops.ts +1980 -0
- package/apps/studio/cloud/endpoints.ts +88 -17
- package/apps/studio/credential-grammar.ts +100 -0
- package/apps/studio/debug-bundle.ts +12 -0
- package/apps/studio/dist/client.bundle.js +2048 -2048
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video.ts +49 -10
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/sync/hub-link.ts +3 -3
- package/apps/studio/sync/hubs-config.ts +32 -0
- package/apps/studio/sync/workspace-signin.ts +8 -4
- package/apps/studio/test/_helpers.ts +5 -3
- package/apps/studio/test/canvas-create-api.test.ts +8 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
- package/apps/studio/test/clip-addressing.test.ts +7 -6
- package/apps/studio/test/clip-ops.test.ts +836 -0
- package/apps/studio/test/cloud-endpoints.test.ts +37 -2
- package/apps/studio/test/comments-api.test.ts +49 -0
- package/apps/studio/test/credential-grammar.test.ts +107 -0
- package/apps/studio/test/read-only-role.test.ts +95 -0
- package/apps/studio/test/ripple.test.ts +197 -0
- package/apps/studio/test/timeline-scale.test.ts +65 -0
- package/apps/studio/test/workspace-signin.test.ts +4 -1
- package/apps/studio/whats-new.json +35 -0
- package/cli/commands/share.mjs +8 -4
- package/cli/lib/ddr-to-kgai.mjs +6 -1
- package/cli/lib/ddr-to-kgai.test.mjs +1 -2
- package/cli/lib/hubs-config.mjs +15 -1
- package/cli/lib/hubs-config.test.mjs +19 -0
- package/cli/lib/share-plan.mjs +5 -1
- package/cli/lib/share-plan.test.mjs +11 -2
- package/package.json +8 -8
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// ClipInspector — feature-enhanced-video-editing (Task 9). The per-clip
|
|
2
|
+
// parametric editing popover, scoped to the timeline selection: tabs
|
|
3
|
+
// Speed · Audio · Crop · Grade (media clips) and Transition (seam chips).
|
|
4
|
+
// Draggable, Esc closes, one instance (owned by TimelinePanel). Values come
|
|
5
|
+
// from the enumerator's authoritative `mediaProps`; every commit goes through
|
|
6
|
+
// `onVerb(verb, params)` → POST /_api/clip-edit (stableId + contentHash).
|
|
7
|
+
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { NumberField, SliderField, Toggle } from '../inspector-controls.jsx';
|
|
11
|
+
|
|
12
|
+
const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4];
|
|
13
|
+
const PRESENTATIONS = ['fade', 'slide', 'wipe', 'flip', 'clock-wipe', 'none'];
|
|
14
|
+
// Named grade "looks" — parameter bundles, still ONE flat filter string on disk.
|
|
15
|
+
const GRADE_PRESETS = {
|
|
16
|
+
None: null,
|
|
17
|
+
Mono: { saturation: 0, contrast: 1.05 },
|
|
18
|
+
Warm: { hue: -12, saturation: 1.15, brightness: 1.03 },
|
|
19
|
+
Cool: { hue: 12, saturation: 1.05, brightness: 0.98 },
|
|
20
|
+
Punch: { contrast: 1.18, saturation: 1.25 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Parse a CSS filter chain back to grade params (mirror of the server's
|
|
24
|
+
* filterToGrade). Null = unrecognized function → read-only badge. */
|
|
25
|
+
export function filterToGradeParams(filter) {
|
|
26
|
+
if (!filter) return {};
|
|
27
|
+
const out = {};
|
|
28
|
+
let rest = String(filter).trim();
|
|
29
|
+
const fnRe = /^([a-z-]+)\(([^)]*)\)\s*/;
|
|
30
|
+
const keys = {
|
|
31
|
+
brightness: 'brightness',
|
|
32
|
+
contrast: 'contrast',
|
|
33
|
+
saturate: 'saturation',
|
|
34
|
+
'hue-rotate': 'hue',
|
|
35
|
+
sepia: 'sepia',
|
|
36
|
+
grayscale: 'grayscale',
|
|
37
|
+
invert: 'invert',
|
|
38
|
+
};
|
|
39
|
+
while (rest.length) {
|
|
40
|
+
const m = rest.match(fnRe);
|
|
41
|
+
if (!m || !keys[m[1]]) return null;
|
|
42
|
+
const v = Number.parseFloat(m[2]);
|
|
43
|
+
if (!Number.isFinite(v)) return null;
|
|
44
|
+
out[keys[m[1]]] = v;
|
|
45
|
+
rest = rest.slice(m[0].length);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function TabBar({ tabs, active, onPick }) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="tlci-tabs" role="tablist">
|
|
53
|
+
{tabs.map((t) => (
|
|
54
|
+
<button
|
|
55
|
+
key={t}
|
|
56
|
+
type="button"
|
|
57
|
+
role="tab"
|
|
58
|
+
aria-selected={active === t}
|
|
59
|
+
className={`tlci-tab${active === t ? ' is-on' : ''}`}
|
|
60
|
+
data-testid={`timeline-inspector-tab-${t.toLowerCase()}`}
|
|
61
|
+
onClick={() => onPick(t)}
|
|
62
|
+
>
|
|
63
|
+
{t}
|
|
64
|
+
</button>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function ClipInspector({
|
|
71
|
+
mode = 'clip', // 'clip' | 'transition'
|
|
72
|
+
clipLabel = '',
|
|
73
|
+
mediaProps = null,
|
|
74
|
+
kind = 'video', // rowKind of the clip
|
|
75
|
+
transition = null, // { dur } when mode === 'transition'
|
|
76
|
+
// Non-null = the clip carries editable text (Title literal / AI prompt).
|
|
77
|
+
textValue = null,
|
|
78
|
+
x = 200,
|
|
79
|
+
y = 200,
|
|
80
|
+
onVerb, // (verb, params) => void
|
|
81
|
+
onClose,
|
|
82
|
+
}) {
|
|
83
|
+
const tabs =
|
|
84
|
+
mode === 'transition'
|
|
85
|
+
? ['Transition']
|
|
86
|
+
: textValue != null && kind !== 'video' && kind !== 'audio'
|
|
87
|
+
? ['Text', 'Speed']
|
|
88
|
+
: kind === 'audio'
|
|
89
|
+
? ['Speed', 'Audio']
|
|
90
|
+
: textValue != null
|
|
91
|
+
? ['Speed', 'Audio', 'Crop', 'Grade', 'Text']
|
|
92
|
+
: ['Speed', 'Audio', 'Crop', 'Grade'];
|
|
93
|
+
const [tab, setTab] = useState(tabs[0]);
|
|
94
|
+
const [pos, setPos] = useState({ x, y });
|
|
95
|
+
const rootRef = useRef(null);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const onKey = (e) => {
|
|
99
|
+
if (e.key === 'Escape') {
|
|
100
|
+
e.stopPropagation();
|
|
101
|
+
onClose?.();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
window.addEventListener('keydown', onKey, true);
|
|
105
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
106
|
+
}, [onClose]);
|
|
107
|
+
|
|
108
|
+
const beginDrag = useCallback(
|
|
109
|
+
(e) => {
|
|
110
|
+
if (e.target.closest('button, input, select')) return;
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
const start = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y };
|
|
113
|
+
const move = (ev) =>
|
|
114
|
+
setPos({ x: start.px + ev.clientX - start.x, y: start.py + ev.clientY - start.y });
|
|
115
|
+
const up = () => {
|
|
116
|
+
window.removeEventListener('pointermove', move);
|
|
117
|
+
window.removeEventListener('pointerup', up);
|
|
118
|
+
};
|
|
119
|
+
window.addEventListener('pointermove', move);
|
|
120
|
+
window.addEventListener('pointerup', up);
|
|
121
|
+
},
|
|
122
|
+
[pos]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Local knob state seeded from the authoritative props; commit on release.
|
|
126
|
+
const rate = mediaProps?.playbackRate ?? 1;
|
|
127
|
+
const volume = mediaProps?.muted ? 0 : (mediaProps?.volume ?? 1);
|
|
128
|
+
const framing = mediaProps?.framing ?? null;
|
|
129
|
+
const gradeParams = filterToGradeParams(mediaProps?.filter);
|
|
130
|
+
const gradeReadOnly = gradeParams == null;
|
|
131
|
+
const [grade, setGrade] = useState(gradeParams ?? {});
|
|
132
|
+
const [frame, setFrame] = useState(framing ?? { scale: 1, x: 0, y: 0 });
|
|
133
|
+
const [transDur, setTransDur] = useState(transition?.dur ?? 15);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
setGrade(filterToGradeParams(mediaProps?.filter) ?? {});
|
|
136
|
+
setFrame(mediaProps?.framing ?? { scale: 1, x: 0, y: 0 });
|
|
137
|
+
}, [mediaProps]);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
setTransDur(transition?.dur ?? 15);
|
|
140
|
+
}, [transition]);
|
|
141
|
+
|
|
142
|
+
const commitGrade = (next) => {
|
|
143
|
+
setGrade(next);
|
|
144
|
+
const meaningful = Object.entries(next).some(
|
|
145
|
+
([k, v]) => (k === 'brightness' || k === 'contrast' || k === 'saturation' ? v !== 1 : v !== 0)
|
|
146
|
+
);
|
|
147
|
+
onVerb?.('grade', { grade: meaningful ? next : null });
|
|
148
|
+
};
|
|
149
|
+
const commitFrame = (next) => {
|
|
150
|
+
setFrame(next);
|
|
151
|
+
onVerb?.('framing', {
|
|
152
|
+
framing: next.scale === 1 && next.x === 0 && next.y === 0 ? null : next,
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const gradeSlider = (label, key, min, max, step, neutral) => (
|
|
157
|
+
<label className="tlci-row" key={key}>
|
|
158
|
+
<span className="tlci-lbl">{label}</span>
|
|
159
|
+
<SliderField
|
|
160
|
+
value={grade[key] ?? neutral}
|
|
161
|
+
min={min}
|
|
162
|
+
max={max}
|
|
163
|
+
step={step}
|
|
164
|
+
onCommit={(v) => commitGrade({ ...grade, [key]: v })}
|
|
165
|
+
ariaLabel={label}
|
|
166
|
+
/>
|
|
167
|
+
</label>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
ref={rootRef}
|
|
173
|
+
className="tlci"
|
|
174
|
+
style={{ left: pos.x, top: pos.y }}
|
|
175
|
+
role="dialog"
|
|
176
|
+
aria-label={`Adjust ${clipLabel || 'clip'}`}
|
|
177
|
+
data-testid="timeline-inspector"
|
|
178
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
179
|
+
>
|
|
180
|
+
<div className="tlci-head" onPointerDown={beginDrag}>
|
|
181
|
+
<span className="tlci-title">{clipLabel || (mode === 'transition' ? 'Transition' : 'Clip')}</span>
|
|
182
|
+
<button type="button" className="tlci-x" aria-label="Close" onClick={onClose}>
|
|
183
|
+
×
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
<TabBar tabs={tabs} active={tab} onPick={setTab} />
|
|
187
|
+
|
|
188
|
+
{tab === 'Speed' && (
|
|
189
|
+
<div className="tlci-body" role="tabpanel">
|
|
190
|
+
<div className="tlci-presets">
|
|
191
|
+
{SPEED_PRESETS.map((p) => (
|
|
192
|
+
<button
|
|
193
|
+
key={p}
|
|
194
|
+
type="button"
|
|
195
|
+
className={`tlci-chip${rate === p ? ' is-on' : ''}`}
|
|
196
|
+
data-testid={`timeline-speed-${String(p).replace('.', '_')}`}
|
|
197
|
+
onClick={() => onVerb?.('speed', { rate: p })}
|
|
198
|
+
>
|
|
199
|
+
{p}×
|
|
200
|
+
</button>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
<label className="tlci-row">
|
|
204
|
+
<span className="tlci-lbl">Custom</span>
|
|
205
|
+
<NumberField
|
|
206
|
+
value={rate}
|
|
207
|
+
min={0.1}
|
|
208
|
+
max={16}
|
|
209
|
+
step={0.05}
|
|
210
|
+
onCommit={(v) => onVerb?.('speed', { rate: v })}
|
|
211
|
+
ariaLabel="Playback rate"
|
|
212
|
+
/>
|
|
213
|
+
</label>
|
|
214
|
+
<p className="tlci-note">Duration recomputes; the cut ripples to stay gapless.</p>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{tab === 'Audio' && (
|
|
219
|
+
<div className="tlci-body" role="tabpanel">
|
|
220
|
+
<label className="tlci-row">
|
|
221
|
+
<span className="tlci-lbl">Muted</span>
|
|
222
|
+
<Toggle
|
|
223
|
+
checked={!!mediaProps?.muted}
|
|
224
|
+
onChange={(v) => onVerb?.('audio', { muted: v })}
|
|
225
|
+
ariaLabel="Mute clip"
|
|
226
|
+
/>
|
|
227
|
+
</label>
|
|
228
|
+
<label className="tlci-row">
|
|
229
|
+
<span className="tlci-lbl">Volume</span>
|
|
230
|
+
<SliderField
|
|
231
|
+
value={volume}
|
|
232
|
+
min={0}
|
|
233
|
+
max={1}
|
|
234
|
+
step={0.05}
|
|
235
|
+
onCommit={(v) => onVerb?.('audio', { volume: v })}
|
|
236
|
+
ariaLabel="Clip volume"
|
|
237
|
+
/>
|
|
238
|
+
</label>
|
|
239
|
+
{kind === 'video' ? (
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
className="tlci-btn"
|
|
243
|
+
data-testid="timeline-detach-audio"
|
|
244
|
+
onClick={() => onVerb?.('detach-audio', {})}
|
|
245
|
+
>
|
|
246
|
+
Detach audio
|
|
247
|
+
</button>
|
|
248
|
+
) : null}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{tab === 'Crop' && (
|
|
253
|
+
<div className="tlci-body" role="tabpanel">
|
|
254
|
+
<label className="tlci-row">
|
|
255
|
+
<span className="tlci-lbl">Scale</span>
|
|
256
|
+
<NumberField
|
|
257
|
+
value={frame.scale}
|
|
258
|
+
min={1}
|
|
259
|
+
max={8}
|
|
260
|
+
step={0.05}
|
|
261
|
+
onCommit={(v) => commitFrame({ ...frame, scale: v })}
|
|
262
|
+
ariaLabel="Crop scale"
|
|
263
|
+
/>
|
|
264
|
+
</label>
|
|
265
|
+
<label className="tlci-row">
|
|
266
|
+
<span className="tlci-lbl">X %</span>
|
|
267
|
+
<NumberField
|
|
268
|
+
value={frame.x}
|
|
269
|
+
min={-100}
|
|
270
|
+
max={100}
|
|
271
|
+
step={1}
|
|
272
|
+
onCommit={(v) => commitFrame({ ...frame, x: v })}
|
|
273
|
+
ariaLabel="Crop x offset"
|
|
274
|
+
/>
|
|
275
|
+
</label>
|
|
276
|
+
<label className="tlci-row">
|
|
277
|
+
<span className="tlci-lbl">Y %</span>
|
|
278
|
+
<NumberField
|
|
279
|
+
value={frame.y}
|
|
280
|
+
min={-100}
|
|
281
|
+
max={100}
|
|
282
|
+
step={1}
|
|
283
|
+
onCommit={(v) => commitFrame({ ...frame, y: v })}
|
|
284
|
+
ariaLabel="Crop y offset"
|
|
285
|
+
/>
|
|
286
|
+
</label>
|
|
287
|
+
<div className="tlci-presets">
|
|
288
|
+
<button type="button" className="tlci-chip" onClick={() => commitFrame({ scale: 1, x: 0, y: 0 })}>
|
|
289
|
+
Reset
|
|
290
|
+
</button>
|
|
291
|
+
<button type="button" className="tlci-chip" onClick={() => commitFrame({ ...frame, scale: 1.2 })}>
|
|
292
|
+
1.2×
|
|
293
|
+
</button>
|
|
294
|
+
<button type="button" className="tlci-chip" onClick={() => commitFrame({ ...frame, scale: 1.5 })}>
|
|
295
|
+
1.5×
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{tab === 'Grade' && (
|
|
302
|
+
<div className="tlci-body" role="tabpanel">
|
|
303
|
+
{gradeReadOnly ? (
|
|
304
|
+
<p className="tlci-note" data-testid="timeline-grade-readonly">
|
|
305
|
+
This clip carries a hand-written filter — edit it in code.
|
|
306
|
+
</p>
|
|
307
|
+
) : (
|
|
308
|
+
<>
|
|
309
|
+
<div className="tlci-presets">
|
|
310
|
+
{Object.entries(GRADE_PRESETS).map(([name, params]) => (
|
|
311
|
+
<button
|
|
312
|
+
key={name}
|
|
313
|
+
type="button"
|
|
314
|
+
className="tlci-chip"
|
|
315
|
+
data-testid={`timeline-grade-${name.toLowerCase()}`}
|
|
316
|
+
onClick={() => commitGrade(params ? { ...params } : {})}
|
|
317
|
+
>
|
|
318
|
+
{name}
|
|
319
|
+
</button>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
{gradeSlider('Brightness', 'brightness', 0.2, 2, 0.01, 1)}
|
|
323
|
+
{gradeSlider('Contrast', 'contrast', 0.2, 2, 0.01, 1)}
|
|
324
|
+
{gradeSlider('Saturation', 'saturation', 0, 2, 0.01, 1)}
|
|
325
|
+
{gradeSlider('Hue', 'hue', -180, 180, 1, 0)}
|
|
326
|
+
{gradeSlider('Sepia', 'sepia', 0, 1, 0.01, 0)}
|
|
327
|
+
{gradeSlider('Grayscale', 'grayscale', 0, 1, 0.01, 0)}
|
|
328
|
+
<p className="tlci-note">
|
|
329
|
+
Preview applies on the paused frame (WKWebView filter ceiling); exports are exact.
|
|
330
|
+
</p>
|
|
331
|
+
</>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
{tab === 'Text' && (
|
|
337
|
+
<div className="tlci-body" role="tabpanel">
|
|
338
|
+
<textarea
|
|
339
|
+
className="tlci-textarea"
|
|
340
|
+
data-testid="timeline-inspector-text"
|
|
341
|
+
defaultValue={textValue || ''}
|
|
342
|
+
rows={4}
|
|
343
|
+
placeholder={kind === 'placeholder' ? 'Generation prompt…' : 'Title text…'}
|
|
344
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
345
|
+
/>
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className="tlci-btn"
|
|
349
|
+
data-testid="timeline-inspector-text-apply"
|
|
350
|
+
onClick={(e) => {
|
|
351
|
+
const v = e.currentTarget.closest('.tlci-body')?.querySelector('textarea')?.value;
|
|
352
|
+
if (v && v.trim()) onVerb?.('set-text', { text: v });
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
Apply text
|
|
356
|
+
</button>
|
|
357
|
+
<p className="tlci-note">
|
|
358
|
+
{kind === 'placeholder'
|
|
359
|
+
? 'Updates the generation prompt on the slate.'
|
|
360
|
+
: 'Rewrites the title text in the composition.'}
|
|
361
|
+
</p>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{tab === 'Transition' && (
|
|
366
|
+
<div className="tlci-body" role="tabpanel">
|
|
367
|
+
<div className="tlci-presets" data-testid="timeline-transition-grid">
|
|
368
|
+
{PRESENTATIONS.map((p) => (
|
|
369
|
+
<button
|
|
370
|
+
key={p}
|
|
371
|
+
type="button"
|
|
372
|
+
className="tlci-chip"
|
|
373
|
+
data-testid={`timeline-transition-${p}`}
|
|
374
|
+
onClick={() => onVerb?.('transition', { presentation: p })}
|
|
375
|
+
>
|
|
376
|
+
{p}
|
|
377
|
+
</button>
|
|
378
|
+
))}
|
|
379
|
+
</div>
|
|
380
|
+
<label className="tlci-row">
|
|
381
|
+
<span className="tlci-lbl">Frames</span>
|
|
382
|
+
<NumberField
|
|
383
|
+
value={transDur}
|
|
384
|
+
min={1}
|
|
385
|
+
max={120}
|
|
386
|
+
step={1}
|
|
387
|
+
onCommit={(v) => {
|
|
388
|
+
setTransDur(v);
|
|
389
|
+
onVerb?.('transition', { durationInFrames: v });
|
|
390
|
+
}}
|
|
391
|
+
ariaLabel="Transition duration"
|
|
392
|
+
/>
|
|
393
|
+
</label>
|
|
394
|
+
<button
|
|
395
|
+
type="button"
|
|
396
|
+
className="tlci-btn"
|
|
397
|
+
data-testid="timeline-transition-remove"
|
|
398
|
+
onClick={() => {
|
|
399
|
+
onVerb?.('remove-transition', {});
|
|
400
|
+
onClose?.();
|
|
401
|
+
}}
|
|
402
|
+
>
|
|
403
|
+
Remove transition (hard cut)
|
|
404
|
+
</button>
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
@@ -123,7 +123,13 @@ export default function CloudBar() {
|
|
|
123
123
|
if (!isNativeApp()) return undefined;
|
|
124
124
|
const consume = (url) => {
|
|
125
125
|
const parsed = parseDeepLink(url);
|
|
126
|
-
if (parsed)
|
|
126
|
+
if (!parsed) return;
|
|
127
|
+
// NEVER replace a link the person is already looking at. The strip is a
|
|
128
|
+
// single slot, so an overwrite would swap the code out from under a
|
|
129
|
+
// click that was aimed at the previous one — the person confirms the
|
|
130
|
+
// name they read and connects the link that arrived last (validate
|
|
131
|
+
// 2026-07-30, attacker A1). A second link waits its turn instead.
|
|
132
|
+
setPending((current) => current ?? parsed);
|
|
127
133
|
};
|
|
128
134
|
invoke('take_pending_deep_link')
|
|
129
135
|
.then((url) => url && consume(url))
|
|
@@ -142,7 +148,11 @@ export default function CloudBar() {
|
|
|
142
148
|
const r = await api('/_api/cloud/attach/code', {
|
|
143
149
|
method: 'POST',
|
|
144
150
|
headers: { 'content-type': 'application/json' },
|
|
145
|
-
|
|
151
|
+
// Send the project the LINK claimed — the one this person just read in
|
|
152
|
+
// the confirm strip. The server compares it against what the exchange
|
|
153
|
+
// actually opens and refuses a mismatch, so a familiar name wrapped
|
|
154
|
+
// around somebody else's code cannot borrow that consent.
|
|
155
|
+
body: JSON.stringify({ code: pending.code, project: pending.project }),
|
|
146
156
|
});
|
|
147
157
|
setBusy('');
|
|
148
158
|
setPending(null);
|