Claude commited on
Commit
f0029cf
·
unverified ·
1 Parent(s): 50e6277

Bring back info-dense cards, drop catcher, add semantic-flow rule

Browse files

User feedback after PR #5: hiding all detail behind an expand button
made cards harder to scan (had to click every card to know what it
was), and the catcher at top was hijacking drops into the assembly
(its sticky z-index made it intercept the cursor on any drag heading
upward). Result: cards "snap back" after every drag attempt.

Card redesign (info-dense again, Linear tokens kept):
- Row 1: role pill + (assembly only) index badge + duration
- Row 2: name (semibold, up to 2 lines)
- Row 3: SRT text preview (up to 2 lines, italic gray)
- Row 4: cues + SRT time window (monospace, tiny)
No more expand button — everything's visible, just typographically
ranked so the eye lands on name/preview first and skips the meta.

Drag fixes:
- Removed the catcher entirely. The auto-route was nice in theory but
its sticky position + z-index intercepted drops into the assembly,
which is the most common direction. Without it, drags go where the
cursor goes.
- Switched off forceFallback. Native HTML5 drag is more reliable inside
the Streamlit iframe — forceFallback's first-event handling is what
was causing the intermittent snap-back.
- Category columns now vertical-stack cards (was horizontal-flow); pairs
well with horizontal scroll of the row.

Prompt change:
- SYS_BLOCKS now requires each segment to be self-contained (start at
sentence beginning, end at sentence ending) so segments can be cut
together in any order without sounding choppy.
- Adds an explicit "semantic flow is critical" rule for
recommended_assembly — read the picks as one paragraph, check for
mid-sentence cuts, dangling pronouns, jarring topic jumps. Prefer
fewer-but-coherent over more-but-choppy.

Files changed (2) hide show
  1. app.py +23 -1
  2. frontend/index.html +91 -196
app.py CHANGED
@@ -263,7 +263,14 @@ B. Drop the parts that are mic checks, false starts, asides, fumbles,
263
  Q&A interruptions, irrelevant tangents, or content that doesn't fit
264
  the chosen theme.
265
  C. The remaining keep-worthy cues form your partition — split them into
266
- 12-20 SEGMENTS at natural topic boundaries.
 
 
 
 
 
 
 
267
  D. For each segment, assign ONE narrative_role.
268
 
269
  narrative_role enum:
