danielrosehill commited on
Commit
4a63305
·
1 Parent(s): 6f4ed59
Files changed (2) hide show
  1. index.html +271 -20
  2. style.css +176 -0
index.html CHANGED
@@ -15,19 +15,45 @@
15
  Play the sample podcast and compare how each transcription model handled it side-by-side.
16
  Each row shows all transcripts for the same time segment.
17
  </p>
 
18
  </div>
19
  <div class="audio-shell">
20
  <audio id="audio" controls preload="auto" src="data/audio/podcast.mp3"></audio>
21
  <canvas id="waveform" role="img" aria-label="Audio waveform preview"></canvas>
22
  </div>
23
  </section>
 
 
 
 
 
 
 
 
 
24
  <section class="transcripts">
 
 
 
 
 
 
 
 
 
 
 
25
  <div class="comparison-table" id="comparison-table" aria-live="polite"></div>
26
  </section>
27
  </main>
28
  <script src="transcripts.js"></script>
29
  <script type="module">
30
  const comparisonTableEl = document.getElementById("comparison-table");
 
 
 
 
 
31
  const audioElem = document.getElementById("audio");
32
  const waveformCanvas = document.getElementById("waveform");
33
  const transcriptSources = window.TRANSCRIPTS || {};
@@ -67,6 +93,16 @@
67
  const segmentRows = [];
68
  let allTranscripts = {};
69
 
 
 
 
 
 
 
 
 
 
 
70
  function parseTimestamp(value) {
71
  const [time, millisecondPart] = value.split(",");
72
  const [hours, minutes, seconds] = time.split(":").map(Number);
@@ -141,58 +177,274 @@
141
  }
142
 
