openai-gabriel 1.0.1__py3-none-any.whl

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 (67) hide show
  1. gabriel/__init__.py +61 -0
  2. gabriel/_version.py +1 -0
  3. gabriel/api.py +2284 -0
  4. gabriel/cli/__main__.py +60 -0
  5. gabriel/core/__init__.py +7 -0
  6. gabriel/core/llm_client.py +34 -0
  7. gabriel/core/pipeline.py +18 -0
  8. gabriel/core/prompt_template.py +152 -0
  9. gabriel/prompts/__init__.py +1 -0
  10. gabriel/prompts/bucket_prompt.jinja2 +113 -0
  11. gabriel/prompts/classification_prompt.jinja2 +50 -0
  12. gabriel/prompts/codify_prompt.jinja2 +95 -0
  13. gabriel/prompts/comparison_prompt.jinja2 +60 -0
  14. gabriel/prompts/deduplicate_prompt.jinja2 +41 -0
  15. gabriel/prompts/deidentification_prompt.jinja2 +112 -0
  16. gabriel/prompts/extraction_prompt.jinja2 +61 -0
  17. gabriel/prompts/filter_prompt.jinja2 +31 -0
  18. gabriel/prompts/ideation_prompt.jinja2 +80 -0
  19. gabriel/prompts/merge_prompt.jinja2 +47 -0
  20. gabriel/prompts/paraphrase_prompt.jinja2 +17 -0
  21. gabriel/prompts/rankings_prompt.jinja2 +49 -0
  22. gabriel/prompts/ratings_prompt.jinja2 +50 -0
  23. gabriel/prompts/regional_analysis_prompt.jinja2 +40 -0
  24. gabriel/prompts/seed.jinja2 +43 -0
  25. gabriel/prompts/snippets.jinja2 +117 -0
  26. gabriel/tasks/__init__.py +63 -0
  27. gabriel/tasks/_attribute_utils.py +69 -0
  28. gabriel/tasks/bucket.py +432 -0
  29. gabriel/tasks/classify.py +562 -0
  30. gabriel/tasks/codify.py +1033 -0
  31. gabriel/tasks/compare.py +235 -0
  32. gabriel/tasks/debias.py +1460 -0
  33. gabriel/tasks/deduplicate.py +341 -0
  34. gabriel/tasks/deidentify.py +316 -0
  35. gabriel/tasks/discover.py +524 -0
  36. gabriel/tasks/extract.py +455 -0
  37. gabriel/tasks/filter.py +169 -0
  38. gabriel/tasks/ideate.py +782 -0
  39. gabriel/tasks/merge.py +464 -0
  40. gabriel/tasks/paraphrase.py +531 -0
  41. gabriel/tasks/rank.py +2041 -0
  42. gabriel/tasks/rate.py +347 -0
  43. gabriel/tasks/seed.py +465 -0
  44. gabriel/tasks/whatever.py +344 -0
  45. gabriel/utils/__init__.py +64 -0
  46. gabriel/utils/audio_utils.py +42 -0
  47. gabriel/utils/file_utils.py +464 -0
  48. gabriel/utils/image_utils.py +22 -0
  49. gabriel/utils/jinja.py +31 -0
  50. gabriel/utils/logging.py +86 -0
  51. gabriel/utils/mapmaker.py +304 -0
  52. gabriel/utils/media_utils.py +78 -0
  53. gabriel/utils/modality_utils.py +148 -0
  54. gabriel/utils/openai_utils.py +5470 -0
  55. gabriel/utils/parsing.py +282 -0
  56. gabriel/utils/passage_viewer.py +2557 -0
  57. gabriel/utils/pdf_utils.py +20 -0
  58. gabriel/utils/plot_utils.py +2881 -0
  59. gabriel/utils/prompt_utils.py +42 -0
  60. gabriel/utils/word_matching.py +158 -0
  61. openai_gabriel-1.0.1.dist-info/METADATA +443 -0
  62. openai_gabriel-1.0.1.dist-info/RECORD +67 -0
  63. openai_gabriel-1.0.1.dist-info/WHEEL +5 -0
  64. openai_gabriel-1.0.1.dist-info/entry_points.txt +2 -0
  65. openai_gabriel-1.0.1.dist-info/licenses/LICENSE +201 -0
  66. openai_gabriel-1.0.1.dist-info/licenses/NOTICE +13 -0
  67. openai_gabriel-1.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2557 @@