@@ -281,6 +288,21 @@ that forms a complete narrative arc:
281
  The recommended_assembly may pick a SUBSET of segments (segments not on the
282
  list still appear in the user's pool — they can pick differently).
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  **IMPORTANT: Respond in Traditional Chinese (zh-TW)** for `name`,
285
  `selling_point`. `hook_quote` is verbatim from SRT.
286
 
 
263
  Q&A interruptions, irrelevant tangents, or content that doesn't fit
264
  the chosen theme.
265
  C. The remaining keep-worthy cues form your partition — split them into
266
+ 12-20 SEGMENTS at natural SENTENCE / TOPIC boundaries. Each segment
267
+ must:
268
+ - Start at a sentence beginning (not mid-clause)
269
+ - End at a sentence ending (period, full pause)
270
+ - Be SELF-CONTAINED — the segment makes sense on its own without
271
+ needing the previous or next segment to resolve references
272
+ This is what makes adjacent segments cuttable in any order without
273
+ sounding choppy.
274
  D. For each segment, assign ONE narrative_role.
275
 
276
  narrative_role enum:
 
288
  The recommended_assembly may pick a SUBSET of segments (segments not on the
289
  list still appear in the user's pool — they can pick differently).
290
 
291
+ ⚠️ SEMANTIC FLOW IS CRITICAL — the recommended_assembly will be cut
292
+ together back-to-back with no transitions. Read the chosen segments AS A
293
+ SINGLE PARAGRAPH and make sure:
294
+ - Adjacent segments connect smoothly: no mid-sentence cuts, no pronouns
295
+ referring to things only in earlier dropped segments, no jarring
296
+ topic jumps.
297
+ - Each segment ends at a natural sentence boundary so the next segment's
298
+ opening sentence stands on its own.
299
+ - The overall arc reads like the speaker said it in one breath, not
300
+ like a clip show.
301
+ - Prefer slightly longer segments over shorter ones if it preserves
302
+ semantic continuity.
303
+ If the only segments that fit the theme don't connect smoothly, it's
304
+ better to return fewer segments than to assemble a choppy clip.
305
+
306
  **IMPORTANT: Respond in Traditional Chinese (zh-TW)** for `name`,
307
  `selling_point`. `hook_quote` is verbatim from SRT.
308
 
frontend/index.html CHANGED
@@ -4,7 +4,6 @@
4
  <meta charset="utf-8" />
5
  <title>block-sorter</title>
6
  <style>
7
- /* ---- Design tokens (Linear-inspired, monochrome with one accent) ---- */
8
  :root {
9
  --canvas: #f9f9fb;
10
  --surface-1: #ffffff;
@@ -20,10 +19,7 @@
20
 
21
  --accent: #5e6ad2;
22
  --accent-hover: #828fff;
23
- --accent-pressed:#5e69d1;
24
  --accent-soft: #eef0fb;
25
- --warn: #b3580d;
26
- --warn-soft: #fef3e8;
27
 
28
  --r-sm: 6px;
29
  --r-md: 8px;
@@ -35,7 +31,6 @@
35
  --s-3: 12px;
36
  --s-4: 16px;
37
  --s-5: 24px;
38
- --s-6: 32px;
39
 
40
  --shadow-1: 0 1px 2px rgba(15, 16, 17, 0.04), 0 0 0 1px rgba(15, 16, 17, 0.02);
41
  --shadow-2: 0 4px 14px rgba(15, 16, 17, 0.10), 0 0 0 1px rgba(15, 16, 17, 0.04);
@@ -56,29 +51,6 @@
56
  letter-spacing: -0.005em;
57
  }
58
 
59
- /* ---- Catcher (sticky drop zone for auto-route) ---- */
60
- #catcher {
61
- height: 36px;
62
- display: flex; align-items: center; justify-content: center;
63
- border: 1px dashed var(--hairline-strong);
64
- border-radius: var(--r-md);
65
- background: var(--surface-2);
66
- color: var(--ink-subtle);
67
- font-size: 12px;
68
- letter-spacing: 0.01em;
69
- margin: 0 0 var(--s-4);
70
- position: sticky;
71
- top: 0;
72
- z-index: 10;
73
- transition: background 0.12s, border-color 0.12s, color 0.12s;
74
- }
75
- #catcher.hot {
76
- background: var(--accent-soft);
77
- border-color: var(--accent);
78
- color: var(--accent);
79
- }
80
- #catcher .item { display: none; }
81
-
82
  /* ---- Containers ---- */
83
  .container {
84
  background: var(--surface-1);
@@ -89,19 +61,21 @@
89
  }
90
  .container.assembly {
91
  border: 1px solid var(--accent);
92
- background: linear-gradient(180deg, rgba(94,106,210,0.04), transparent 24px), var(--surface-1);
 
 
93
  }
94
 
95
  .header {
96
  display: flex; align-items: center; justify-content: space-between;
97
- margin-bottom: var(--s-2);
98
  padding: 0 var(--s-1);
99
- color: var(--ink);
100
  }
101
  .header .title {
102
  font-size: 13px;
103
  font-weight: 600;
104
  letter-spacing: -0.01em;
 
105
  }
106
  .header .meta {
107
  font-size: 11px;
@@ -110,20 +84,16 @@
110
  font-variant-numeric: tabular-nums;
111
  }
112
 
113
- /* ---- Items area ---- */
114
  .items {
115
- min-height: 40px;
116
  border-radius: var(--r-md);
117
  padding: var(--s-1);
118
- transition: background 0.1s;
119
  }