143
  function findSegmentForTime(segments, time) {
144
- return segments.find(seg => time >= seg.start && time < seg.end);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
 
147
  function renderComparisonTable() {
148
- // Create header row
 
 
149
  const header = document.createElement("div");
150
  header.className = "comparison-header";
151
  header.innerHTML = `
152
  <div class="time-column">Time</div>
153
- ${tracks.map(track => `<div class="model-column" style="--accent: ${track.accent}">${track.label}</div>`).join("")}
154
  `;
155
  comparisonTableEl.appendChild(header);
156
 
157
- // Use ground truth as the base timeline
158
  const groundTruthSegments = allTranscripts.truth?.segments || [];
159
 
160
- groundTruthSegments.forEach((truthSegment, index) => {
161
  const row = document.createElement("div");
162
  row.className = "comparison-row";
163
  row.dataset.start = truthSegment.start;
164
  row.dataset.end = truthSegment.end;
165
 
166
- // Time column
167
  const timeCell = document.createElement("div");
168
  timeCell.className = "time-cell";
169
  timeCell.textContent = formatTime(truthSegment.start);
170
  row.appendChild(timeCell);
171
 
172
- // Add cells for each model
173
- tracks.forEach(track => {
 
174
  const cell = document.createElement("div");
175
  cell.className = "transcript-cell";
176
  cell.style.setProperty("--accent", track.accent);
177
-
178
  const transcript = allTranscripts[track.id];
 
179
  if (transcript?.error) {
180
  cell.innerHTML = '<em class="error-text">Error loading</em>';
 
181
  } else {
182
- // Find the segment that overlaps with this ground truth time
183
- const segment = findSegmentForTime(
184
- transcript?.segments || [],
185
- truthSegment.start
186
- );
187
- cell.textContent = segment?.content || "";
 
 
188
  }
189
 
190
  row.appendChild(cell);
191
  });
192
 
 
193
  comparisonTableEl.appendChild(row);
194
  segmentRows.push(row);
195
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  }
197
 
198
  function updateActiveSegments(time) {
@@ -201,8 +453,6 @@
201
  const end = Number(row.dataset.end);
202
  const isActive = time >= start && time < end;
203
  row.classList.toggle("is-active", isActive);
204
-
205
- // Auto-scroll to active row
206
  if (isActive) {
207
  row.scrollIntoView({ behavior: "smooth", block: "center" });
208
  }
@@ -223,15 +473,15 @@
223
  const ctx = canvas.getContext("2d");
224
  ctx.scale(dpr, dpr);
225
  ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
226
- const sliceWidth = Math.floor(rawData.length / canvas.clientWidth);
227
  const halfHeight = canvas.clientHeight / 2;
228
  ctx.lineWidth = 1.25;
229
  ctx.strokeStyle = "#1f2937";
230
  ctx.beginPath();
231
- for (let i = 0; i < canvas.clientWidth; i++) {
232
  const sliceStart = i * sliceWidth;
233
  let sum = 0;
234
- for (let j = 0; j < sliceWidth; j++) {
235
  sum += Math.abs(rawData[sliceStart + j] || 0);
236
  }
237
  const amplitude = sum / sliceWidth;
@@ -246,6 +496,7 @@
246
  async function bootstrap() {
247
  allTranscripts = await loadAllTranscripts();
248
  renderComparisonTable();
 
249
  void drawWaveform();
250
  }
251
 
 
15
  Play the sample podcast and compare how each transcription model handled it side-by-side.
16
  Each row shows all transcripts for the same time segment.
17
  </p>
18
+ <p class="hero-meta">The active row follows the audio so you can quickly inspect what every model heard.</p>
19
  </div>
20
  <div class="audio-shell">
21
  <audio id="audio" controls preload="auto" src="data/audio/podcast.mp3"></audio>
22
  <canvas id="waveform" role="img" aria-label="Audio waveform preview"></canvas>
23
  </div>
24
  </section>
25
+
26
+ <section class="insights">
27
+ <div>
28
+ <h2>Model snapshots</h2>
29
+ <p class="section-subtitle">WER and coverage come from the loaded SRT files compared against the ground truth.</p>
30
+ </div>
31
+ <div class="insight-grid" id="model-insights" aria-live="polite" aria-busy="true"></div>
32
+ </section>
33
+
34
  <section class="transcripts">
35
+ <header class="transcripts-toolbar">
36
+ <div class="search-group">
37
+ <label for="segment-search">Search segments</label>
38
+ <div class="search-input">
39
+ <input id="segment-search" type="search" placeholder="Find a topic, word, or phrase..." aria-describedby="match-count" />
40
+ <button id="clear-search" type="button" aria-label="Clear search">Clear</button>
41
+ </div>
42
+ </div>
43
+ <div class="match-count" id="match-count" role="status" aria-live="polite">Loading segments...</div>
44
+ </header>
45
+ <div class="model-legend" id="model-legend" aria-live="polite"></div>
46
  <div class="comparison-table" id="comparison-table" aria-live="polite"></div>
47
  </section>
48
  </main>
49
  <script src="transcripts.js"></script>
50
  <script type="module">
51
  const comparisonTableEl = document.getElementById("comparison-table");
52
+ const insightsEl = document.getElementById("model-insights");
53
+ const legendEl = document.getElementById("model-legend");
54
+ const matchCountEl = document.getElementById("match-count");
55
+ const searchInput = document.getElementById("segment-search");
56
+ const clearSearchBtn = document.getElementById("clear-search");
57
  const audioElem = document.getElementById("audio");
58
  const waveformCanvas = document.getElementById("waveform");
59
  const transcriptSources = window.TRANSCRIPTS || {};
 
93
  const segmentRows = [];
94
  let allTranscripts = {};
95
 
96
+ renderLegend();
97
+
98
+ searchInput?.addEventListener("input", (event) => filterRows(event.target.value));
99
+ clearSearchBtn?.addEventListener("click", () => {
100
+ if (!searchInput) return;
101
+ searchInput.value = "";
102
+ filterRows("");
103
+ searchInput.focus();
104
+ });
105
+
106
  function parseTimestamp(value) {
107
  const [time, millisecondPart] = value.split(",");
108
  const [hours, minutes, seconds] = time.split(":").map(Number);
 
177
  }
178
 
179
  function findSegmentForTime(segments, time) {
180
+ return segments.find((seg) => time >= seg.start && time < seg.end);
181
+ }
182
+
183
+ function escapeHtml(value = "") {
184
+ return value.replace(/[&<>"']/g, (char) => {
185
+ switch (char) {
186
+ case "&":
187
+ return "&amp;";
188
+ case "<":
189
+ return "&lt;";
190
+ case ">":
191
+ return "&gt;";
192
+ case '"':
193
+ return "&quot;";
194
+ case "'":
195
+ return "&#39;";
196
+ default:
197
+ return char;
198
+ }
199
+ });
200
+ }
201
+
202
+ function tokenizeText(text = "") {
203
+ return text
204
+ .replace(/\s+/g, " ")
205
+ .trim()
206
+ .split(" ")
207
+ .filter(Boolean);
208
+ }
209
+
210
+ function tokenizeSegments(segments = []) {
211
+ return tokenizeText(segments.map((segment) => segment.content).join(" "));
212
+ }
213
+
214
+ function buildLcsMatrix(referenceTokens, candidateTokens) {
215
+ const rows = referenceTokens.length + 1;
216
+ const cols = candidateTokens.length + 1;
217
+ const matrix = Array.from({ length: rows }, () => new Array(cols).fill(0));
218
+ for (let i = 1; i < rows; i += 1) {
219
+ for (let j = 1; j < cols; j += 1) {
220
+ if (referenceTokens[i - 1] === candidateTokens[j - 1]) {
221
+ matrix[i][j] = matrix[i - 1][j - 1] + 1;
222
+ } else {
223
+ matrix[i][j] = Math.max(matrix[i - 1][j], matrix[i][j - 1]);
224
+ }
225
+ }
226
+ }
227
+ return matrix;
228
+ }
229
+
230
+ function diffWords(reference, candidate) {
231
+ const referenceTokens = tokenizeText(reference).map((token) => token.toLowerCase());
232
+ const candidateTokens = tokenizeText(candidate);
233
+ const candidateLower = candidateTokens.map((token) => token.toLowerCase());
234
+ if (!candidateTokens.length) {
235
+ return [];
236
+ }
237
+ const matrix = buildLcsMatrix(referenceTokens, candidateLower);
238
+ const output = [];
239
+ let i = referenceTokens.length;
240
+ let j = candidateLower.length;
241
+ while (i > 0 && j > 0) {
242
+ if (referenceTokens[i - 1] === candidateLower[j - 1]) {
243
+ output.unshift({ text: candidateTokens[j - 1], match: true });
244
+ i -= 1;
245
+ j -= 1;
246
+ } else if (matrix[i - 1][j] >= matrix[i][j - 1]) {
247
+ i -= 1;
248
+ } else {
249
+ output.unshift({ text: candidateTokens[j - 1], match: false });
250
+ j -= 1;
251
+ }
252
+ }
253
+ while (j > 0) {
254
+ output.unshift({ text: candidateTokens[j - 1], match: false });
255
+ j -= 1;
256
+ }
257
+ return output;
258
+ }
259
+
260
+ function renderDiffTokens(tokens) {
261
+ if (!tokens.length) {
262
+ return '<span class="muted-text">No transcript</span>';
263
+ }
264
+ return tokens
265
+ .map((token) => {
266
+ if (token.match) {
267
+ return escapeHtml(token.text);
268
+ }
269
+ return `<span class="diff-token">${escapeHtml(token.text)}</span>`;
270
+ })
271
+ .join(" ");
272
+ }
273
+
274
+ function levenshteinDistance(referenceTokens, candidateTokens) {
275
+ const rows = referenceTokens.length + 1;
276
+ const cols = candidateTokens.length + 1;
277
+ const matrix = Array.from({ length: rows }, () => new Array(cols).fill(0));
278
+ for (let i = 0; i < rows; i += 1) {
279
+ matrix[i][0] = i;
280
+ }
281
+ for (let j = 0; j < cols; j += 1) {
282
+ matrix[0][j] = j;
283
+ }
284
+ for (let i = 1; i < rows; i += 1) {
285
+ for (let j = 1; j < cols; j += 1) {
286
+ const cost = referenceTokens[i - 1].toLowerCase() === candidateTokens[j - 1].toLowerCase() ? 0 : 1;
287
+ matrix[i][j] = Math.min(
288
+ matrix[i - 1][j] + 1,
289
+ matrix[i][j - 1] + 1,
290
+ matrix[i - 1][j - 1] + cost
291
+ );
292
+ }
293
+ }
294
+ return matrix[referenceTokens.length][candidateTokens.length];
295
+ }
296
+
297
+ function computeModelInsights() {
298
+ const truthSegments = allTranscripts.truth?.segments || [];
299
+ const truthTokens = tokenizeSegments(truthSegments);
300
+ const truthTokenCount = truthTokens.length || 1;
301
+ const truthSegmentCount = truthSegments.length || 1;
302
+
303
+ return tracks
304
+ .filter((track) => track.id !== "truth")
305
+ .map((track) => {
306
+ const transcript = allTranscripts[track.id];
307
+ const candidateSegments = transcript?.segments || [];
308
+ const candidateTokens = tokenizeSegments(candidateSegments);
309
+ const distance = levenshteinDistance(truthTokens, candidateTokens);
310
+ const wer = distance / truthTokenCount;
311
+ const wordMatch = Math.max(0, 1 - wer);
312
+ const coverage = Math.min(1, candidateSegments.length / truthSegmentCount);
313
+ const avgWords =
314
+ candidateSegments.length > 0 ? Math.round(candidateTokens.length / candidateSegments.length) : 0;
315
+ return {
316
+ track,
317
+ wer: wer * 100,
318
+ wordMatch: wordMatch * 100,
319
+ coverage: coverage * 100,
320
+ segments: candidateSegments.length,
321
+ avgWords
322
+ };
323
+ });
324
+ }
325
+
326
+ function renderModelInsights() {
327
+ if (!insightsEl) return;
328
+ insightsEl.removeAttribute("aria-busy");
329
+ if (!allTranscripts.truth?.segments?.length) {
330
+ insightsEl.innerHTML = '<p class="match-count">Ground truth transcript was not found.</p>';
331
+ return;
332
+ }
333
+ const stats = computeModelInsights();
334
+ insightsEl.innerHTML = stats
335
+ .map(
336
+ (stat) => `
337
+ <article class="insight-card" style="--accent: ${stat.track.accent}">
338
+ <h3>${stat.track.label}</h3>
339
+ <div>
340
+ <div class="metric-value">${stat.wordMatch.toFixed(1)}%</div>
341
+ <div class="metric-label">Word Match</div>
342
+ </div>
343
+ <div class="insight-meta">
344
+ <span>WER ${stat.wer.toFixed(1)}%</span>
345
+ <span>${stat.segments} segments</span>
346
+ </div>
347
+ <div class="insight-meta">
348
+ <span>Coverage ${Math.round(stat.coverage)}%</span>
349
+ <span>${stat.avgWords || 0} words/segment</span>
350
+ </div>
351
+ </article>
352
+ `
353
+ )
354
+ .join("");
355
+ }
356
+
357
+ function renderLegend() {
358
+ if (!legendEl) return;
359
+ legendEl.innerHTML = tracks
360
+ .map((track) => `<span style="--accent: ${track.accent}"><i></i>${track.label}</span>`)
361
+ .join("");
362
  }
363
 
364
  function renderComparisonTable() {
365
+ comparisonTableEl.innerHTML = "";
366
+ segmentRows.length = 0;
367
+
368
  const header = document.createElement("div");
369
  header.className = "comparison-header";
370
  header.innerHTML = `
371
  <div class="time-column">Time</div>
372
+ ${tracks.map((track) => `<div class="model-column" style="--accent: ${track.accent}">${track.label}</div>`).join("")}
373
  `;
374
  comparisonTableEl.appendChild(header);
375
 
 
376
  const groundTruthSegments = allTranscripts.truth?.segments || [];
377
 
378
+ groundTruthSegments.forEach((truthSegment) => {
379
  const row = document.createElement("div");
380
  row.className = "comparison-row";
381
  row.dataset.start = truthSegment.start;
382
  row.dataset.end = truthSegment.end;
383
 
 
384
  const timeCell = document.createElement("div");
385
  timeCell.className = "time-cell";
386
  timeCell.textContent = formatTime(truthSegment.start);
387
  row.appendChild(timeCell);
388
 
389
+ const rowTextParts = [truthSegment.content || ""];
390
+
391
+ tracks.forEach((track) => {
392
  const cell = document.createElement("div");
393
  cell.className = "transcript-cell";
394
  cell.style.setProperty("--accent", track.accent);
 
395
  const transcript = allTranscripts[track.id];
396
+
397
  if (transcript?.error) {
398
  cell.innerHTML = '<em class="error-text">Error loading</em>';
399
+ rowTextParts.push("");
400
  } else {
401
+ const segment = findSegmentForTime(transcript?.segments || [], truthSegment.start);
402
+ rowTextParts.push(segment?.content || "");
403
+ if (track.id === "truth") {
404
+ cell.innerHTML = `<p class="transcript-text">${escapeHtml(truthSegment.content)}</p>`;
405
+ } else {
406
+ const diffTokens = diffWords(truthSegment.content || "", segment?.content || "");
407
+ cell.innerHTML = renderDiffTokens(diffTokens);
408
+ }
409
  }
410
 
411
  row.appendChild(cell);
412
  });
413
 
414
+ row.dataset.rowText = rowTextParts.join(" ").toLowerCase();
415
  comparisonTableEl.appendChild(row);
416
  segmentRows.push(row);
417
  });
418
+
419
+ filterRows(searchInput?.value || "");
420
+ }
421
+
422
+ function updateMatchCount(matches, query) {
423
+ if (!matchCountEl) return;
424
+ const total = segmentRows.length;
425
+ if (!total) {
426
+ matchCountEl.textContent = "No segments loaded.";
427
+ return;
428
+ }
429
+ if (!query) {
430
+ matchCountEl.textContent = `Showing all ${total} segments`;
431
+ } else {
432
+ matchCountEl.textContent = `Showing ${matches}/${total} segments for "${query}"`;
433
+ }
434
+ }
435
+
436
+ function filterRows(rawQuery) {
437
+ const normalized = rawQuery.trim().toLowerCase();
438
+ let matches = 0;
439
+ segmentRows.forEach((row) => {
440
+ const rowText = row.dataset.rowText || "";
441
+ const isMatch = !normalized || rowText.includes(normalized);
442
+ row.classList.toggle("is-hidden", !isMatch);
443
+ if (isMatch) {
444
+ matches += 1;
445
+ }
446
+ });
447
+ updateMatchCount(matches, normalized ? rawQuery : "");
448
  }
449
 
450
  function updateActiveSegments(time) {
 
453
  const end = Number(row.dataset.end);
454
  const isActive = time >= start && time < end;
455
  row.classList.toggle("is-active", isActive);
 
 
456
  if (isActive) {
457
  row.scrollIntoView({ behavior: "smooth", block: "center" });
458
  }
 
473
  const ctx = canvas.getContext("2d");
474
  ctx.scale(dpr, dpr);
475
  ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
476
+ const sliceWidth = Math.max(1, Math.floor(rawData.length / Math.max(1, canvas.clientWidth)));
477
  const halfHeight = canvas.clientHeight / 2;
478
  ctx.lineWidth = 1.25;
479
  ctx.strokeStyle = "#1f2937";
480
  ctx.beginPath();
481
+ for (let i = 0; i < canvas.clientWidth; i += 1) {
482
  const sliceStart = i * sliceWidth;
483
  let sum = 0;
484
+ for (let j = 0; j < sliceWidth; j += 1) {
485
  sum += Math.abs(rawData[sliceStart + j] || 0);
486
  }
487
  const amplitude = sum / sliceWidth;
 
496
  async function bootstrap() {
497
  allTranscripts = await loadAllTranscripts();
498
  renderComparisonTable();
499
+ renderModelInsights();
500
  void drawWaveform();
501
  }
502
 
style.css CHANGED
@@ -46,6 +46,12 @@ p {
46
  box-shadow: 0 25px 60px rgba(15, 23, 42, 0.08);
47
  }
48
 
 
 
 
 
 
 
49
  .audio-shell {
50
  background: rgba(15, 23, 42, 0.9);
51
  border-radius: 18px;
@@ -72,6 +78,142 @@ audio {
72
  gap: 1.75rem;
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  .comparison-table {
76
  background: #ffffff;
77
  border-radius: 20px;
@@ -143,6 +285,32 @@ audio {
143
  border-left: 3px solid transparent;
144
  }
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  .comparison-row.is-active {
147
  background: linear-gradient(90deg, rgba(64, 112, 244, 0.08), rgba(64, 112, 244, 0.03));
148
  transform: scale(1.01);
@@ -164,6 +332,10 @@ audio {
164
  font-style: italic;
165
  }
166
 
 
 
 
 
167
  @media (min-width: 720px) {
168
  .hero {
169
  grid-template-columns: 1.1fr 0.9fr;
@@ -198,6 +370,10 @@ audio {
198
  padding: 0.75rem 0.5rem;
199
  }
200
 
 
 
 
 
201
  .comparison-table {
202
  overflow-x: auto;
203
  }
 
46
  box-shadow: 0 25px 60px rgba(15, 23, 42, 0.08);
47
  }
48
 
49
+ .hero-meta {
50
+ margin-top: 0.75rem;
51
+ font-size: 0.95rem;
52
+ color: #6b7280;
53
+ }
54
+
55
  .audio-shell {
56
  background: rgba(15, 23, 42, 0.9);
57
  border-radius: 18px;
 
78
  gap: 1.75rem;
79
  }
80
 
81
+ .insights {
82
+ display: grid;
83
+ gap: 1rem;
84
+ padding: 1.5rem;
85
+ border-radius: 24px;
86
+ background: rgba(255, 255, 255, 0.9);
87
+ box-shadow: 0 30px 60px rgba(15, 23, 42, 0.08);
88
+ border: 1px solid rgba(15, 23, 42, 0.08);
89
+ }
90
+
91
+ .section-subtitle {
92
+ margin: 0;
93
+ color: #6b7280;
94
+ font-size: 0.95rem;
95
+ }
96
+
97
+ .insight-grid {
98
+ display: grid;
99
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
100
+ gap: 1rem;
101
+ }
102
+
103
+ .insight-card {
104
+ padding: 1rem;
105
+ border-radius: 18px;
106
+ background: #fff;
107
+ box-shadow: inset 0 0 0 1px rgba(31, 41, 55, 0.08);
108
+ display: grid;
109
+ gap: 0.75rem;
110
+ }
111
+
112
+ .insight-card::before {
113
+ content: "";
114
+ width: 32px;
115
+ height: 4px;
116
+ border-radius: 999px;
117
+ background: var(--accent, #4070f4);
118
+ display: block;
119
+ }
120
+
121
+ .metric-value {
122
+ font-size: 1.75rem;
123
+ font-weight: 700;
124
+ color: #0f172a;
125
+ }
126
+
127
+ .metric-label {
128
+ font-size: 0.85rem;
129
+ color: #6b7280;
130
+ text-transform: uppercase;
131
+ letter-spacing: 0.04em;
132
+ }
133
+
134
+ .insight-meta {
135
+ display: flex;
136
+ justify-content: space-between;
137
+ font-size: 0.85rem;
138
+ color: #4b5563;
139
+ }
140
+
141
+ .transcripts-toolbar {
142
+ display: flex;
143
+ flex-wrap: wrap;
144
+ justify-content: space-between;
145
+ gap: 1rem;
146
+ align-items: flex-end;
147
+ }
148
+
149
+ .search-group label {
150
+ display: block;
151
+ font-size: 0.85rem;
152
+ text-transform: uppercase;
153
+ letter-spacing: 0.04em;
154
+ color: #6b7280;
155
+ margin-bottom: 0.35rem;
156
+ }
157
+
158
+ .search-input {
159
+ display: flex;
160
+ border-radius: 12px;
161
+ background: #fff;
162
+ box-shadow: inset 0 0 0 1px rgba(31, 41, 55, 0.12);
163
+ overflow: hidden;
164
+ }
165
+
166
+ .search-input input {
167
+ flex: 1;
168
+ padding: 0.75rem 1rem;
169
+ border: none;
170
+ font-size: 1rem;
171
+ outline: none;
172
+ min-width: 220px;
173
+ }
174
+
175
+ .search-input button {
176
+ border: none;
177
+ background: rgba(31, 41, 55, 0.08);
178
+ padding: 0 1rem;
179
+ cursor: pointer;
180
+ font-weight: 600;
181
+ color: #1f2937;
182
+ transition: background 0.2s ease;
183
+ }
184
+
185
+ .search-input button:hover {
186
+ background: rgba(31, 41, 55, 0.16);
187
+ }
188
+
189
+ .match-count {
190
+ font-size: 0.9rem;
191
+ color: #4b5563;
192
+ }
193
+
194
+ .model-legend {
195
+ display: flex;
196
+ flex-wrap: wrap;
197
+ gap: 0.75rem 1.5rem;
198
+ font-size: 0.9rem;
199
+ color: #4b5563;
200
+ }
201
+
202
+ .model-legend span {
203
+ display: inline-flex;
204
+ align-items: center;
205
+ gap: 0.4rem;
206
+ }
207
+
208
+ .model-legend i {
209
+ width: 12px;
210
+ height: 12px;
211
+ border-radius: 50%;
212
+ display: inline-block;
213
+ background: var(--accent, #1f2937);
214
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5);
215
+ }
216
+
217
  .comparison-table {
218
  background: #ffffff;
219
  border-radius: 20px;
 
285
  border-left: 3px solid transparent;
286
  }
287
 
288
+ .transcript-text {
289
+ margin: 0;
290
+ }
291
+
292
+ .diff-token {
293
+ display: inline-flex;
294
+ align-items: center;
295
+ background: rgba(255, 107, 107, 0.12);
296
+ border-radius: 6px;
297
+ padding: 0.05rem 0.35rem;
298
+ margin: 0 0.15rem;
299
+ font-weight: 600;
300
+ color: #b91c1c;
301
+ }
302
+
303
+ .muted-text {
304
+ color: #9ca3af;
305
+ font-style: italic;
306
+ }
307
+
308
+ .insight-card h3 {
309
+ margin: 0;
310
+ font-size: 1rem;
311
+ color: #1f2937;
312
+ }
313
+
314
  .comparison-row.is-active {
315
  background: linear-gradient(90deg, rgba(64, 112, 244, 0.08), rgba(64, 112, 244, 0.03));
316
  transform: scale(1.01);
 
332
  font-style: italic;
333
  }
334
 
335
+ .comparison-row.is-hidden {
336
+ display: none;
337
+ }
338
+
339
  @media (min-width: 720px) {
340
  .hero {
341
  grid-template-columns: 1.1fr 0.9fr;
 
370
  padding: 0.75rem 0.5rem;
371
  }
372
 
373
+ .search-input input {
374
+ min-width: 160px;
375
+ }
376
+
377
  .comparison-table {
378
  overflow-x: auto;
379
  }