1
+ import ast
2
+ import colorsys
3
+ import math
4
+ import html
5
+ import json
6
+ import random
7
+ import re
8
+ import uuid
9
+ from string import Template
10
+ from dataclasses import dataclass
11
+ from typing import (
12
+ Any,
13
+ Dict,
14
+ Iterable,
15
+ List,
16
+ Mapping,
17
+ Optional,
18
+ Sequence,
19
+ Tuple,
20
+ Union,
21
+ Literal,
22
+ Set,
23
+ )
24
+
25
+ import pandas as pd
26
+ try:
27
+ import matplotlib.pyplot as plt
28
+ except Exception: # pragma: no cover - optional dependency
29
+ plt = None
30
+
31
+
32
+ def _generate_distinct_colors(n: int) -> List[str]:
33
+ """Generate ``n`` visually distinct hex colors.
34
+
35
+ This helper is shared by both the rich ``tkinter`` viewer and the simpler
36
+ HTML based viewer used in headless environments such as Google Colab.
37
+ """
38
+
39
+ base_colors: List[str] = []
40
+ if plt is not None:
41
+ if n <= 20:
42
+ cmap = plt.get_cmap("tab20")
43
+ for i in range(n):
44
+ rgb = cmap(i)[:3]
45
+ base_colors.append(
46
+ "#{:02x}{:02x}{:02x}".format(
47
+ int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255)
48
+ )
49
+ )
50
+ return base_colors
51
+ else:
52
+ cmap = plt.get_cmap("tab20")
53
+ for i in range(20):
54
+ rgb = cmap(i)[:3]
55
+ base_colors.append(
56
+ "#{:02x}{:02x}{:02x}".format(
57
+ int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255)
58
+ )
59
+ )
60
+
61
+ for i in range(len(base_colors), n):
62
+ hue = (i * 1.0 / n) % 1.0
63
+ r, g, b = colorsys.hsv_to_rgb(hue, 0.7, 1.0)
64
+ base_colors.append(
65
+ "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
66
+ )
67
+ return base_colors[:n]
68
+
69
+
70
+ def _parse_hex_color(value: str) -> Optional[Tuple[int, int, int]]:
71
+ """Return an RGB tuple from a hex color string."""
72
+
73
+ if not value:
74
+ return None
75
+
76
+ text = value.strip()
77
+ if not text:
78
+ return None
79
+ if text.startswith("#"):
80
+ text = text[1:]
81
+ if len(text) == 3:
82
+ text = "".join(ch * 2 for ch in text)
83
+ if len(text) != 6:
84
+ return None
85
+ try:
86
+ r = int(text[0:2], 16)
87
+ g = int(text[2:4], 16)
88
+ b = int(text[4:6], 16)
89
+ except ValueError:
90
+ return None
91
+ return r, g, b
92
+
93
+
94
+ def _rgba_string_from_hex(value: str, alpha: float) -> Optional[str]:
95
+ """Convert a hex color to an ``rgba()`` string with the given alpha."""
96
+
97
+ rgb = _parse_hex_color(value)
98
+ if rgb is None:
99
+ return None
100
+ try:
101
+ alpha_float = float(alpha)
102
+ except (TypeError, ValueError): # pragma: no cover - defensive
103
+ alpha_float = 1.0
104
+ alpha_float = max(0.0, min(1.0, alpha_float))
105
+ r, g, b = rgb
106
+ return f"rgba({r}, {g}, {b}, {alpha_float:.3f})"
107
+
108
+
109
+ def _build_chip_style_tokens(color: str, intensity: float = 1.0) -> Optional[str]:
110
+ """Produce inline CSS custom properties for legend chips.
111
+
112
+ ``intensity`` allows callers to dim or brighten the glow that surrounds a
113
+ chip. The value is normalized to the ``[0.0, 1.0]`` range so that
114
+ ``intensity=1`` matches the original styling, while lower values softly
115
+ fade the background/border/glow.
116
+ """
117
+
118
+ if not color:
119
+ return None
120
+
121
+ try:
122
+ normalized = float(intensity)
123
+ except (TypeError, ValueError):
124
+ normalized = 1.0
125
+ if not math.isfinite(normalized):
126
+ normalized = 1.0
127
+ normalized = max(0.0, min(1.0, normalized))
128
+
129
+ tokens: List[str] = []
130
+ bg = _rgba_string_from_hex(color, 0.05 + 0.17 * normalized)
131
+ border = _rgba_string_from_hex(color, 0.08 + 0.47 * normalized)
132
+ glow = _rgba_string_from_hex(color, 0.04 + 0.31 * normalized)
133
+ if bg:
134
+ tokens.append(f"--gabriel-chip-bg:{bg}")
135
+ if border:
136
+ tokens.append(f"--gabriel-chip-border:{border}")
137
+ if glow:
138
+ tokens.append(f"--gabriel-chip-glow:{glow}")
139
+ if not tokens:
140
+ return None
141
+ return ";".join(tokens)
142
+
143
+
144
+ def _compute_numeric_intensity(
145
+ value: Optional[float],
146
+ bounds: Optional[Tuple[Optional[float], Optional[float]]],
147
+ ) -> float:
148
+ """Return a 0-1 score indicating where ``value`` sits within ``bounds``."""
149
+
150
+ if value is None or not bounds:
151
+ return 0.0
152
+
153
+ lower, upper = bounds
154
+ if lower is None and upper is None:
155
+ return 0.0
156
+
157
+ try:
158
+ numeric_value = float(value)
159
+ except (TypeError, ValueError):
160
+ return 0.0
161
+
162
+ lower_bound = float(lower) if lower is not None else numeric_value
163
+ upper_bound = float(upper) if upper is not None else numeric_value
164
+ if not math.isfinite(lower_bound):
165
+ lower_bound = numeric_value
166
+ if not math.isfinite(upper_bound):
167
+ upper_bound = numeric_value
168
+
169
+ # If every datapoint shares the exact same numeric value the rating should
170
+ # not appear highlighted. Treat effectively zero-width ranges as having no
171
+ # intensity so the chip renders in its neutral state.
172
+ if math.isclose(lower_bound, upper_bound, rel_tol=1e-9, abs_tol=1e-12):
173
+ return 0.0
174
+
175
+ span = upper_bound - lower_bound
176
+ if span == 0:
177
+ return 0.0
178
+
179
+ normalized = (numeric_value - lower_bound) / span
180
+ return max(0.0, min(1.0, normalized))
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class _AttributeRequest:
185
+ column: str
186
+ label: str
187
+ dynamic: bool = False
188
+ description: Optional[str] = None
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class _AttributeSpec:
193
+ column: str
194
+ label: str
195
+ kind: Literal["snippet", "boolean", "numeric", "text"]
196
+ dynamic: bool = False
197
+ description: Optional[str] = None
198
+
199
+ def _coerce_category_spec(
200
+ categories: Optional[Union[Sequence[Any], Any]]
201
+ ) -> Optional[Union[List[str], str]]:
202
+ """Normalize the ``categories`` argument into a predictable form."""
203
+
204
+ if categories is None:
205
+ return None
206
+
207
+ if isinstance(categories, str):
208
+ return categories if categories == "coded_passages" else [categories]
209
+
210
+ if isinstance(categories, Iterable):
211
+ normalized: List[str] = []
212
+ for item in categories:
213
+ if item is None:
214
+ continue
215
+ normalized.append(str(item))
216
+ return normalized
217
+
218
+ return [str(categories)]
219
+
220
+
221
+ def _normalize_attribute_requests(
222
+ attributes: Optional[Union[Mapping[str, Any], Sequence[Any], Any]]
223
+ ) -> List[_AttributeRequest]:
224
+ """Coerce attribute selections into a structured list."""
225
+
226
+ if attributes is None:
227
+ return []
228
+
229
+ label_overrides: Dict[str, str] = {}
230
+ descriptions: Dict[str, str] = {}
231
+ if isinstance(attributes, Mapping):
232
+ iterable: Iterable[Any] = attributes.keys()
233
+ for key, value in attributes.items():
234
+ if value is None:
235
+ continue
236
+ column_key = str(key)
237
+ if isinstance(value, Mapping):
238
+ label_hint = str(value.get("label", "")).strip()
239
+ desc_hint = str(value.get("description", "")).strip()
240
+ if label_hint:
241
+ label_overrides[column_key] = label_hint
242
+ if desc_hint:
243
+ descriptions[column_key] = desc_hint
244
+ continue
245
+ text = str(value).strip()
246
+ if not text:
247
+ continue
248
+ descriptions[column_key] = text
249
+ elif isinstance(attributes, (str, bytes)):
250
+ iterable = [attributes]
251
+ elif isinstance(attributes, Iterable):
252
+ iterable = attributes
253
+ else:
254
+ iterable = [attributes]
255
+
256
+ requests: List[_AttributeRequest] = []
257
+ seen: Set[Tuple[str, bool]] = set()
258
+ for entry in iterable:
259
+ dynamic = False
260
+ label_hint: Optional[str] = None
261
+ description_hint: Optional[str] = None
262
+ if isinstance(entry, tuple) and entry:
263
+ column = str(entry[0])
264
+ label_hint = str(entry[1]) if len(entry) > 1 else None
265
+ elif isinstance(entry, list) and entry:
266
+ column = str(entry[0])
267
+ label_hint = str(entry[1]) if len(entry) > 1 else None
268
+ elif isinstance(entry, tuple) and not entry:
269
+ continue
270
+ elif isinstance(entry, Mapping):
271
+ for key, value in entry.items():
272
+ column = str(key)
273
+ override = str(value).strip() if value is not None else ""
274
+ dynamic = column == "coded_passages"
275
+ pretty = override or column.replace("_", " ").title()
276
+ identity = (column, dynamic)
277
+ if identity in seen:
278
+ continue
279
+ seen.add(identity)
280
+ requests.append(
281
+ _AttributeRequest(
282
+ column=column,
283
+ label=pretty,
284
+ dynamic=dynamic,
285
+ description=descriptions.get(column),
286
+ )
287
+ )
288
+ continue
289
+ else:
290
+ column = str(entry)
291
+
292
+ dynamic = column == "coded_passages"
293
+ label_source = label_overrides.get(column) or label_hint
294
+ description_hint = descriptions.get(column)
295
+ if label_source:
296
+ pretty = label_source
297
+ else:
298
+ pretty = column.replace("_", " ").title()
299
+ identity = (column, dynamic)
300
+ if identity in seen:
301
+ continue
302
+ seen.add(identity)
303
+ requests.append(
304
+ _AttributeRequest(
305
+ column=column,
306
+ label=pretty,
307
+ dynamic=dynamic,
308
+ description=description_hint,
309
+ )
310
+ )
311
+
312
+ return requests
313
+
314
+
315
+ def _collect_mapping_keys(series: pd.Series, limit: int = 64) -> List[str]:
316
+ keys: List[str] = []
317
+ seen: Set[str] = set()
318
+ for value in series:
319
+ parsed = _parse_structured_cell(value)
320
+ if not isinstance(parsed, Mapping):
321
+ continue
322
+ for key in parsed.keys():
323
+ if key is None:
324
+ continue
325
+ text = str(key).strip()
326
+ if not text or text in seen:
327
+ continue
328
+ seen.add(text)
329
+ keys.append(text)
330
+ if len(keys) >= limit:
331
+ return keys
332
+ return keys
333
+
334
+
335
+ def _extract_mapping_value(value: Any, key: str) -> Any:
336
+ parsed = _parse_structured_cell(value)
337
+ if not isinstance(parsed, Mapping):
338
+ return None
339
+ raw = parsed.get(key)
340
+ if isinstance(raw, Mapping):
341
+ for candidate in ("value", "rating", "score", "answer", "label", "text"):
342
+ if candidate in raw and raw[candidate] is not None:
343
+ return raw[candidate]
344
+ return raw.get("value")
345
+ return raw
346
+
347
+
348
+ def _expand_mapping_attribute_requests(
349
+ df: pd.DataFrame,
350
+ requests: List[_AttributeRequest],
351
+ ) -> Tuple[pd.DataFrame, List[_AttributeRequest]]:
352
+ if df.empty or not requests:
353
+ return df, requests
354
+
355
+ expanded: List[_AttributeRequest] = []
356
+ for request in requests:
357
+ if request.dynamic or request.column not in df.columns:
358
+ expanded.append(request)
359
+ continue
360
+ series = df[request.column]
361
+ mapping_keys = _collect_mapping_keys(series)
362
+ if not mapping_keys:
363
+ expanded.append(request)
364
+ continue
365
+ for key in mapping_keys:
366
+ derived_column = f"{request.column}::{key}"
367
+ if derived_column not in df.columns:
368
+ df[derived_column] = series.apply(
369
+ lambda value, key=key: _extract_mapping_value(value, key)
370
+ )
371
+ expanded.append(
372
+ _AttributeRequest(
373
+ column=derived_column,
374
+ label=str(key),
375
+ dynamic=False,
376
+ description=request.description,
377
+ )
378
+ )
379
+ return df, expanded
380
+
381
+
382
+ def _coerce_bool_value(value: Any) -> Optional[bool]:
383
+ if isinstance(value, bool):
384
+ return value
385
+ if isinstance(value, str):
386
+ lowered = value.strip().lower()
387
+ if lowered in {"true", "yes", "y"}:
388
+ return True
389
+ if lowered in {"false", "no", "n"}:
390
+ return False
391
+ return None
392
+
393
+
394
+ def _coerce_numeric_value(value: Any) -> Optional[float]:
395
+ if _is_na(value):
396
+ return None
397
+ if isinstance(value, bool):
398
+ return None
399
+ if isinstance(value, (int, float)):
400
+ result = float(value)
401
+ if math.isnan(result):
402
+ return None
403
+ return result
404
+ if isinstance(value, str):
405
+ stripped = value.strip()
406
+ if not stripped:
407
+ return None
408
+ try:
409
+ result = float(stripped)
410
+ if math.isnan(result):
411
+ return None
412
+ return result
413
+ except ValueError:
414
+ return None
415
+ return None
416
+
417
+
418
+ def _looks_like_snippet_column(values: Sequence[Any]) -> bool:
419
+ hits = 0
420
+ inspected = 0
421
+ structured = 0
422
+ for value in values:
423
+ if _is_na(value):
424
+ continue
425
+ inspected += 1
426
+ parsed = _parse_structured_cell(value)
427
+
428
+ candidate = parsed
429
+ if isinstance(parsed, str):
430
+ candidate = _parse_structured_cell(parsed)
431
+
432
+ if isinstance(candidate, dict):
433
+ structured += 1
434
+ if candidate:
435
+ hits += 1
436
+ continue
437
+
438
+ if isinstance(candidate, (list, tuple, set)):
439
+ structured += 1
440
+ if any(str(item).strip() for item in candidate if not _is_na(item)):
441
+ hits += 1
442
+ continue
443
+
444
+ if inspected == 0:
445
+ return False
446
+
447
+ threshold = max(1, inspected // 2)
448
+ if hits >= threshold:
449
+ return True
450
+
451
+ return structured >= threshold
452
+
453
+
454
+ def _infer_attribute_kind(series: Optional[pd.Series]) -> Literal["snippet", "boolean", "numeric", "text"]:
455
+ if series is None:
456
+ return "text"
457
+
458
+ sample: List[Any] = []
459
+ for value in series:
460
+ if _is_na(value):
461
+ continue
462
+ sample.append(value)
463
+ if len(sample) >= 25:
464
+ break
465
+
466
+ if not sample:
467
+ return "text"
468
+
469
+ if _looks_like_snippet_column(sample):
470
+ return "snippet"
471
+
472
+ bool_hits = 0
473
+ numeric_hits = 0
474
+ for value in sample:
475
+ if _coerce_bool_value(value) is not None:
476
+ bool_hits += 1
477
+ continue
478
+ if _coerce_numeric_value(value) is not None:
479
+ numeric_hits += 1
480
+
481
+ threshold = max(1, int(len(sample) * 0.6))
482
+ if bool_hits >= threshold:
483
+ return "boolean"
484
+ if numeric_hits >= threshold:
485
+ return "numeric"
486
+ return "text"
487
+
488
+
489
+ def _format_numeric_chip(value: float) -> str:
490
+ magnitude = abs(value)
491
+ if magnitude >= 100:
492
+ text = f"{value:.0f}"
493
+ elif magnitude >= 10:
494
+ text = f"{value:.1f}"
495
+ else:
496
+ text = f"{value:.2f}"
497
+ trimmed = text
498
+ if "." in trimmed:
499
+ trimmed = trimmed.rstrip("0").rstrip(".")
500
+ if len(trimmed) > 4:
501
+ trimmed = trimmed[:4]
502
+ return trimmed
503
+
504
+
505
+ def _passage_matches_filters(
506
+ passage: Mapping[str, Any],
507
+ *,
508
+ required_snippets: Optional[Set[str]] = None,
509
+ required_bools: Optional[Set[str]] = None,
510
+ numeric_filters: Optional[Mapping[str, Tuple[float, float]]] = None,
511
+ ) -> bool:
512
+ """Return ``True`` if a passage matches the provided filter selections."""
513
+
514
+ snippet_map = passage.get("snippets") or {}
515
+ for category in required_snippets or ():
516
+ if not snippet_map.get(category):
517
+ return False
518
+
519
+ bool_map = passage.get("bools") or {}
520
+ for column in required_bools or ():
521
+ if bool_map.get(column) is not True:
522
+ return False
523
+
524
+ numeric_map = passage.get("numeric") or {}
525
+ for column, bounds in (numeric_filters or {}).items():
526
+ value = numeric_map.get(column)
527
+ if value is None:
528
+ return False
529
+ lower, upper = bounds
530
+ if value < lower - 1e-9 or value > upper + 1e-9:
531
+ return False
532
+
533
+ return True
534
+
535
+
536
+ class PassageViewer:
537
+ """Legacy desktop viewer placeholder."""
538
+
539
+ def __init__(self, *_, **__):
540
+ raise RuntimeError(
541
+ "The tkinter-based PassageViewer has been retired. "
542
+ "Use gabriel.view(...) inside a notebook environment."
543
+ )
544
+
545
+ def show(self):
546
+ raise RuntimeError(
547
+ "gabriel.view now renders exclusively via the notebook interface; "
548
+ "desktop mode is no longer available."
549
+ )
550
+
551
+ _COLAB_STYLE = """
552
+ <style>
553
+ .gabriel-codify-viewer {
554
+ font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
555
+ color: #f5f7fa;
556
+ background: transparent;
557
+ }
558
+ .gabriel-codify-viewer .gabriel-status {
559
+ font-size: 14px;
560
+ letter-spacing: 0.02em;
561
+ text-transform: uppercase;
562
+ color: rgba(255, 255, 255, 0.7);
563
+ margin-bottom: 8px;
564
+ }
565
+ .gabriel-codify-viewer .gabriel-controls {
566
+ display: flex;
567
+ gap: 10px;
568
+ align-items: center;
569
+ flex-wrap: wrap;
570
+ margin-bottom: 12px;
571
+ }
572
+ .gabriel-codify-viewer .gabriel-nav-group {
573
+ display: inline-flex;
574
+ gap: 8px;
575
+ align-items: center;
576
+ flex-wrap: wrap;
577
+ }
578
+ .gabriel-codify-viewer .gabriel-nav-button {
579
+ border-radius: 999px;
580
+ border: 1px solid rgba(255, 255, 255, 0.18);
581
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.03));
582
+ color: rgba(255, 255, 255, 0.95);
583
+ padding: 6px 16px;
584
+ font-size: 12px;
585
+ font-weight: 600;
586
+ letter-spacing: 0.06em;
587
+ text-transform: uppercase;
588
+ cursor: pointer;
589
+ transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease;
590
+ }
591
+ .gabriel-codify-viewer .gabriel-nav-button:hover:not(:disabled) {
592
+ transform: translateY(-1px);
593
+ background: linear-gradient(135deg, rgba(0, 188, 212, 0.55), rgba(0, 188, 212, 0.2));
594
+ border-color: rgba(0, 188, 212, 0.65);
595
+ box-shadow: 0 10px 22px rgba(0, 188, 212, 0.25);
596
+ }
597
+ .gabriel-codify-viewer .gabriel-nav-button:disabled {
598
+ opacity: 0.35;
599
+ cursor: not-allowed;
600
+ box-shadow: none;
601
+ }
602
+ .gabriel-codify-viewer .gabriel-slider-shell {
603
+ display: inline-flex;
604
+ align-items: center;
605
+ gap: 12px;
606
+ padding: 6px 16px;
607
+ border-radius: 999px;
608
+ border: 1px solid rgba(255, 255, 255, 0.12);
609
+ background: rgba(255, 255, 255, 0.03);
610
+ }
611
+ .gabriel-codify-viewer .gabriel-slider {
612
+ -webkit-appearance: none;
613
+ appearance: none;
614
+ width: 160px;
615
+ height: 4px;
616
+ background: transparent;
617
+ cursor: pointer;
618
+ }
619
+ .gabriel-codify-viewer .gabriel-slider:focus-visible {
620
+ outline: none;
621
+ }
622
+ .gabriel-codify-viewer .gabriel-slider::-webkit-slider-runnable-track {
623
+ height: 4px;
624
+ border-radius: 999px;
625
+ background: rgba(255, 255, 255, 0.25);
626
+ }
627
+ .gabriel-codify-viewer .gabriel-slider::-moz-range-track {
628
+ height: 4px;
629
+ border-radius: 999px;
630
+ background: rgba(255, 255, 255, 0.25);
631
+ }
632
+ .gabriel-codify-viewer .gabriel-slider::-webkit-slider-thumb {
633
+ -webkit-appearance: none;
634
+ appearance: none;
635
+ width: 18px;
636
+ height: 18px;
637
+ border-radius: 50%;
638
+ background: #00bcd4;
639
+ border: 2px solid #0b1016;
640
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.45);
641
+ margin-top: -7px;
642
+ }
643
+ .gabriel-codify-viewer .gabriel-slider::-moz-range-thumb {
644
+ width: 18px;
645
+ height: 18px;
646
+ border-radius: 50%;
647
+ background: #00bcd4;
648
+ border: 2px solid #0b1016;
649
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.45);
650
+ }
651
+ .gabriel-codify-viewer .gabriel-slider:disabled {
652
+ opacity: 0.35;
653
+ cursor: not-allowed;
654
+ }
655
+ .gabriel-codify-viewer .gabriel-slider-count {
656
+ font-size: 11px;
657
+ letter-spacing: 0.08em;
658
+ text-transform: uppercase;
659
+ color: rgba(255, 255, 255, 0.78);
660
+ font-weight: 600;
661
+ }
662
+ .gabriel-codify-viewer .gabriel-passage-panel {
663
+ background: #13161a;
664
+ border: 1px solid #2b323c;
665
+ border-radius: 14px;
666
+ padding: 18px 20px;
667
+ box-shadow: 0 16px 40px rgba(9, 11, 16, 0.45);
668
+ }
669
+ .gabriel-codify-viewer .gabriel-passage-scroll {
670
+ max-height: 560px;
671
+ overflow-y: auto;
672
+ padding-right: 12px;
673
+ }
674
+ .gabriel-codify-viewer .gabriel-legend {
675
+ position: sticky;
676
+ top: 0;
677
+ z-index: 2;
678
+ background: #13161a;
679
+ padding-bottom: 12px;
680
+ margin-bottom: 16px;
681
+ border-bottom: 1px solid #2b323c;
682
+ }
683
+ .gabriel-codify-viewer .gabriel-legend-grid {
684
+ display: flex;
685
+ flex-wrap: wrap;
686
+ gap: 12px;
687
+ }
688
+ .gabriel-codify-viewer .gabriel-legend-item {
689
+ display: inline-flex;
690
+ align-items: center;
691
+ gap: 10px;
692
+ padding: 6px 12px;
693
+ border-radius: 10px;
694
+ background: rgba(255, 255, 255, 0.05);
695
+ border: 1px solid rgba(255, 255, 255, 0.08);
696
+ font-size: 13px;
697
+ color: rgba(255, 255, 255, 0.88);
698
+ cursor: pointer;
699
+ transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
700
+ text-decoration: none;
701
+ font: inherit;
702
+ line-height: 1.2;
703
+ }
704
+ .gabriel-codify-viewer .gabriel-legend-item--boolean,
705
+ .gabriel-codify-viewer .gabriel-legend-item--numeric {
706
+ min-height: 36px;
707
+ --gabriel-chip-bg: rgba(255, 255, 255, 0.05);
708
+ --gabriel-chip-border: rgba(255, 255, 255, 0.12);
709
+ --gabriel-chip-glow: rgba(0, 0, 0, 0.12);
710
+ }
711
+ .gabriel-codify-viewer .gabriel-legend-item:hover {
712
+ background: rgba(255, 255, 255, 0.12);
713
+ border-color: rgba(255, 255, 255, 0.18);
714
+ box-shadow: 0 10px 22px rgba(0, 0, 0, 0.25);
715
+ }
716
+ .gabriel-codify-viewer .gabriel-legend-item:focus-visible {
717
+ outline: none;
718
+ box-shadow: 0 0 0 2px rgba(0, 188, 212, 0.65);
719
+ }
720
+ .gabriel-codify-viewer .gabriel-legend-item span {
721
+ pointer-events: none;
722
+ }
723
+ .gabriel-codify-viewer .gabriel-legend-item--snippet {
724
+ --gabriel-chip-bg: rgba(255, 255, 255, 0.12);
725
+ --gabriel-chip-border: rgba(255, 255, 255, 0.3);
726
+ --gabriel-chip-glow: rgba(0, 0, 0, 0.25);
727
+ padding: 6px 14px;
728
+ }
729
+ .gabriel-codify-viewer .gabriel-legend-item--snippet[data-count]:not([data-count="0"]) {
730
+ background: var(--gabriel-chip-bg);
731
+ border-color: var(--gabriel-chip-border);
732
+ box-shadow: 0 12px 26px var(--gabriel-chip-glow);
733
+ }
734
+ .gabriel-codify-viewer .gabriel-legend-item--snippet[data-count]:not([data-count="0"]):hover {
735
+ background: var(--gabriel-chip-bg);
736
+ border-color: var(--gabriel-chip-border);
737
+ box-shadow: 0 16px 32px var(--gabriel-chip-glow);
738
+ }
739
+ .gabriel-codify-viewer .gabriel-legend-item--snippet[data-count="0"] {
740
+ opacity: 0.55;
741
+ }
742
+ .gabriel-codify-viewer .gabriel-legend-item--snippet .gabriel-legend-count {
743
+ background: rgba(0, 0, 0, 0.4);
744
+ }
745
+ .gabriel-codify-viewer .gabriel-legend-color {
746
+ width: 16px;
747
+ height: 16px;
748
+ border-radius: 4px;
749
+ border: 1px solid rgba(0, 0, 0, 0.18);
750
+ }
751
+ .gabriel-codify-viewer .gabriel-legend-label {
752
+ font-weight: 600;
753
+ color: inherit;
754
+ }
755
+ .gabriel-codify-viewer .gabriel-legend-count {
756
+ font-size: 11px;
757
+ padding: 2px 6px;
758
+ border-radius: 999px;
759
+ background: rgba(255, 255, 255, 0.12);
760
+ color: rgba(255, 255, 255, 0.78);
761
+ }
762
+ .gabriel-codify-viewer .gabriel-legend-value {
763
+ font-size: 11px;
764
+ padding: 2px 8px;
765
+ border-radius: 999px;
766
+ background: rgba(255, 255, 255, 0.14);
767
+ color: rgba(255, 255, 255, 0.85);
768
+ display: inline-flex;
769
+ justify-content: center;
770
+ align-items: center;
771
+ min-width: calc(3ch + 10px);
772
+ text-align: center;
773
+ line-height: 1.3;
774
+ font-variant-numeric: tabular-nums;
775
+ font-feature-settings: 'tnum' 1, 'liga' 0;
776
+ }
777
+ .gabriel-codify-viewer .gabriel-legend-item--boolean {
778
+ --gabriel-chip-bg: rgba(0, 188, 212, 0.18);
779
+ --gabriel-chip-border: rgba(0, 188, 212, 0.55);
780
+ --gabriel-chip-glow: rgba(0, 188, 212, 0.25);
781
+ background: rgba(255, 255, 255, 0.04);
782
+ border-color: rgba(255, 255, 255, 0.08);
783
+ opacity: 0.85;
784
+ }
785
+ .gabriel-codify-viewer .gabriel-legend-item--boolean .gabriel-legend-value {
786
+ text-transform: uppercase;
787
+ letter-spacing: 0.06em;
788
+ }
789
+ .gabriel-codify-viewer .gabriel-legend-item--boolean .gabriel-legend-swatch {
790
+ width: 10px;
791
+ height: 10px;
792
+ border-radius: 999px;
793
+ background: var(--gabriel-chip-border, rgba(0, 188, 212, 0.55));
794
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
795
+ opacity: 0.75;
796
+ transition: box-shadow 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
797
+ flex-shrink: 0;
798
+ }
799
+ .gabriel-codify-viewer .gabriel-legend-item--boolean.is-true {
800
+ background: var(--gabriel-chip-bg);
801
+ border-color: var(--gabriel-chip-border);
802
+ box-shadow: 0 12px 28px var(--gabriel-chip-glow);
803
+ color: #e6fcff;
804
+ opacity: 1;
805
+ }
806
+ .gabriel-codify-viewer .gabriel-legend-item--boolean.is-true:hover {
807
+ background: var(--gabriel-chip-bg);
808
+ border-color: var(--gabriel-chip-border);
809
+ box-shadow: 0 16px 34px var(--gabriel-chip-glow);
810
+ }
811
+ .gabriel-codify-viewer .gabriel-legend-item--boolean.is-true .gabriel-legend-value {
812
+ background: var(--gabriel-chip-bg);
813
+ color: #e6fcff;
814
+ }
815
+ .gabriel-codify-viewer .gabriel-legend-item--boolean.is-true .gabriel-legend-swatch {
816
+ opacity: 1;
817
+ box-shadow: 0 0 12px var(--gabriel-chip-glow, rgba(0, 188, 212, 0.3));
818
+ transform: scale(1.1);
819
+ }
820
+ .gabriel-codify-viewer .gabriel-legend-item--boolean.is-false {
821
+ opacity: 0.6;
822
+ }
823
+ .gabriel-codify-viewer .gabriel-legend-item--boolean.is-false .gabriel-legend-value {
824
+ background: rgba(255, 255, 255, 0.1);
825
+ color: rgba(255, 255, 255, 0.65);
826
+ }
827
+ .gabriel-codify-viewer .gabriel-legend-item.is-filtered,
828
+ .gabriel-codify-viewer .gabriel-legend-item.is-sorted {
829
+ border-color: var(--gabriel-chip-border, rgba(0, 188, 212, 0.6));
830
+ box-shadow: 0 6px 18px var(--gabriel-chip-glow, rgba(0, 188, 212, 0.25));
831
+ }
832
+ .gabriel-codify-viewer .gabriel-legend-item--numeric {
833
+ background: var(--gabriel-chip-bg, rgba(255, 255, 255, 0.05));
834
+ border-color: var(--gabriel-chip-border, rgba(255, 255, 255, 0.12));
835
+ box-shadow: 0 8px 18px var(--gabriel-chip-glow, rgba(0, 0, 0, 0.12));
836
+ }
837
+ .gabriel-codify-viewer .gabriel-legend-item--numeric .gabriel-sort-indicator {
838
+ display: inline-flex;
839
+ width: 16px;
840
+ height: 16px;
841
+ align-items: center;
842
+ justify-content: center;
843
+ font-size: 11px;
844
+ color: rgba(255, 255, 255, 0.7);
845
+ }
846
+ .gabriel-codify-viewer .gabriel-legend-item--numeric .gabriel-sort-indicator::before {
847
+ content: '↕';
848
+ }
849
+ .gabriel-codify-viewer .gabriel-legend-item--numeric.is-sorted-asc .gabriel-sort-indicator::before {
850
+ content: '↑';
851
+ }
852
+ .gabriel-codify-viewer .gabriel-legend-item--numeric.is-sorted-desc .gabriel-sort-indicator::before {
853
+ content: '↓';
854
+ }
855
+ .gabriel-codify-viewer .gabriel-reset-button {
856
+ border-radius: 999px;
857
+ border: 1px solid rgba(255, 255, 255, 0.18);
858
+ background: rgba(255, 255, 255, 0.05);
859
+ color: rgba(255, 255, 255, 0.8);
860
+ padding: 5px 16px;
861
+ font-size: 11px;
862
+ letter-spacing: 0.08em;
863
+ text-transform: uppercase;
864
+ cursor: pointer;
865
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
866
+ }
867
+ .gabriel-codify-viewer .gabriel-reset-button:hover:not(:disabled) {
868
+ background: rgba(0, 188, 212, 0.15);
869
+ border-color: rgba(0, 188, 212, 0.6);
870
+ color: #e6fcff;
871
+ }
872
+ .gabriel-codify-viewer .gabriel-reset-button:disabled {
873
+ opacity: 0.4;
874
+ cursor: not-allowed;
875
+ }
876
+ .gabriel-codify-viewer .gabriel-header {
877
+ margin-bottom: 18px;
878
+ padding: 14px 16px;
879
+ border-radius: 16px;
880
+ background: linear-gradient(140deg, rgba(255, 255, 255, 0.05), rgba(9, 12, 18, 0.2));
881
+ border: 1px solid rgba(255, 255, 255, 0.08);
882
+ box-shadow: 0 18px 36px rgba(5, 8, 13, 0.55);
883
+ display: flex;
884
+ flex-direction: column;
885
+ gap: 12px;
886
+ }
887
+ .gabriel-codify-viewer .gabriel-header-grid {
888
+ display: grid;
889
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
890
+ gap: 10px;
891
+ width: 100%;
892
+ }
893
+ .gabriel-codify-viewer .gabriel-header-row {
894
+ display: flex;
895
+ align-items: center;
896
+ gap: 10px;
897
+ padding: 8px 14px;
898
+ border-radius: 999px;
899
+ border: 1px solid rgba(255, 255, 255, 0.12);
900
+ background: rgba(255, 255, 255, 0.03);
901
+ min-height: 0;
902
+ flex-wrap: wrap;
903
+ }
904
+ .gabriel-codify-viewer .gabriel-header-label {
905
+ font-weight: 600;
906
+ text-transform: uppercase;
907
+ font-size: 10px;
908
+ letter-spacing: 0.12em;
909
+ color: rgba(255, 255, 255, 0.68);
910
+ white-space: nowrap;
911
+ line-height: 1.1;
912
+ flex-shrink: 0;
913
+ }
914
+ .gabriel-codify-viewer .gabriel-header-value {
915
+ font-size: 13px;
916
+ color: rgba(248, 250, 252, 0.96);
917
+ font-weight: 600;
918
+ letter-spacing: 0.01em;
919
+ line-height: 1.35;
920
+ word-break: break-word;
921
+ text-align: left;
922
+ max-width: 100%;
923
+ flex: 1;
924
+ min-width: 0;
925
+ }
926
+ .gabriel-codify-viewer .gabriel-header-label,
927
+ .gabriel-codify-viewer .gabriel-header-value {
928
+ display: block;
929
+ }
930
+ @supports (backdrop-filter: blur(6px)) {
931
+ .gabriel-codify-viewer .gabriel-header {
932
+ backdrop-filter: blur(12px);
933
+ }
934
+ }
935
+ @media (max-width: 640px) {
936
+ .gabriel-codify-viewer .gabriel-header-grid {
937
+ grid-template-columns: 1fr;
938
+ }
939
+ .gabriel-codify-viewer .gabriel-header-label {
940
+ white-space: normal;
941
+ font-size: 10px;
942
+ letter-spacing: 0.08em;
943
+ }
944
+ }
945
+ .gabriel-codify-viewer .gabriel-active-cats {
946
+ margin-top: 6px;
947
+ display: flex;
948
+ flex-wrap: wrap;
949
+ gap: 6px 12px;
950
+ align-items: center;
951
+ }
952
+ .gabriel-codify-viewer .gabriel-active-label {
953
+ text-transform: uppercase;
954
+ font-size: 10px;
955
+ letter-spacing: 0.1em;
956
+ color: rgba(255, 255, 255, 0.62);
957
+ }
958
+ .gabriel-codify-viewer .gabriel-active-pill-stack {
959
+ display: flex;
960
+ flex-wrap: wrap;
961
+ gap: 6px;
962
+ }
963
+ .gabriel-codify-viewer .gabriel-active-pill {
964
+ font-size: 11px;
965
+ padding: 4px 10px;
966
+ border-radius: 999px;
967
+ background: rgba(255, 255, 255, 0.08);
968
+ border: 1px solid rgba(255, 255, 255, 0.18);
969
+ color: rgba(255, 255, 255, 0.85);
970
+ }
971
+ .gabriel-codify-viewer .gabriel-note {
972
+ font-size: 13px;
973
+ color: rgba(255, 255, 255, 0.72);
974
+ margin-bottom: 10px;
975
+ }
976
+ .gabriel-codify-viewer.gabriel-theme-dark {
977
+ color-scheme: dark;
978
+ }
979
+ .gabriel-codify-viewer .gabriel-text {
980
+ font-size: 15px;
981
+ line-height: 1.7;
982
+ color: rgba(245, 247, 250, 0.96);
983
+ }
984
+ .gabriel-codify-viewer .gabriel-text p {
985
+ margin: 0 0 1em 0;
986
+ }
987
+ .gabriel-codify-viewer .gabriel-snippet {
988
+ position: relative;
989
+ border-radius: 6px;
990
+ padding: 1px 5px;
991
+ font-weight: 600;
992
+ color: #0d1014;
993
+ cursor: pointer;
994
+ transition: box-shadow 0.2s ease, transform 0.2s ease;
995
+ }
996
+ .gabriel-codify-viewer .gabriel-snippet::after {
997
+ content: attr(data-label);
998
+ position: absolute;
999
+ left: 0;
1000
+ bottom: 100%;
1001
+ transform: translateY(-6px);
1002
+ background: rgba(8, 11, 17, 0.92);
1003
+ color: #f8fafc;
1004
+ padding: 3px 8px;
1005
+ border-radius: 6px;
1006
+ font-size: 11px;
1007
+ white-space: nowrap;
1008
+ opacity: 0;
1009
+ pointer-events: none;
1010
+ transition: opacity 0.2s ease, transform 0.2s ease;
1011
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
1012
+ z-index: 5;
1013
+ }
1014
+ .gabriel-codify-viewer .gabriel-snippet:hover::after {
1015
+ opacity: 1;
1016
+ transform: translateY(-10px);
1017
+ }
1018
+ .gabriel-codify-viewer .gabriel-snippet-active {
1019
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 0 18px rgba(255, 255, 255, 0.35);
1020
+ }
1021
+ .gabriel-codify-viewer .gabriel-empty {
1022
+ font-style: italic;
1023
+ color: rgba(255, 255, 255, 0.65);
1024
+ }
1025
+ @media (prefers-color-scheme: light) {
1026
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) {
1027
+ color: #1f2933;
1028
+ }
1029
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-passage-panel {
1030
+ background: #f7f9fb;
1031
+ border-color: #d0d7e2;
1032
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
1033
+ }
1034
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend {
1035
+ background: #f7f9fb;
1036
+ border-color: #d0d7e2;
1037
+ }
1038
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-header {
1039
+ background: rgba(15, 23, 42, 0.06);
1040
+ border-color: rgba(15, 23, 42, 0.12);
1041
+ }
1042
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-header-row {
1043
+ background: rgba(255, 255, 255, 0.92);
1044
+ border-color: rgba(15, 23, 42, 0.12);
1045
+ }
1046
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-header-label {
1047
+ color: rgba(15, 23, 42, 0.65);
1048
+ }
1049
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-header-value {
1050
+ color: rgba(15, 23, 42, 0.92);
1051
+ }
1052
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-text {
1053
+ color: #1f2933;
1054
+ }
1055
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item {
1056
+ background: rgba(15, 23, 42, 0.06);
1057
+ border-color: rgba(15, 23, 42, 0.12);
1058
+ color: rgba(15, 23, 42, 0.82);
1059
+ }
1060
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item:hover {
1061
+ background: rgba(15, 23, 42, 0.1);
1062
+ border-color: rgba(15, 23, 42, 0.18);
1063
+ }
1064
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item:focus-visible {
1065
+ box-shadow: 0 0 0 2px rgba(0, 188, 212, 0.4);
1066
+ }
1067
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--snippet .gabriel-legend-count {
1068
+ background: rgba(15, 23, 42, 0.15);
1069
+ }
1070
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--snippet[data-count]:not([data-count="0"]) {
1071
+ box-shadow: 0 12px 24px var(--gabriel-chip-glow, rgba(15, 23, 42, 0.18));
1072
+ }
1073
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--boolean {
1074
+ background: rgba(15, 23, 42, 0.04);
1075
+ border-color: rgba(15, 23, 42, 0.08);
1076
+ color: rgba(15, 23, 42, 0.82);
1077
+ }
1078
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--boolean .gabriel-legend-swatch {
1079
+ box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.2);
1080
+ }
1081
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--boolean.is-true {
1082
+ background: var(--gabriel-chip-bg, rgba(37, 99, 235, 0.18));
1083
+ border-color: var(--gabriel-chip-border, rgba(37, 99, 235, 0.4));
1084
+ box-shadow: 0 12px 24px var(--gabriel-chip-glow, rgba(37, 99, 235, 0.2));
1085
+ color: rgba(15, 23, 42, 0.92);
1086
+ }
1087
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--boolean.is-true .gabriel-legend-value {
1088
+ background: var(--gabriel-chip-bg, rgba(37, 99, 235, 0.35));
1089
+ color: rgba(15, 23, 42, 0.92);
1090
+ }
1091
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--boolean.is-true .gabriel-legend-swatch {
1092
+ box-shadow: 0 0 12px var(--gabriel-chip-glow, rgba(37, 99, 235, 0.3));
1093
+ }
1094
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--boolean.is-false .gabriel-legend-value {
1095
+ background: rgba(15, 23, 42, 0.08);
1096
+ color: rgba(15, 23, 42, 0.6);
1097
+ }
1098
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-active-label {
1099
+ color: rgba(15, 23, 42, 0.55);
1100
+ }
1101
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-active-pill {
1102
+ background: rgba(15, 23, 42, 0.06);
1103
+ border-color: rgba(15, 23, 42, 0.15);
1104
+ color: rgba(15, 23, 42, 0.8);
1105
+ }
1106
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item--numeric {
1107
+ background: var(--gabriel-chip-bg, rgba(15, 23, 42, 0.06));
1108
+ border-color: var(--gabriel-chip-border, rgba(15, 23, 42, 0.12));
1109
+ box-shadow: 0 10px 20px var(--gabriel-chip-glow, rgba(15, 23, 42, 0.12));
1110
+ }
1111
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-count {
1112
+ background: rgba(15, 23, 42, 0.1);
1113
+ color: rgba(15, 23, 42, 0.75);
1114
+ }
1115
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-nav-button {
1116
+ background: linear-gradient(135deg, #ffffff, #e0e7ff);
1117
+ border-color: rgba(15, 23, 42, 0.12);
1118
+ color: #1f2933;
1119
+ }
1120
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-slider-shell {
1121
+ background: rgba(255, 255, 255, 0.9);
1122
+ border-color: rgba(15, 23, 42, 0.12);
1123
+ }
1124
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-slider::-webkit-slider-runnable-track,
1125
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-slider::-moz-range-track {
1126
+ background: rgba(15, 23, 42, 0.2);
1127
+ }
1128
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-slider::-webkit-slider-thumb,
1129
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-slider::-moz-range-thumb {
1130
+ background: #2563eb;
1131
+ border-color: #ffffff;
1132
+ }
1133
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-numeric-values {
1134
+ color: rgba(15, 23, 42, 0.7);
1135
+ }
1136
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-numeric-value-chip {
1137
+ background: rgba(15, 23, 42, 0.08);
1138
+ color: rgba(15, 23, 42, 0.85);
1139
+ }
1140
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-numeric-slider::before {
1141
+ background: rgba(15, 23, 42, 0.15);
1142
+ }
1143
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-numeric-slider::after {
1144
+ background: linear-gradient(135deg, rgba(37, 99, 235, 0.85), rgba(14, 165, 233, 0.85));
1145
+ }
1146
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-range-input::-webkit-slider-thumb,
1147
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-range-input::-moz-range-thumb {
1148
+ background: #2563eb;
1149
+ border-color: rgba(255, 255, 255, 0.95);
1150
+ }
1151
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-note {
1152
+ color: rgba(15, 23, 42, 0.6);
1153
+ }
1154
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-empty {
1155
+ color: rgba(15, 23, 42, 0.55);
1156
+ }
1157
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-snippet::after {
1158
+ background: rgba(15, 23, 42, 0.92);
1159
+ color: #f8fafc;
1160
+ }
1161
+ .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-snippet-active {
1162
+ box-shadow: 0 0 0 2px rgba(15, 23, 42, 0.28), 0 0 18px rgba(15, 23, 42, 0.3);
1163
+ }
1164
+ }
1165
+ .gabriel-codify-viewer.gabriel-theme-light {
1166
+ color: #1f2933;
1167
+ color-scheme: light;
1168
+ }
1169
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-passage-panel {
1170
+ background: #f7f9fb;
1171
+ border-color: #d0d7e2;
1172
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
1173
+ }
1174
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend {
1175
+ background: #f7f9fb;
1176
+ border-color: #d0d7e2;
1177
+ }
1178
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-header {
1179
+ background: rgba(15, 23, 42, 0.06);
1180
+ border-color: rgba(15, 23, 42, 0.12);
1181
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
1182
+ }
1183
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-header-row {
1184
+ background: rgba(255, 255, 255, 0.92);
1185
+ border-color: rgba(15, 23, 42, 0.12);
1186
+ }
1187
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-header-label {
1188
+ color: rgba(15, 23, 42, 0.65);
1189
+ }
1190
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-header-value {
1191
+ color: rgba(15, 23, 42, 0.92);
1192
+ }
1193
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-text {
1194
+ color: #1f2933;
1195
+ }
1196
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item {
1197
+ background: rgba(15, 23, 42, 0.06);
1198
+ border-color: rgba(15, 23, 42, 0.12);
1199
+ color: rgba(15, 23, 42, 0.82);
1200
+ }
1201
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item:hover {
1202
+ background: rgba(15, 23, 42, 0.1);
1203
+ border-color: rgba(15, 23, 42, 0.18);
1204
+ }
1205
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item:focus-visible {
1206
+ box-shadow: 0 0 0 2px rgba(0, 188, 212, 0.4);
1207
+ }
1208
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-count {
1209
+ background: rgba(15, 23, 42, 0.1);
1210
+ color: rgba(15, 23, 42, 0.75);
1211
+ }
1212
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item--boolean {
1213
+ background: rgba(15, 23, 42, 0.04);
1214
+ border-color: rgba(15, 23, 42, 0.08);
1215
+ color: rgba(15, 23, 42, 0.82);
1216
+ }
1217
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item--boolean .gabriel-legend-swatch {
1218
+ box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.2);
1219
+ }
1220
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item--boolean.is-true {
1221
+ background: var(--gabriel-chip-bg, rgba(37, 99, 235, 0.18));
1222
+ border-color: var(--gabriel-chip-border, rgba(37, 99, 235, 0.4));
1223
+ box-shadow: 0 12px 24px var(--gabriel-chip-glow, rgba(37, 99, 235, 0.2));
1224
+ color: rgba(15, 23, 42, 0.92);
1225
+ }
1226
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item--boolean.is-true .gabriel-legend-value {
1227
+ background: var(--gabriel-chip-bg, rgba(37, 99, 235, 0.35));
1228
+ color: rgba(15, 23, 42, 0.92);
1229
+ }
1230
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item--boolean.is-true .gabriel-legend-swatch {
1231
+ box-shadow: 0 0 12px var(--gabriel-chip-glow, rgba(37, 99, 235, 0.3));
1232
+ }
1233
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-legend-item--boolean.is-false .gabriel-legend-value {
1234
+ background: rgba(15, 23, 42, 0.08);
1235
+ color: rgba(15, 23, 42, 0.6);
1236
+ }
1237
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-nav-button {
1238
+ background: linear-gradient(135deg, #ffffff, #e0e7ff);
1239
+ border-color: rgba(15, 23, 42, 0.12);
1240
+ color: #1f2933;
1241
+ }
1242
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-slider-shell {
1243
+ background: rgba(255, 255, 255, 0.92);
1244
+ border-color: rgba(15, 23, 42, 0.12);
1245
+ }
1246
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-slider::-webkit-slider-runnable-track,
1247
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-slider::-moz-range-track {
1248
+ background: rgba(15, 23, 42, 0.2);
1249
+ }
1250
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-slider::-webkit-slider-thumb,
1251
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-slider::-moz-range-thumb {
1252
+ background: #2563eb;
1253
+ border-color: #ffffff;
1254
+ }
1255
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-numeric-values {
1256
+ color: rgba(15, 23, 42, 0.7);
1257
+ }
1258
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-numeric-value-chip {
1259
+ background: rgba(15, 23, 42, 0.08);
1260
+ color: rgba(15, 23, 42, 0.85);
1261
+ }
1262
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-numeric-slider::before {
1263
+ background: rgba(15, 23, 42, 0.15);
1264
+ }
1265
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-numeric-slider::after {
1266
+ background: linear-gradient(135deg, rgba(37, 99, 235, 0.85), rgba(14, 165, 233, 0.85));
1267
+ }
1268
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-active-label {
1269
+ color: rgba(15, 23, 42, 0.55);
1270
+ }
1271
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-active-pill {
1272
+ background: rgba(15, 23, 42, 0.06);
1273
+ border-color: rgba(15, 23, 42, 0.15);
1274
+ color: rgba(15, 23, 42, 0.8);
1275
+ }
1276
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-range-input::-webkit-slider-thumb,
1277
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-range-input::-moz-range-thumb {
1278
+ background: #2563eb;
1279
+ border-color: rgba(255, 255, 255, 0.95);
1280
+ }
1281
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-note {
1282
+ color: rgba(15, 23, 42, 0.6);
1283
+ }
1284
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-empty {
1285
+ color: rgba(15, 23, 42, 0.55);
1286
+ }
1287
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-snippet::after {
1288
+ background: rgba(15, 23, 42, 0.92);
1289
+ color: #f8fafc;
1290
+ }
1291
+ .gabriel-codify-viewer.gabriel-theme-light .gabriel-snippet-active {
1292
+ box-shadow: 0 0 0 2px rgba(15, 23, 42, 0.28), 0 0 18px rgba(15, 23, 42, 0.3);
1293
+ }
1294
+ </style>
1295
+ <script>
1296
+ (function () {
1297
+ if (window.__gabrielPassageViewerEnhancer) {
1298
+ return;
1299
+ }
1300
+ window.__gabrielPassageViewerEnhancer = true;
1301
+
1302
+ const stateMap = new WeakMap();
1303
+
1304
+ function ensureState(container, token) {
1305
+ let record = stateMap.get(container);
1306
+ if (!record || record.token !== token) {
1307
+ record = { token: token, indices: {} };
1308
+ stateMap.set(container, record);
1309
+ }
1310
+ return record;
1311
+ }
1312
+
1313
+ function escapeSelector(value) {
1314
+ if (window.CSS && typeof window.CSS.escape === 'function') {
1315
+ return window.CSS.escape(value);
1316
+ }
1317
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
1318
+ }
1319
+
1320
+ function bindLegendItem(item) {
1321
+ if (!(item instanceof Element) || item.dataset.gabrielBound === '1') {
1322
+ return;
1323
+ }
1324
+ const legend = item.closest('.gabriel-legend');
1325
+ const container = item.closest('.gabriel-codify-viewer');
1326
+ if (!legend || !container) {
1327
+ return;
1328
+ }
1329
+ const category = item.getAttribute('data-category');
1330
+ if (!category) {
1331
+ return;
1332
+ }
1333
+ const token = legend.getAttribute('data-legend-token') || '';
1334
+ const state = ensureState(container, token);
1335
+ item.dataset.gabrielBound = '1';
1336
+ item.addEventListener('click', function (event) {
1337
+ event.preventDefault();
1338
+ const selector = '.gabriel-snippet[data-category="' + escapeSelector(category) + '"]';
1339
+ const snippets = container.querySelectorAll(selector);
1340
+ if (!snippets.length) {
1341
+ return;
1342
+ }
1343
+ const nextIndex = state.indices[category] || 0;
1344
+ const target = snippets[nextIndex % snippets.length];
1345
+ state.indices[category] = (nextIndex + 1) % snippets.length;
1346
+ container.querySelectorAll('.gabriel-snippet.gabriel-snippet-active').forEach(function (el) {
1347
+ if (el !== target) {
1348
+ el.classList.remove('gabriel-snippet-active');
1349
+ }
1350
+ });
1351
+ target.classList.add('gabriel-snippet-active');
1352
+ if (typeof target.scrollIntoView === 'function') {
1353
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
1354
+ }
1355
+ window.setTimeout(function () {
1356
+ target.classList.remove('gabriel-snippet-active');
1357
+ }, 1600);
1358
+ });
1359
+ }
1360
+
1361
+ function scan(root) {
1362
+ if (!(root instanceof Element)) {
1363
+ return;
1364
+ }
1365
+ root.querySelectorAll('.gabriel-legend-item').forEach(bindLegendItem);
1366
+ }
1367
+
1368
+ const observer = new MutationObserver(function (mutations) {
1369
+ mutations.forEach(function (mutation) {
1370
+ mutation.addedNodes.forEach(function (node) {
1371
+ if (!(node instanceof Element)) {
1372
+ return;
1373
+ }
1374
+ if (node.classList.contains('gabriel-legend-item')) {
1375
+ bindLegendItem(node);
1376
+ } else {
1377
+ scan(node);
1378
+ }
1379
+ });
1380
+ });
1381
+ });
1382
+
1383
+ if (document && document.body) {
1384
+ observer.observe(document.body, { childList: true, subtree: true });
1385
+ scan(document.body);
1386
+ }
1387
+ })();
1388
+ </script>
1389
+ """
1390
+
1391
+ def _build_style_overrides(
1392
+ font_scale: float = 1.0,
1393
+ font_family: Optional[str] = None,
1394
+ color_mode: str = "auto",
1395
+ ) -> str:
1396
+ """Return additional CSS overrides based on user preferences."""
1397
+
1398
+ fragments: List[str] = []
1399
+
1400
+ try:
1401
+ scale = float(font_scale)
1402
+ except (TypeError, ValueError):
1403
+ scale = 1.0
1404
+ if not math.isfinite(scale):
1405
+ scale = 1.0
1406
+ scale = max(0.6, min(2.5, scale))
1407
+
1408
+ if not math.isclose(scale, 1.0, rel_tol=1e-3):
1409
+ for selector, base_size in _FONT_SIZE_OVERRIDES.items():
1410
+ scaled = max(8.0, min(48.0, base_size * scale))
1411
+ fragments.append(f"{selector} {{ font-size: {scaled:.2f}px; }}")
1412
+
1413
+ if font_family:
1414
+ preferred = str(font_family).strip()
1415
+ if preferred:
1416
+ sanitized = preferred.replace("'", "\\'")
1417
+ fragments.append(
1418
+ ".gabriel-codify-viewer { font-family: '"
1419
+ + sanitized
1420
+ + "', 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; }"
1421
+ )
1422
+
1423
+ if color_mode == "light":
1424
+ fragments.append(".gabriel-codify-viewer { color-scheme: light; }")
1425
+ elif color_mode == "dark":
1426
+ fragments.append(".gabriel-codify-viewer { color-scheme: dark; }")
1427
+
1428
+ if not fragments:
1429
+ return ""
1430
+
1431
+ return "<style>" + "".join(fragments) + "</style>"
1432
+
1433
+
1434
+ def _normalize_header_columns(
1435
+ header_columns: Optional[Union[Sequence[Any], Any]]
1436
+ ) -> List[Tuple[str, str]]:
1437
+ if header_columns is None:
1438
+ return []
1439
+
1440
+ if isinstance(header_columns, Mapping):
1441
+ header_sequence: Iterable[Any] = header_columns.items()
1442
+ elif isinstance(header_columns, (str, bytes)):
1443
+ header_sequence = [header_columns]
1444
+ elif isinstance(header_columns, Iterable):
1445
+ header_sequence = header_columns
1446
+ else:
1447
+ header_sequence = [header_columns]
1448
+
1449
+ normalized: List[Tuple[str, str]] = []
1450
+ for entry in header_sequence:
1451
+ if isinstance(entry, tuple) and entry:
1452
+ column = str(entry[0])
1453
+ label = str(entry[1]) if len(entry) > 1 else column
1454
+ elif isinstance(entry, list) and entry:
1455
+ column = str(entry[0])
1456
+ label = str(entry[1]) if len(entry) > 1 else column
1457
+ else:
1458
+ column = str(entry)
1459
+ label = column
1460
+ pretty_label = label.replace("_", " ").title()
1461
+ normalized.append((column, pretty_label))
1462
+ return normalized
1463
+
1464
+
1465
+ def _is_na(value: Any) -> bool:
1466
+ if value is None:
1467
+ return True
1468
+ try:
1469
+ result = pd.isna(value)
1470
+ except Exception:
1471
+ return False
1472
+ if isinstance(result, bool):
1473
+ return result
1474
+ return False
1475
+
1476
+
1477
+ def _parse_structured_cell(value: Any) -> Any:
1478
+ """Attempt to parse serialized list/dict values from CSV/JSON sources."""
1479
+
1480
+ if not isinstance(value, str):
1481
+ return value
1482
+
1483
+ stripped = value.strip()
1484
+ if not stripped:
1485
+ return []
1486
+
1487
+ lowered = stripped.lower()
1488
+ if lowered in {"nan", "none", "null", "n/a"}:
1489
+ return None
1490
+
1491
+ for loader in (json.loads, ast.literal_eval):
1492
+ try:
1493
+ return loader(stripped)
1494
+ except Exception:
1495
+ continue
1496
+
1497
+ return value
1498
+
1499
+
1500
+ def _coerce_snippet_list(value: Any) -> List[str]:
1501
+ parsed = _parse_structured_cell(value)
1502
+
1503
+ if _is_na(parsed) or parsed is None:
1504
+ return []
1505
+
1506
+ if isinstance(parsed, str):
1507
+ return [parsed] if parsed.strip() else []
1508
+
1509
+ if isinstance(parsed, (list, tuple, set)):
1510
+ snippets: List[str] = []
1511
+ for item in parsed:
1512
+ if _is_na(item) or item is None:
1513
+ continue
1514
+ text = str(item)
1515
+ if text.strip():
1516
+ snippets.append(text)
1517
+ return snippets
1518
+
1519
+ text = str(parsed)
1520
+ return [text] if text.strip() else []
1521
+
1522
+
1523
+ def _coerce_coded_passage_map(value: Any) -> Dict[str, List[str]]:
1524
+ parsed = _parse_structured_cell(value)
1525
+
1526
+ if _is_na(parsed) or parsed is None:
1527
+ return {}
1528
+
1529
+ if isinstance(parsed, dict):
1530
+ normalized: Dict[str, List[str]] = {}
1531
+ for key, snippets in parsed.items():
1532
+ if _is_na(key) or key is None:
1533
+ continue
1534
+ cat = str(key)
1535
+ normalized[cat] = _coerce_snippet_list(snippets)
1536
+ return normalized
1537
+
1538
+ if isinstance(parsed, (list, tuple)):
1539
+ aggregated: Dict[str, List[str]] = {}
1540
+ for entry in parsed:
1541
+ if isinstance(entry, dict):
1542
+ for key, snippets in entry.items():
1543
+ cat = str(key)
1544
+ aggregated.setdefault(cat, []).extend(_coerce_snippet_list(snippets))
1545
+ return aggregated
1546
+
1547
+ return {}
1548
+
1549
+
1550
+ def _normalize_structured_dataframe(
1551
+ df: pd.DataFrame,
1552
+ categories: Optional[Union[List[str], str]],
1553
+ ) -> pd.DataFrame:
1554
+ if "coded_passages" in df.columns:
1555
+ df["coded_passages"] = df["coded_passages"].apply(_coerce_coded_passage_map)
1556
+
1557
+ category_columns: Iterable[str]
1558
+ if categories is None or categories == "coded_passages":
1559
+ category_columns = []
1560
+ else:
1561
+ category_columns = categories
1562
+
1563
+ for column in category_columns:
1564
+ if column in df.columns:
1565
+ df[column] = df[column].apply(_coerce_snippet_list)
1566
+
1567
+ return df
1568
+
1569
+
1570
+ def _extract_categories_from_coded_passages(df: pd.DataFrame) -> List[str]:
1571
+ if "coded_passages" not in df.columns:
1572
+ return []
1573
+
1574
+ all_categories = set()
1575
+ for entry in df["coded_passages"]:
1576
+ if isinstance(entry, dict):
1577
+ for key in entry.keys():
1578
+ all_categories.add(str(key))
1579
+
1580
+ return sorted(all_categories)
1581
+
1582
+
1583
+ def _format_header_value(value: Any) -> str:
1584
+ if _is_na(value):
1585
+ return ""
1586
+
1587
+ if isinstance(value, str):
1588
+ stripped = value.strip()
1589
+ if not stripped:
1590
+ return ""
1591
+ parsed = _parse_structured_cell(value)
1592
+ if isinstance(parsed, dict) and not parsed:
1593
+ return ""
1594
+ if isinstance(parsed, (list, tuple, set)) and not parsed:
1595
+ return ""
1596
+ return stripped
1597
+ if isinstance(value, (list, tuple, set)):
1598
+ parts = [str(item).strip() for item in value if str(item).strip()]
1599
+ return ", ".join(parts)
1600
+ return str(value)
1601
+
1602
+
1603
+ def _build_highlighted_text(
1604
+ text: str,
1605
+ snippet_map: Dict[str, List[str]],
1606
+ category_colors: Dict[str, str],
1607
+ category_labels: Dict[str, str],
1608
+ ) -> str:
1609
+ if not text:
1610
+ return "<div class='gabriel-empty'>No text available.</div>"
1611
+
1612
+ spans: List[Tuple[int, int, str]] = []
1613
+ for category, snippets in snippet_map.items():
1614
+ if not snippets or category not in category_colors:
1615
+ continue
1616
+ for snippet in snippets:
1617
+ if not snippet:
1618
+ continue
1619
+ start = 0
1620
+ while True:
1621
+ index = text.find(snippet, start)
1622
+ if index == -1:
1623
+ break
1624
+ spans.append((index, index + len(snippet), category))
1625
+ start = index + len(snippet)
1626
+
1627
+ if not spans:
1628
+ return html.escape(text).replace("\n", "<br/>")
1629
+
1630
+ spans.sort(key=lambda x: (x[0], -(x[1] - x[0])))
1631
+ merged: List[Tuple[int, int, str]] = []
1632
+ current_end = -1
1633
+ for start, end, category in spans:
1634
+ if start < current_end:
1635
+ continue
1636
+ merged.append((start, end, category))
1637
+ current_end = end
1638
+
1639
+ pieces: List[str] = []
1640
+ cursor = 0
1641
+ snippet_indices: Dict[str, int] = {}
1642
+ for start, end, category in merged:
1643
+ pieces.append(html.escape(text[cursor:start]).replace("\n", "<br/>"))
1644
+ snippet_html = html.escape(text[start:end]).replace("\n", "<br/>")
1645
+ category_key = str(category)
1646
+ label_source = category_labels.get(category_key, category_key)
1647
+ label = html.escape(label_source.replace("_", " ").title())
1648
+ color = category_colors.get(category_key, "#ffd54f")
1649
+ safe_color = html.escape(color, quote=True)
1650
+ safe_category = html.escape(category_key, quote=True)
1651
+ index = snippet_indices.get(category_key, 0)
1652
+ snippet_indices[category_key] = index + 1
1653
+ slug = re.sub(r"[^0-9a-zA-Z_-]+", "-", category_key).strip("-")
1654
+ if not slug:
1655
+ slug = "category"
1656
+ element_id = f"gabriel-snippet-{slug}-{index}"
1657
+ pieces.append(
1658
+ "<span class='gabriel-snippet' "
1659
+ f"data-category='{safe_category}' data-index='{index}' "
1660
+ f"data-label='{label}' id='{element_id}' "
1661
+ f"style='background-color:{safe_color}' title='{label}'>"
1662
+ f"{snippet_html}</span>"
1663
+ )
1664
+ cursor = end
1665
+ pieces.append(html.escape(text[cursor:]).replace("\n", "<br/>"))
1666
+ return "".join(pieces)
1667
+
1668
+
1669
+ def _build_header_html(
1670
+ header_rows: List[Tuple[str, str]],
1671
+ active_categories: List[str],
1672
+ ) -> str:
1673
+ if not header_rows and not active_categories:
1674
+ return ""
1675
+
1676
+ card_rows: List[str] = []
1677
+ for label, value in header_rows:
1678
+ safe_label = html.escape(label)
1679
+ safe_value = html.escape(value).replace("\n", "<br/>")
1680
+ card_rows.append(
1681
+ f"<div class='gabriel-header-row'>"
1682
+ f"<span class='gabriel-header-label'>{safe_label}</span>"
1683
+ f"<span class='gabriel-header-value'>{safe_value}</span>"
1684
+ f"</div>"
1685
+ )
1686
+
1687
+ sections: List[str] = []
1688
+ if card_rows:
1689
+ sections.append(
1690
+ "<div class='gabriel-header-grid'>" + "".join(card_rows) + "</div>"
1691
+ )
1692
+
1693
+ if active_categories:
1694
+ pills = "".join(
1695
+ "<span class='gabriel-active-pill'>"
1696
+ + html.escape(cat.replace("_", " ").title())
1697
+ + "</span>"
1698
+ for cat in active_categories
1699
+ )
1700
+ sections.append(
1701
+ "<div class='gabriel-active-cats'>"
1702
+ "<span class='gabriel-active-label'>Categories</span>"
1703
+ f"<div class='gabriel-active-pill-stack'>{pills}</div>"
1704
+ "</div>"
1705
+ )
1706
+
1707
+ return "<div class='gabriel-header'>" + "".join(sections) + "</div>"
1708
+
1709
+
1710
+ def _build_note_html(notes: Optional[Sequence[Any]]) -> str:
1711
+ """Render helper notes such as truncation or text-only warnings."""
1712
+
1713
+ if not notes:
1714
+ return ""
1715
+
1716
+ fragments: List[str] = []
1717
+ for message in notes:
1718
+ if message is None:
1719
+ continue
1720
+ text = str(message).strip()
1721
+ if not text:
1722
+ continue
1723
+ fragments.append(
1724
+ f"<div class='gabriel-note'>{html.escape(text)}</div>"
1725
+ )
1726
+
1727
+ return "".join(fragments)
1728
+
1729
+
1730
+ def _build_legend_html(
1731
+ category_colors: Dict[str, str],
1732
+ category_counts: Dict[str, int],
1733
+ category_labels: Dict[str, str],
1734
+ legend_token: Optional[str] = None,
1735
+ *,
1736
+ boolean_specs: Optional[Sequence[_AttributeSpec]] = None,
1737
+ boolean_values: Optional[Mapping[str, Optional[bool]]] = None,
1738
+ numeric_specs: Optional[Sequence[_AttributeSpec]] = None,
1739
+ numeric_values: Optional[Mapping[str, Optional[float]]] = None,
1740
+ boolean_colors: Optional[Mapping[str, str]] = None,
1741
+ numeric_colors: Optional[Mapping[str, str]] = None,
1742
+ numeric_ranges: Optional[
1743
+ Mapping[str, Tuple[Optional[float], Optional[float]]]
1744
+ ] = None,
1745
+ ) -> str:
1746
+ if not category_colors and not boolean_specs and not numeric_specs:
1747
+ return ""
1748
+
1749
+ boolean_values = boolean_values or {}
1750
+ numeric_values = numeric_values or {}
1751
+ boolean_colors = boolean_colors or {}
1752
+ numeric_colors = numeric_colors or {}
1753
+ numeric_ranges = numeric_ranges or {}
1754
+
1755
+ def _format_bool(value: Optional[bool]) -> str:
1756
+ if value is True:
1757
+ return "Yes"
1758
+ if value is False:
1759
+ return "No"
1760
+ return "—"
1761
+
1762
+ def _format_numeric(value: Optional[float]) -> str:
1763
+ if value is None:
1764
+ return "—"
1765
+ return _format_numeric_chip(value)
1766
+
1767
+ items = []
1768
+ for category, color in category_colors.items():
1769
+ pretty = category_labels.get(category, category).replace("_", " ").title()
1770
+ label = html.escape(pretty)
1771
+ raw_count = category_counts.get(category, 0)
1772
+ try:
1773
+ count_value = int(raw_count)
1774
+ except (TypeError, ValueError):
1775
+ count_value = 0
1776
+ aria_label = html.escape(f"{pretty} ({count_value})", quote=True)
1777
+ count = html.escape(str(count_value))
1778
+ safe_color = html.escape(color, quote=True)
1779
+ safe_category = html.escape(category, quote=True)
1780
+ chip_style = _build_chip_style_tokens(color)
1781
+ style_attr = (
1782
+ f" style=\"{html.escape(chip_style, quote=True)}\""
1783
+ if chip_style
1784
+ else ""
1785
+ )
1786
+ items.append(
1787
+ "<button type='button' class='gabriel-legend-item gabriel-legend-item--snippet' "
1788
+ f"data-category='{safe_category}' data-count='{count}' aria-label='{aria_label}'{style_attr}>"
1789
+ f"<span class='gabriel-legend-color' style='background:{safe_color}'></span>"
1790
+ f"<span class='gabriel-legend-label'>{label}</span>"
1791
+ f"<span class='gabriel-legend-count'>{count}</span>"
1792
+ "</button>"
1793
+ )
1794
+
1795
+ for spec in boolean_specs or ():
1796
+ column = spec.column
1797
+ label_text = spec.label or column.replace("_", " ").title()
1798
+ label = html.escape(label_text)
1799
+ value = boolean_values.get(column)
1800
+ display = html.escape(_format_bool(value))
1801
+ aria_label = html.escape(f"{label_text}: {display}", quote=True)
1802
+ safe_column = html.escape(column, quote=True)
1803
+ state_class = " is-true" if value is True else (" is-false" if value is False else "")
1804
+ chip_style = _build_chip_style_tokens(boolean_colors.get(column))
1805
+ style_attr = (
1806
+ f" style=\"{html.escape(chip_style, quote=True)}\""
1807
+ if chip_style
1808
+ else ""
1809
+ )
1810
+ items.append(
1811
+ "<button type='button' class='gabriel-legend-item gabriel-legend-item--boolean"
1812
+ f"{state_class}' data-boolean='{safe_column}' aria-pressed='false' aria-label='{aria_label}'{style_attr}>"
1813
+ "<span class='gabriel-legend-swatch' aria-hidden='true'></span>"
1814
+ f"<span class='gabriel-legend-label'>{label}</span>"
1815
+ f"<span class='gabriel-legend-value'>{display}</span>"
1816
+ "</button>"
1817
+ )
1818
+
1819
+ for spec in numeric_specs or ():
1820
+ column = spec.column
1821
+ label_text = spec.label or column.replace("_", " ").title()
1822
+ label = html.escape(label_text)
1823
+ value = numeric_values.get(column)
1824
+ display = html.escape(_format_numeric(value))
1825
+ aria_label = html.escape(f"{label_text}: {display}", quote=True)
1826
+ safe_column = html.escape(column, quote=True)
1827
+ bounds = numeric_ranges.get(column)
1828
+ intensity = _compute_numeric_intensity(value, bounds)
1829
+ chip_style = _build_chip_style_tokens(
1830
+ numeric_colors.get(column), intensity=intensity
1831
+ )
1832
+ style_attr = (
1833
+ f" style=\"{html.escape(chip_style, quote=True)}\""
1834
+ if chip_style
1835
+ else ""
1836
+ )
1837
+ items.append(
1838
+ "<button type='button' class='gabriel-legend-item gabriel-legend-item--numeric' "
1839
+ f"data-numeric='{safe_column}' aria-pressed='false' aria-label='{aria_label}'{style_attr}>"
1840
+ f"<span class='gabriel-legend-label'>{label}</span>"
1841
+ f"<span class='gabriel-legend-value'>{display}</span>"
1842
+ "<span class='gabriel-sort-indicator' aria-hidden='true'></span>"
1843
+ "</button>"
1844
+ )
1845
+
1846
+
1847
+ token_attr = (
1848
+ f" data-legend-token='{html.escape(legend_token, quote=True)}'"
1849
+ if legend_token
1850
+ else ""
1851
+ )
1852
+ return (
1853
+ f"<div class='gabriel-legend'{token_attr}><div class='gabriel-legend-grid'>"
1854
+ + "".join(items)
1855
+ + "</div></div>"
1856
+ )
1857
+
1858
+
1859
+ def _render_passage_viewer(
1860
+ df: pd.DataFrame,
1861
+ column_name: str,
1862
+ attributes: Optional[Union[Mapping[str, Any], Sequence[Any], Any]] = None,
1863
+ header_columns: Optional[Union[Sequence[Any], Any]] = None,
1864
+ *,
1865
+ max_passages: Optional[int] = None,
1866
+ font_scale: float = 1.0,
1867
+ font_family: Optional[str] = None,
1868
+ color_mode: str = "auto",
1869
+ ) -> None:
1870
+ """Display passages inside a Jupyter notebook."""
1871
+
1872
+ from IPython.display import HTML, display # pragma: no cover - optional
1873
+
1874
+ df = df.copy()
1875
+ attribute_requests = _normalize_attribute_requests(attributes)
1876
+ if attributes is None and "coded_passages" in df.columns:
1877
+ if not any(req.column == "coded_passages" for req in attribute_requests):
1878
+ attribute_requests.append(
1879
+ _AttributeRequest("coded_passages", "Coded Passages", dynamic=True)
1880
+ )
1881
+ if "overall_rank" in df.columns and not any(
1882
+ req.column == "overall_rank" for req in attribute_requests
1883
+ ):
1884
+ attribute_requests.insert(
1885
+ 0, _AttributeRequest("overall_rank", "Overall Rank")
1886
+ )
1887
+ df, attribute_requests = _expand_mapping_attribute_requests(df, attribute_requests)
1888
+
1889
+ attribute_specs: List[_AttributeSpec] = []
1890
+ boolean_specs: List[_AttributeSpec] = []
1891
+ numeric_specs: List[_AttributeSpec] = []
1892
+ snippet_columns: List[str] = []
1893
+ for request in attribute_requests:
1894
+ if request.dynamic:
1895
+ attribute_specs.append(
1896
+ _AttributeSpec(
1897
+ request.column,
1898
+ request.label,
1899
+ "snippet",
1900
+ dynamic=True,
1901
+ description=request.description,
1902
+ )
1903
+ )
1904
+ continue
1905
+ series = df[request.column] if request.column in df.columns else None
1906
+ kind = _infer_attribute_kind(series)
1907
+ spec = _AttributeSpec(
1908
+ request.column,
1909
+ request.label,
1910
+ kind,
1911
+ dynamic=False,
1912
+ description=request.description,
1913
+ )
1914
+ attribute_specs.append(spec)
1915
+ if kind == "snippet":
1916
+ snippet_columns.append(request.column)
1917
+ elif kind == "boolean":
1918
+ boolean_specs.append(spec)
1919
+ elif kind == "numeric":
1920
+ numeric_specs.append(spec)
1921
+
1922
+ df = _normalize_structured_dataframe(df, snippet_columns)
1923
+ normalized_headers = _normalize_header_columns(header_columns)
1924
+
1925
+ numeric_bounds: Dict[str, List[Optional[float]]] = {
1926
+ spec.column: [None, None] for spec in numeric_specs
1927
+ }
1928
+
1929
+ has_dynamic = any(spec.dynamic for spec in attribute_specs)
1930
+ text_only_mode = not attribute_specs
1931
+ category_names: List[str] = []
1932
+ category_labels: Dict[str, str] = {}
1933
+ if has_dynamic:
1934
+ for cat in _extract_categories_from_coded_passages(df):
1935
+ if cat not in category_labels:
1936
+ category_names.append(cat)
1937
+ category_labels[cat] = cat
1938
+ for spec in attribute_specs:
1939
+ if spec.kind == "snippet" and not spec.dynamic:
1940
+ if spec.column not in category_labels:
1941
+ category_names.append(spec.column)
1942
+ category_labels[spec.column] = spec.label
1943
+
1944
+ total_color_targets = len(category_names) + len(boolean_specs) + len(numeric_specs)
1945
+ palette = _generate_distinct_colors(total_color_targets)
1946
+ palette_iter = iter(palette)
1947
+ category_colors = {
1948
+ cat: next(palette_iter, "#ffd54f")
1949
+ for cat in category_names
1950
+ }
1951
+ boolean_colors: Dict[str, str] = {
1952
+ spec.column: next(palette_iter, "#80cbc4")
1953
+ for spec in boolean_specs
1954
+ }
1955
+ numeric_colors: Dict[str, str] = {
1956
+ spec.column: next(palette_iter, "#ffe082")
1957
+ for spec in numeric_specs
1958
+ }
1959
+
1960
+ passages: List[Dict[str, Any]] = []
1961
+ for _, row in df.iterrows():
1962
+ raw_text = row.get(column_name)
1963
+ text = "" if _is_na(raw_text) else str(raw_text)
1964
+ snippet_map: Dict[str, List[str]] = {cat: [] for cat in category_names}
1965
+ bool_values: Dict[str, Optional[bool]] = {}
1966
+ numeric_values: Dict[str, Optional[float]] = {}
1967
+ if has_dynamic:
1968
+ raw_map = row.get("coded_passages")
1969
+ if isinstance(raw_map, dict):
1970
+ for cat, snippets in raw_map.items():
1971
+ cat_key = str(cat)
1972
+ if cat_key in snippet_map:
1973
+ snippet_map[cat_key] = _coerce_snippet_list(snippets)
1974
+
1975
+ for spec in attribute_specs:
1976
+ if spec.dynamic or spec.kind != "snippet":
1977
+ continue
1978
+ cat_key = spec.column
1979
+ if cat_key in snippet_map:
1980
+ snippet_map[cat_key] = _coerce_snippet_list(row.get(cat_key, []))
1981
+
1982
+ header_rows: List[Tuple[str, str]] = []
1983
+ for column, label in normalized_headers:
1984
+ value = row.get(column)
1985
+ formatted = _format_header_value(value)
1986
+ if formatted:
1987
+ header_rows.append((label, formatted))
1988
+
1989
+ text_attributes: List[Tuple[str, str]] = []
1990
+ for spec in attribute_specs:
1991
+ if spec.dynamic or spec.kind == "snippet":
1992
+ continue
1993
+ value = row.get(spec.column)
1994
+ if spec.kind == "boolean":
1995
+ bool_value = _coerce_bool_value(value)
1996
+ bool_values[spec.column] = bool_value
1997
+ elif spec.kind == "numeric":
1998
+ numeric_value = _coerce_numeric_value(value)
1999
+ numeric_values[spec.column] = numeric_value
2000
+ if numeric_value is not None:
2001
+ bounds = numeric_bounds.setdefault(spec.column, [None, None])
2002
+ lower, upper = bounds
2003
+ if lower is None or numeric_value < lower:
2004
+ bounds[0] = numeric_value
2005
+ if upper is None or numeric_value > upper:
2006
+ bounds[1] = numeric_value
2007
+ else:
2008
+ formatted = _format_header_value(value)
2009
+ if formatted:
2010
+ label = spec.label or spec.column.replace("_", " ").title()
2011
+ text_attributes.append((label, formatted))
2012
+
2013
+ if text_attributes:
2014
+ header_rows.extend(text_attributes)
2015
+
2016
+ active_categories = [cat for cat, snippets in snippet_map.items() if snippets]
2017
+ passage_counts = {
2018
+ cat: len(snippet_map.get(cat, []))
2019
+ for cat in category_names
2020
+ }
2021
+ passages.append(
2022
+ {
2023
+ "text": text,
2024
+ "snippets": snippet_map,
2025
+ "header": header_rows,
2026
+ "active": active_categories,
2027
+ "counts": passage_counts,
2028
+ "bools": bool_values,
2029
+ "numeric": numeric_values,
2030
+ }
2031
+ )
2032
+
2033
+ numeric_ranges: Dict[str, Tuple[Optional[float], Optional[float]]] = {
2034
+ column: (bounds[0], bounds[1])
2035
+ for column, bounds in numeric_bounds.items()
2036
+ }
2037
+
2038
+ color_choice = str(color_mode or "auto").lower()
2039
+ if color_choice not in {"auto", "dark", "light"}:
2040
+ color_choice = "auto"
2041
+ theme_class = (
2042
+ " gabriel-theme-light"
2043
+ if color_choice == "light"
2044
+ else (" gabriel-theme-dark" if color_choice == "dark" else "")
2045
+ )
2046
+
2047
+ style_html = _COLAB_STYLE + _build_style_overrides(
2048
+ font_scale=font_scale,
2049
+ font_family=font_family,
2050
+ color_mode=color_choice,
2051
+ )
2052
+
2053
+ original_total = len(passages)
2054
+ limit = max_passages
2055
+ trunc_note: Optional[str] = None
2056
+ if limit is not None and limit >= 0 and original_total > limit:
2057
+ trunc_note = f"Showing first {limit} of {original_total} passages."
2058
+ passages = passages[:limit]
2059
+
2060
+ total = len(passages)
2061
+ note_messages: List[str] = []
2062
+ if text_only_mode:
2063
+ note_messages.append(
2064
+ "Displaying raw text because no attribute columns were provided."
2065
+ )
2066
+ if trunc_note:
2067
+ note_messages.append(trunc_note)
2068
+ note_html = _build_note_html(note_messages)
2069
+ root_class = f"gabriel-codify-viewer{theme_class}"
2070
+
2071
+ viewer_id = f"gabriel-viewer-{uuid.uuid4().hex}"
2072
+ if total == 0:
2073
+ empty_html = (
2074
+ f"<div class='{root_class}'><div class='gabriel-empty'>No passages to display.</div>"
2075
+ f"{note_html}</div>"
2076
+ )
2077
+ display(HTML(style_html + empty_html))
2078
+ return
2079
+
2080
+ render_entries: List[Dict[str, Any]] = []
2081
+ for idx, payload in enumerate(passages):
2082
+ body_html = _build_highlighted_text(
2083
+ payload["text"], payload["snippets"], category_colors, category_labels
2084
+ )
2085
+ header_html = _build_header_html(
2086
+ payload["header"], payload["active"]
2087
+ )
2088
+ legend_token = f"interactive-{idx}-{random.random()}"
2089
+ legend_html = _build_legend_html(
2090
+ category_colors,
2091
+ payload["counts"],
2092
+ category_labels,
2093
+ legend_token,
2094
+ boolean_specs=boolean_specs,
2095
+ boolean_values=payload.get("bools"),
2096
+ numeric_specs=numeric_specs,
2097
+ numeric_values=payload.get("numeric"),
2098
+ boolean_colors=boolean_colors,
2099
+ numeric_colors=numeric_colors,
2100
+ numeric_ranges=numeric_ranges,
2101
+ )
2102
+ snippet_flags = {
2103
+ cat: bool(payload["snippets"].get(cat)) for cat in category_names
2104
+ }
2105
+ bool_map: Dict[str, Optional[bool]] = {}
2106
+ for column, value in (payload.get("bools") or {}).items():
2107
+ if value is True:
2108
+ bool_map[column] = True
2109
+ elif value is False:
2110
+ bool_map[column] = False
2111
+ else:
2112
+ bool_map[column] = None
2113
+ numeric_map: Dict[str, Optional[float]] = {}
2114
+ for column, value in (payload.get("numeric") or {}).items():
2115
+ if value is None:
2116
+ numeric_map[column] = None
2117
+ else:
2118
+ try:
2119
+ numeric_map[column] = float(value)
2120
+ except (TypeError, ValueError):
2121
+ numeric_map[column] = None
2122
+ render_entries.append(
2123
+ {
2124
+ "html": f"{legend_html}{header_html}<div class='gabriel-text'>{body_html}</div>",
2125
+ "snippets": snippet_flags,
2126
+ "bools": bool_map,
2127
+ "numeric": numeric_map,
2128
+ }
2129
+ )
2130
+
2131
+ numeric_label_map = {
2132
+ spec.column: spec.label or spec.column.replace("_", " ").title()
2133
+ for spec in numeric_specs
2134
+ }
2135
+
2136
+ data_payload = {
2137
+ "passages": render_entries,
2138
+ "numericLabels": numeric_label_map,
2139
+ }
2140
+ data_json = json.dumps(data_payload).replace("</", r"<\/")
2141
+ note_block = (
2142
+ f"<div class='gabriel-note-stack' data-role='note'>{note_html}</div>"
2143
+ if note_html
2144
+ else ""
2145
+ )
2146
+ slider_max = max(1, total)
2147
+ slider_count = f"1 / {total}"
2148
+ viewer_template = Template(
2149
+ """
2150
+ <div id="$viewer_id" class="$root_class">
2151
+ <div class="gabriel-status" data-role="status"></div>
2152
+ <div class="gabriel-controls">
2153
+ <div class="gabriel-nav-group">
2154
+ <button type="button" class="gabriel-nav-button" data-action="prev">◀ Previous</button>
2155
+ <button type="button" class="gabriel-nav-button" data-action="random">Random</button>
2156
+ <button type="button" class="gabriel-nav-button" data-action="next">Next ▶</button>
2157
+ </div>
2158
+ <div class="gabriel-slider-shell">
2159
+ <input type="range" min="1" max="$slider_max" value="1" class="gabriel-slider" data-role="slider" />
2160
+ <div class="gabriel-slider-count" data-role="slider-count">$slider_count</div>
2161
+ </div>
2162
+ <button type="button" class="gabriel-reset-button" data-action="reset" disabled>Reset</button>
2163
+ </div>
2164
+ <div class="gabriel-passage-panel">
2165
+ <div class="gabriel-passage-scroll" data-role="passage"></div>
2166
+ </div>
2167
+ $note_block
2168
+ </div>
2169
+ <script>
2170
+ (function() {
2171
+ const container = document.getElementById("$viewer_id");
2172
+ if (!container) {
2173
+ return;
2174
+ }
2175
+ const data = $data_json;
2176
+ const statusEl = container.querySelector('[data-role="status"]');
2177
+ const passageEl = container.querySelector('[data-role="passage"]');
2178
+ const sliderEl = container.querySelector('[data-role="slider"]');
2179
+ const sliderCountEl = container.querySelector('[data-role="slider-count"]');
2180
+ const prevBtn = container.querySelector('[data-action="prev"]');
2181
+ const nextBtn = container.querySelector('[data-action="next"]');
2182
+ const randomBtn = container.querySelector('[data-action="random"]');
2183
+ const resetBtn = container.querySelector('[data-action="reset"]');
2184
+ const total = data.passages.length;
2185
+ const numericLabels = data.numericLabels || {};
2186
+ if (!total) {
2187
+ if (passageEl) {
2188
+ passageEl.innerHTML = "<div class='gabriel-empty'>No passages to display.</div>";
2189
+ }
2190
+ if (statusEl) {
2191
+ statusEl.textContent = "No passages available.";
2192
+ }
2193
+ if (sliderEl) sliderEl.disabled = true;
2194
+ if (prevBtn) prevBtn.disabled = true;
2195
+ if (nextBtn) nextBtn.disabled = true;
2196
+ if (randomBtn) randomBtn.disabled = true;
2197
+ if (resetBtn) resetBtn.disabled = true;
2198
+ return;
2199
+ }
2200
+ const state = {
2201
+ active: data.passages.map((_, idx) => idx),
2202
+ index: 0,
2203
+ bools: new Set(),
2204
+ sort: null,
2205
+ };
2206
+
2207
+ function matchesFilters(entry) {
2208
+ if (!entry) {
2209
+ return false;
2210
+ }
2211
+ if (state.bools.size) {
2212
+ for (const column of state.bools) {
2213
+ if (!entry.bools || entry.bools[column] !== true) {
2214
+ return false;
2215
+ }
2216
+ }
2217
+ }
2218
+ return true;
2219
+ }
2220
+
2221
+ function compareForSort(aIdx, bIdx) {
2222
+ if (!state.sort || !state.sort.column) {
2223
+ return 0;
2224
+ }
2225
+ const column = state.sort.column;
2226
+ const entryA = data.passages[aIdx];
2227
+ const entryB = data.passages[bIdx];
2228
+ const valA = entryA && entryA.numeric ? entryA.numeric[column] : null;
2229
+ const valB = entryB && entryB.numeric ? entryB.numeric[column] : null;
2230
+ const aValid = typeof valA === 'number' && isFinite(valA);
2231
+ const bValid = typeof valB === 'number' && isFinite(valB);
2232
+ if (!aValid && !bValid) {
2233
+ return 0;
2234
+ }
2235
+ if (!aValid) {
2236
+ return 1;
2237
+ }
2238
+ if (!bValid) {
2239
+ return -1;
2240
+ }
2241
+ if (valA === valB) {
2242
+ return 0;
2243
+ }
2244
+ const base = valA < valB ? -1 : 1;
2245
+ return state.sort.direction === 'asc' ? base : -base;
2246
+ }
2247
+
2248
+ function renderPassage() {
2249
+ if (!passageEl) {
2250
+ return;
2251
+ }
2252
+ if (!state.active.length) {
2253
+ passageEl.innerHTML = "<div class='gabriel-empty'>No passages match the current selection.</div>";
2254
+ return;
2255
+ }
2256
+ const idx = state.active[state.index];
2257
+ const payload = data.passages[idx];
2258
+ passageEl.innerHTML = payload && payload.html ? payload.html : '';
2259
+ bindLegendInteractions();
2260
+ syncLegendStates();
2261
+ }
2262
+
2263
+ function bindLegendInteractions() {
2264
+ if (!passageEl) {
2265
+ return;
2266
+ }
2267
+ passageEl.querySelectorAll('.gabriel-legend-item').forEach(item => {
2268
+ if (!(item instanceof Element) || item.dataset.interactionBound === '1') {
2269
+ return;
2270
+ }
2271
+ if (item.hasAttribute('data-boolean')) {
2272
+ item.addEventListener('click', event => {
2273
+ event.preventDefault();
2274
+ toggleBooleanFilter(item.getAttribute('data-boolean'));
2275
+ });
2276
+ } else if (item.hasAttribute('data-numeric')) {
2277
+ item.addEventListener('click', event => {
2278
+ event.preventDefault();
2279
+ cycleNumericSort(item.getAttribute('data-numeric'));
2280
+ });
2281
+ }
2282
+ item.dataset.interactionBound = '1';
2283
+ });
2284
+ }
2285
+
2286
+ function syncLegendStates() {
2287
+ if (!passageEl) {
2288
+ return;
2289
+ }
2290
+ passageEl.querySelectorAll('[data-boolean]').forEach(chip => {
2291
+ const column = chip.getAttribute('data-boolean');
2292
+ const active = column && state.bools.has(column);
2293
+ chip.classList.toggle('is-filtered', Boolean(active));
2294
+ chip.setAttribute('aria-pressed', active ? 'true' : 'false');
2295
+ });
2296
+ passageEl.querySelectorAll('[data-numeric]').forEach(chip => {
2297
+ const column = chip.getAttribute('data-numeric');
2298
+ const isActive = Boolean(state.sort && state.sort.column === column);
2299
+ chip.classList.toggle('is-sorted', isActive);
2300
+ chip.classList.toggle('is-sorted-asc', Boolean(isActive && state.sort && state.sort.direction === 'asc'));
2301
+ chip.classList.toggle('is-sorted-desc', Boolean(isActive && state.sort && state.sort.direction === 'desc'));
2302
+ chip.setAttribute('aria-pressed', isActive ? 'true' : 'false');
2303
+ });
2304
+ }
2305
+
2306
+ function updateStatus() {
2307
+ if (!statusEl) {
2308
+ return;
2309
+ }
2310
+ if (!state.active.length) {
2311
+ statusEl.textContent = 'No passages match the current selection.';
2312
+ return;
2313
+ }
2314
+ let message = 'Passage <strong>' + (state.index + 1) + '</strong> of ' + state.active.length;
2315
+ if (state.active.length !== total) {
2316
+ message += ' • ' + state.active.length + ' / ' + total + ' match';
2317
+ }
2318
+ if (state.sort && state.sort.column) {
2319
+ const label = numericLabels[state.sort.column] || state.sort.column;
2320
+ const arrow = state.sort.direction === 'asc' ? '↑' : '↓';
2321
+ message += ' • ' + arrow + ' ' + label;
2322
+ }
2323
+ statusEl.innerHTML = message;
2324
+ }
2325
+
2326
+ function updateSlider(forceValue = true) {
2327
+ if (!sliderEl || !sliderCountEl) {
2328
+ return;
2329
+ }
2330
+ const max = Math.max(1, state.active.length || 1);
2331
+ sliderEl.max = String(max);
2332
+ if (forceValue !== false) {
2333
+ sliderEl.value = state.active.length ? String(state.index + 1) : '1';
2334
+ }
2335
+ sliderEl.disabled = state.active.length <= 1;
2336
+ sliderCountEl.textContent = state.active.length ? (state.index + 1) + ' / ' + state.active.length : '0 / 0';
2337
+ }
2338
+
2339
+ function updateNavDisabled() {
2340
+ const disabled = state.active.length === 0;
2341
+ if (prevBtn) prevBtn.disabled = disabled;
2342
+ if (nextBtn) nextBtn.disabled = disabled;
2343
+ if (randomBtn) randomBtn.disabled = disabled;
2344
+ }
2345
+
2346
+ function updateResetButton() {
2347
+ if (!resetBtn) {
2348
+ return;
2349
+ }
2350
+ const hasActive = state.bools.size > 0 || Boolean(state.sort);
2351
+ resetBtn.disabled = !hasActive;
2352
+ }
2353
+
2354
+ function moveIndex(delta) {
2355
+ if (!state.active.length) {
2356
+ return;
2357
+ }
2358
+ const length = state.active.length;
2359
+ state.index = (state.index + delta + length) % length;
2360
+ renderPassage();
2361
+ updateStatus();
2362
+ updateSlider();
2363
+ }
2364
+
2365
+ function toggleBooleanFilter(column) {
2366
+ if (!column) {
2367
+ return;
2368
+ }
2369
+ if (state.bools.has(column)) {
2370
+ state.bools.delete(column);
2371
+ } else {
2372
+ state.bools.add(column);
2373
+ }
2374
+ applyFilters(true);
2375
+ }
2376
+
2377
+ function cycleNumericSort(column) {
2378
+ if (!column) {
2379
+ return;
2380
+ }
2381
+ const current = state.sort && state.sort.column === column ? state.sort : null;
2382
+ if (!current) {
2383
+ state.sort = { column, direction: 'desc' };
2384
+ } else if (current.direction === 'desc') {
2385
+ state.sort = { column, direction: 'asc' };
2386
+ } else {
2387
+ state.sort = null;
2388
+ }
2389
+ applyFilters(false);
2390
+ }
2391
+
2392
+ function resetSelections() {
2393
+ state.bools.clear();
2394
+ state.sort = null;
2395
+ applyFilters(true);
2396
+ }
2397
+
2398
+ function applyFilters(resetIndex = true) {
2399
+ const matches = [];
2400
+ data.passages.forEach((entry, idx) => {
2401
+ if (matchesFilters(entry)) {
2402
+ matches.push(idx);
2403
+ }
2404
+ });
2405
+ if (state.sort && state.sort.column) {
2406
+ matches.sort(compareForSort);
2407
+ }
2408
+ state.active = matches;
2409
+ if (!matches.length) {
2410
+ state.index = 0;
2411
+ } else if (resetIndex || state.index >= matches.length) {
2412
+ state.index = 0;
2413
+ }
2414
+ renderPassage();
2415
+ updateStatus();
2416
+ updateSlider();
2417
+ updateNavDisabled();
2418
+ updateResetButton();
2419
+ }
2420
+
2421
+ if (prevBtn) {
2422
+ prevBtn.addEventListener('click', () => moveIndex(-1));
2423
+ }
2424
+ if (nextBtn) {
2425
+ nextBtn.addEventListener('click', () => moveIndex(1));
2426
+ }
2427
+ if (randomBtn) {
2428
+ randomBtn.addEventListener('click', () => {
2429
+ if (!state.active.length) {
2430
+ return;
2431
+ }
2432
+ const idx = Math.floor(Math.random() * state.active.length);
2433
+ state.index = idx;
2434
+ renderPassage();
2435
+ updateStatus();
2436
+ updateSlider();
2437
+ });
2438
+ }
2439
+ if (resetBtn) {
2440
+ resetBtn.addEventListener('click', () => resetSelections());
2441
+ }
2442
+ if (sliderEl) {
2443
+ sliderEl.addEventListener('input', event => {
2444
+ if (!state.active.length) {
2445
+ return;
2446
+ }
2447
+ const value = parseInt(event.target.value, 10);
2448
+ if (Number.isNaN(value)) {
2449
+ return;
2450
+ }
2451
+ state.index = Math.min(state.active.length - 1, Math.max(0, value - 1));
2452
+ renderPassage();
2453
+ updateStatus();
2454
+ updateSlider(false);
2455
+ });
2456
+ }
2457
+
2458
+ applyFilters(true);
2459
+ })();
2460
+ </script>
2461
+ """
2462
+ )
2463
+ viewer_html = viewer_template.substitute(
2464
+ viewer_id=viewer_id,
2465
+ root_class=root_class,
2466
+ slider_max=slider_max,
2467
+ slider_count=slider_count,
2468
+ note_block=note_block,
2469
+ data_json=data_json,
2470
+ )
2471
+ display(HTML(style_html + viewer_html))
2472
+
2473
+
2474
+ def view(
2475
+ df: pd.DataFrame,
2476
+ column_name: str,
2477
+ attributes: Optional[Union[Mapping[str, Any], Sequence[Any], Any]] = None,
2478
+ *,
2479
+ header_columns: Optional[Union[Sequence[Any], Any]] = None,
2480
+ max_passages: Optional[int] = None,
2481
+ font_scale: float = 1.0,
2482
+ font_family: Optional[str] = None,
2483
+ color_mode: str = "auto",
2484
+ ):
2485
+ """View passages and their associated attributes.
2486
+
2487
+ Parameters
2488
+ ----------
2489
+ df:
2490
+ DataFrame containing the passages.
2491
+ column_name:
2492
+ Column name in ``df`` holding the raw text.
2493
+ attributes:
2494
+ Attribute columns to render. Accepts sequences of column names, tuples
2495
+ of ``(column, label)``, mappings, or the special string
2496
+ ``"coded_passages"`` for Codify outputs.
2497
+ header_columns:
2498
+ Optional sequence of column names (or ``(column, label)`` tuples)
2499
+ displayed above each passage. Values are rendered in the provided
2500
+ order to expose metadata such as speaker names or timestamps.
2501
+ max_passages:
2502
+ Optional cap on the number of passages rendered in the notebook.
2503
+ When ``None`` (default) all passages are available inside the viewer.
2504
+ font_scale:
2505
+ Multiplier applied to key font sizes inside the viewer.
2506
+ font_family:
2507
+ Optional custom font family prepended to the default stack.
2508
+ color_mode:
2509
+ ``"auto"`` (default), ``"dark"``, or ``"light"`` to force a theme.
2510
+ """
2511
+
2512
+ _render_passage_viewer(
2513
+ df,
2514
+ column_name,
2515
+ attributes=attributes,
2516
+ header_columns=header_columns,
2517
+ max_passages=max_passages,
2518
+ font_scale=font_scale,
2519
+ font_family=font_family,
2520
+ color_mode=color_mode,
2521
+ )
2522
+ return None
2523
+
2524
+
2525
+ if __name__ == "__main__":
2526
+ # Example usage
2527
+ import pandas as pd
2528
+
2529
+ # Sample data
2530
+ sample_data = {
2531
+ 'id': [1, 2, 3],
2532
+ 'text': [
2533
+ "This is a great example of positive text. I really appreciate your help with this matter.",
2534
+ "I can't believe how terrible this service is. This is absolutely unacceptable behavior.",
2535
+ "Could you please explain how this works? I'm genuinely curious about the process."
2536
+ ],
2537
+ 'positive_sentiment': [
2538
+ ["This is a great example of positive text", "I really appreciate your help"],
2539
+ [],
2540
+ ["I'm genuinely curious about the process"]
2541
+ ],
2542
+ 'negative_sentiment': [
2543
+ [],
2544
+ ["I can't believe how terrible this service is", "This is absolutely unacceptable behavior"],
2545
+ []
2546
+ ],
2547
+ 'questions': [
2548
+ [],
2549
+ [],
2550
+ ["Could you please explain how this works?"]
2551
+ ]
2552
+ }
2553
+
2554
+ df = pd.DataFrame(sample_data)
2555
+ categories = ['positive_sentiment', 'negative_sentiment', 'questions']
2556
+
2557
+ view(df, 'text', attributes=categories)