120
- .items.empty {
121
- background: var(--surface-2);
122
- }
123
- .items.horizontal { display: flex; flex-direction: column; gap: var(--s-2); }
124
- .items.vertical { display: flex; flex-direction: column; gap: var(--s-2); }
125
 
126
- /* ---- Layout: category row scrolls horizontally ---- */
127
  .row {
128
  display: flex;
129
  flex-direction: row;
@@ -142,141 +112,116 @@
142
  }
143
  .row .container {
144
  margin-bottom: 0;
145
- flex: 0 0 220px;
146
- min-width: 220px;
147
- max-width: 220px;
148
  }
149
 
150
- /* ---- The card itself ---- */
151
  .item {
152
  position: relative;
153
  background: var(--surface-1);
154
  border: 1px solid var(--hairline);
155
  border-radius: var(--r-md);
156
- padding: var(--s-2) var(--s-3);
157
  cursor: grab;
158
  user-select: none;
159
  transition: border-color 0.1s, box-shadow 0.1s, transform 0.1s;
160
  box-shadow: var(--shadow-1);
161
  }
162
- .container.assembly .item {
163
- background: var(--surface-1);
164
- }
165
  .item:hover {
166
  border-color: var(--hairline-strong);
167
  box-shadow: var(--shadow-2);
168
  }
169
  .item:active { cursor: grabbing; }
170
- .item.ghost { opacity: 0.35; background: var(--accent-soft); }
 
 
 
 
171
  .item.drag {
172
  cursor: grabbing;
173
- box-shadow: 0 12px 32px rgba(15, 16, 17, 0.18);
174
- transform: rotate(0.5deg);
175
  }
176
 
177
- /* ---- Card summary row (always visible) ---- */
178
- .item .summary {
179
  display: flex;
180
  align-items: center;
181
  gap: var(--s-2);
182
- min-width: 0;
183
  }
184
- .item .role-dot {
185
- width: 18px; height: 18px;
186
- border-radius: var(--r-sm);
 
187
  background: var(--surface-2);
188
- display: inline-flex;
189
- align-items: center; justify-content: center;
190
- font-size: 11px;
191
- line-height: 1;
192
- flex: 0 0 18px;
193
  }
194
  .item .idx {
195
  display: none;
196
  font-size: 10px;
197
- font-weight: 600;
198
- color: var(--accent);
199
- background: var(--accent-soft);
200
- padding: 2px 6px;
201
  border-radius: var(--r-pill);
202
  font-variant-numeric: tabular-nums;
203
  flex: 0 0 auto;
204
  }
205
  .container.assembly .item .idx { display: inline-flex; }
206
- .item .name {
207
- font-size: 13px;
208
- font-weight: 500;
209
- color: var(--ink);
210
- letter-spacing: -0.01em;
211
- overflow: hidden;
212
- text-overflow: ellipsis;
213
- white-space: nowrap;
214
- flex: 1 1 auto;
215
- min-width: 0;
216
- }
217
  .item .secs {
218
  font-size: 11px;
219
  color: var(--ink-subtle);
220
  font-variant-numeric: tabular-nums;
221
  flex: 0 0 auto;
222
  }
223
- .item .expand-btn {
224
- border: 0;
225
- background: transparent;
226
- color: var(--ink-tertiary);
227
- cursor: pointer;
228
- padding: 2px 4px;
229
- margin: -2px -4px;
230
- border-radius: var(--r-sm);
231
- font-size: 10px;
232
- line-height: 1;
233
- transition: color 0.1s, background 0.1s;
234
- flex: 0 0 auto;
235
- }
236
- .item .expand-btn:hover {
237
  color: var(--ink);
238
- background: var(--surface-2);
239
- }
240
- .item .expand-btn::before {
241
- content: "▸";
242
- display: inline-block;
243
- transition: transform 0.12s;
244
- }
245
- .item.expanded .expand-btn::before {
246
- transform: rotate(90deg);
247
  }
248
 
249
- /* ---- Detail (hidden until expanded) ---- */
250
- .item .detail {
251
- display: none;
252
- margin-top: var(--s-2);
253
- padding-top: var(--s-2);
254
- border-top: 1px solid var(--hairline);
255
- font-size: 12px;
256
  line-height: 1.5;
 
 
 
 
 
257
  }
258
- .item.expanded .detail { display: block; }
259
- .item .detail .meta-row {
 
 
 
 
 
 
 
 
260
  display: flex;
261
- gap: var(--s-3);
262
  flex-wrap: wrap;
263
- color: var(--ink-subtle);
264
- font-variant-numeric: tabular-nums;
265
- margin-bottom: var(--s-2);
266
- }
267
- .item .detail .meta-row code {
268
- font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
269
- font-size: 11px;
270
- background: var(--surface-2);
271
- padding: 1px 6px;
272
- border-radius: var(--r-sm);
273
- color: var(--ink-muted);
274
- }
275
- .item .detail .quote {
276
- color: var(--ink-muted);
277
- font-style: italic;
278
- border-left: 2px solid var(--hairline);
279
- padding-left: var(--s-2);
280
  }
281
 
282
  .empty-msg {
@@ -294,7 +239,6 @@
294
  <script>
295
  "use strict";
296
 
297
- // ====== Streamlit bridge ======
298
  const RENDER_EVT = "streamlit:render";
299
  const READY_EVT = "streamlit:componentReady";
300
  const SET_VALUE_EVT = "streamlit:setComponentValue";
@@ -321,7 +265,6 @@ function escapeHtml(s) {
321
  let sortableInstances = [];
322
  let lastEmittedFingerprint = null;
323
  let lastRenderedFingerprint = null;
324
- const expandedIds = new Set(); // persisted across rerenders
325
 
326
  function readDomState() {
327
  const lists = document.querySelectorAll(".items");
@@ -342,53 +285,37 @@ function emitState() {
342
  }
343
  }
344
 
345
- // ====== Card rendering ======
346
  function makeItem(item, layout) {
347
  const el = document.createElement("div");
348
  el.className = "item " + layout;
349
  el.dataset.id = item.id;
350
  if (item.role_key) el.dataset.role = item.role_key;
351
- if (expandedIds.has(item.id)) el.classList.add("expanded");
352
 
353
- const roleEmoji = (item.role || "").trim().split(" ")[0] || "•";
354
  const name = escapeHtml(item.name || "?");
355
  const secs = item.secs != null ? `~${escapeHtml(item.secs)}s` : "";
356
  const cueRange = escapeHtml(item.cue_range || "");
357
  const srtRange = escapeHtml(item.srt_range || "");
358
  const quoteRaw = item.quote || "";
359
- const quote = escapeHtml(quoteRaw.slice(0, 200))
360
- + (quoteRaw.length > 200 ? "…" : "");
361
 
362
  el.innerHTML = `
363
- <div class="summary">
364
- <span class="role-dot" title="${escapeHtml(item.role || "")}">${escapeHtml(roleEmoji)}</span>
365
  <span class="idx">#?</span>
366
- <span class="name" title="${name}">${name}</span>
367
- <span class="secs">${secs}</span>
368
- <button class="expand-btn" type="button" aria-label="展開細節"></button>
369
  </div>
370
- <div class="detail">
371
- <div class="meta-row">
372
- ${cueRange ? `<span>cues <code>${cueRange}</code></span>` : ""}
373
- ${srtRange ? `<span>SRT <code>${srtRange}</code></span>` : ""}
 
 
374
  </div>
375
- ${quote ? `<div class="quote">「${quote}」</div>` : ""}
376
- </div>
377
  `;
378
-
379
- const btn = el.querySelector(".expand-btn");
380
- btn.addEventListener("click", (e) => {
381
- e.stopPropagation();
382
- e.preventDefault();
383
- el.classList.toggle("expanded");
384
- if (el.classList.contains("expanded")) expandedIds.add(item.id);
385
- else expandedIds.delete(item.id);
386
- setHeight();
387
- });
388
- // Stop drag from starting on the button.
389
- btn.addEventListener("pointerdown", (e) => e.stopPropagation());
390
- btn.addEventListener("mousedown", (e) => e.stopPropagation());
391
-
392
  return el;
393
  }
394
 
@@ -472,10 +399,10 @@ function setupSortables() {
472
  ghostClass: "ghost",
473
  dragClass: "drag",
474
  draggable: ".item",
475
- filter: ".empty-msg, .expand-btn",
476
- preventOnFilter: false,
477
- forceFallback: true,
478
- fallbackOnBody: true,
479
  onEnd: (evt) => {
480
  if (evt.from) fixEmptyState(evt.from);
481
  if (evt.to && evt.to !== evt.from) fixEmptyState(evt.to);
@@ -484,33 +411,6 @@ function setupSortables() {
484
  },
485
  }));
486
  });
487
-
488
- const catcher = document.getElementById("catcher");
489
- if (catcher) {
490
- sortableInstances.push(new Sortable(catcher, {
491
- group: { name: "blocks", pull: false, put: true },
492
- animation: 160,
493
- ghostClass: "ghost",
494
- draggable: ".item",
495
- forceFallback: true,
496
- fallbackOnBody: true,
497
- onAdd: (evt) => {
498
- const item = evt.item;
499
- const role = item.dataset.role;
500
- let target = role
501
- ? document.querySelector(`.items[data-role="${role}"]`)
502
- : null;
503
- if (!target) target = document.querySelector(".row .items");
504
- if (target) {
505
- target.appendChild(item);
506
- fixEmptyState(target);
507
- }
508
- catcher.querySelectorAll(".item").forEach((el) => el.remove());
509
- updateAssemblyIndices();
510
- emitState();
511
- },
512
- }));
513
- }
514
  }
515
 
516
  function renderUI(args) {
@@ -518,15 +418,10 @@ function renderUI(args) {
518
  const root = document.getElementById("root");
519
  root.innerHTML = "";
520
 
521
- const catcher = document.createElement("div");
522
- catcher.id = "catcher";
523
- catcher.textContent = "拖到這裡 → 自動歸位到對應分類";
524
- root.appendChild(catcher);
525
-
526
  let rowDiv = null;
527
  containers.forEach((c, idx) => {
528
  const isAssembly = idx === 0;
529
- const layout = "horizontal";
530
  const { wrapper } = makeContainer(c, layout, isAssembly);
531
  if (isAssembly) {
532
  root.appendChild(wrapper);
 
4
  <meta charset="utf-8" />
5
  <title>block-sorter</title>
6
  <style>
 
7
  :root {
8
  --canvas: #f9f9fb;
9
  --surface-1: #ffffff;
 
19
 
20
  --accent: #5e6ad2;
21
  --accent-hover: #828fff;
 
22
  --accent-soft: #eef0fb;
 
 
23
 
24
  --r-sm: 6px;
25
  --r-md: 8px;
 
31
  --s-3: 12px;
32
  --s-4: 16px;
33
  --s-5: 24px;
 
34
 
35
  --shadow-1: 0 1px 2px rgba(15, 16, 17, 0.04), 0 0 0 1px rgba(15, 16, 17, 0.02);
36
  --shadow-2: 0 4px 14px rgba(15, 16, 17, 0.10), 0 0 0 1px rgba(15, 16, 17, 0.04);
 
51
  letter-spacing: -0.005em;
52
  }
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  /* ---- Containers ---- */
55
  .container {
56
  background: var(--surface-1);
 
61
  }
62
  .container.assembly {
63
  border: 1px solid var(--accent);
64
+ background:
65
+ linear-gradient(180deg, rgba(94,106,210,0.05), transparent 60px),
66
+ var(--surface-1);
67
  }
68
 
69
  .header {
70
  display: flex; align-items: center; justify-content: space-between;
71
+ margin-bottom: var(--s-3);
72
  padding: 0 var(--s-1);
 
73
  }
74
  .header .title {
75
  font-size: 13px;
76
  font-weight: 600;
77
  letter-spacing: -0.01em;
78
+ color: var(--ink);
79
  }
80
  .header .meta {
81
  font-size: 11px;
 
84
  font-variant-numeric: tabular-nums;
85
  }
86
 
 
87
  .items {
88
+ min-height: 48px;
89
  border-radius: var(--r-md);
90
  padding: var(--s-1);
 
91
  }
92
+ .items.empty { background: var(--surface-2); }
93
+ .items.horizontal,
94
+ .items.vertical { display: flex; flex-direction: column; gap: var(--s-2); }
 
 
95
 
96
+ /* Category row horizontal scroll, fixed-width columns */
97
  .row {
98
  display: flex;
99
  flex-direction: row;
 
112
  }
113
  .row .container {
114
  margin-bottom: 0;
115
+ flex: 0 0 240px;
116
+ min-width: 240px;
117
+ max-width: 240px;
118
  }
119
 
120
+ /* ---- Card ---- */
121
  .item {
122
  position: relative;
123
  background: var(--surface-1);
124
  border: 1px solid var(--hairline);
125
  border-radius: var(--r-md);
126
+ padding: var(--s-2) var(--s-3) var(--s-3);
127
  cursor: grab;
128
  user-select: none;
129
  transition: border-color 0.1s, box-shadow 0.1s, transform 0.1s;
130
  box-shadow: var(--shadow-1);
131
  }
 
 
 
132
  .item:hover {
133
  border-color: var(--hairline-strong);
134
  box-shadow: var(--shadow-2);
135
  }
136
  .item:active { cursor: grabbing; }
137
+ .item.ghost {
138
+ opacity: 0.35;
139
+ background: var(--accent-soft);
140
+ border-style: dashed;
141
+ }
142
  .item.drag {
143
  cursor: grabbing;
144
+ box-shadow: 0 14px 36px rgba(15, 16, 17, 0.22);
145
+ transform: rotate(0.6deg);
146
  }
147
 
148
+ /* Row 1: role pill + index badge + duration */
149
+ .item .row1 {
150
  display: flex;
151
  align-items: center;
152
  gap: var(--s-2);
153
+ margin-bottom: 6px;
154
  }
155
+ .item .role {
156
+ font-size: 10px;
157
+ font-weight: 500;
158
+ color: var(--ink-muted);
159
  background: var(--surface-2);
160
+ padding: 2px 8px;
161
+ border-radius: var(--r-pill);
162
+ letter-spacing: 0.02em;
163
+ white-space: nowrap;
164
+ flex: 0 0 auto;
165
  }
166
  .item .idx {
167
  display: none;
168
  font-size: 10px;
169
+ font-weight: 700;
170
+ color: #fff;
171
+ background: var(--accent);
172
+ padding: 2px 7px;
173
  border-radius: var(--r-pill);
174
  font-variant-numeric: tabular-nums;
175
  flex: 0 0 auto;
176
  }
177
  .container.assembly .item .idx { display: inline-flex; }
178
+ .item .spacer { flex: 1 1 auto; }
 
 
 
 
 
 
 
 
 
 
179
  .item .secs {
180
  font-size: 11px;
181
  color: var(--ink-subtle);
182
  font-variant-numeric: tabular-nums;
183
  flex: 0 0 auto;
184
  }
185
+
186
+ /* Row 2: name (semibold, can wrap to 2 lines) */
187
+ .item .name {
188
+ font-size: 13px;
189
+ font-weight: 600;
 
 
 
 
 
 
 
 
 
190
  color: var(--ink);
191
+ letter-spacing: -0.01em;
192
+ line-height: 1.35;
193
+ margin-bottom: 4px;
194
+ display: -webkit-box;
195
+ -webkit-line-clamp: 2;
196
+ -webkit-box-orient: vertical;
197
+ overflow: hidden;
198
+ word-break: break-word;
 
199
  }
200
 
201
+ /* Row 3: SRT preview text 2 lines max */
202
+ .item .quote {
203
+ font-size: 11.5px;
204
+ color: var(--ink-muted);
 
 
 
205
  line-height: 1.5;
206
+ display: -webkit-box;
207
+ -webkit-line-clamp: 2;
208
+ -webkit-box-orient: vertical;
209
+ overflow: hidden;
210
+ word-break: break-word;
211
  }
212
+ .item .quote::before { content: "「"; color: var(--ink-tertiary); }
213
+ .item .quote::after { content: "」"; color: var(--ink-tertiary); }
214
+
215
+ /* Row 4: meta (cue range + SRT time) — small, monospace */
216
+ .item .meta-line {
217
+ margin-top: 6px;
218
+ font-size: 10.5px;
219
+ color: var(--ink-tertiary);
220
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
221
+ font-variant-numeric: tabular-nums;
222
  display: flex;
223
+ gap: var(--s-2);
224
  flex-wrap: wrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  }
226
 
227
  .empty-msg {
 
239
  <script>
240
  "use strict";
241
 
 
242
  const RENDER_EVT = "streamlit:render";
243
  const READY_EVT = "streamlit:componentReady";
244
  const SET_VALUE_EVT = "streamlit:setComponentValue";
 
265
  let sortableInstances = [];
266
  let lastEmittedFingerprint = null;
267
  let lastRenderedFingerprint = null;
 
268
 
269
  function readDomState() {
270
  const lists = document.querySelectorAll(".items");
 
285
  }
286
  }
287
 
 
288
  function makeItem(item, layout) {
289
  const el = document.createElement("div");
290
  el.className = "item " + layout;
291
  el.dataset.id = item.id;
292
  if (item.role_key) el.dataset.role = item.role_key;
 
293
 
294
+ const roleLabel = escapeHtml((item.role || "").trim());
295
  const name = escapeHtml(item.name || "?");
296
  const secs = item.secs != null ? `~${escapeHtml(item.secs)}s` : "";
297
  const cueRange = escapeHtml(item.cue_range || "");
298
  const srtRange = escapeHtml(item.srt_range || "");
299
  const quoteRaw = item.quote || "";
300
+ const quote = escapeHtml(quoteRaw.slice(0, 120))
301
+ + (quoteRaw.length > 120 ? "…" : "");
302
 
303
  el.innerHTML = `
304
+ <div class="row1">
 
305
  <span class="idx">#?</span>
306
+ ${roleLabel ? `<span class="role">${roleLabel}</span>` : ""}
307
+ <span class="spacer"></span>
308
+ ${secs ? `<span class="secs">${secs}</span>` : ""}
309
  </div>
310
+ <div class="name">${name}</div>
311
+ ${quote ? `<div class="quote">${quote}</div>` : ""}
312
+ ${(cueRange || srtRange) ? `
313
+ <div class="meta-line">
314
+ ${cueRange ? `<span>cues ${cueRange}</span>` : ""}
315
+ ${srtRange ? `<span>${srtRange}</span>` : ""}
316
  </div>
317
+ ` : ""}
 
318
  `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  return el;
320
  }
321
 
 
399
  ghostClass: "ghost",
400
  dragClass: "drag",
401
  draggable: ".item",
402
+ filter: ".empty-msg",
403
+ // Use native HTML5 drag — Streamlit iframe + forceFallback has
404
+ // proven flaky (intermittent first-drag snap-back).
405
+ forceFallback: false,
406
  onEnd: (evt) => {
407
  if (evt.from) fixEmptyState(evt.from);
408
  if (evt.to && evt.to !== evt.from) fixEmptyState(evt.to);
 
411
  },
412
  }));
413
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  }
415
 
416
  function renderUI(args) {
 
418
  const root = document.getElementById("root");
419
  root.innerHTML = "";
420
 
 
 
 
 
 
421
  let rowDiv = null;
422
  containers.forEach((c, idx) => {
423
  const isAssembly = idx === 0;
424
+ const layout = "vertical";
425
  const { wrapper } = makeContainer(c, layout, isAssembly);
426
  if (isAssembly) {
427
  root.appendChild(wrapper);