kash-shell 0.3.25__py3-none-any.whl → 0.3.26__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 (38) hide show
  1. kash/commands/help/assistant_commands.py +4 -3
  2. kash/config/colors.py +5 -3
  3. kash/config/text_styles.py +1 -0
  4. kash/config/unified_live.py +251 -0
  5. kash/docs/markdown/assistant_instructions_template.md +3 -3
  6. kash/docs/markdown/topics/a1_what_is_kash.md +22 -20
  7. kash/docs/markdown/topics/a2_installation.md +10 -10
  8. kash/docs/markdown/topics/a3_getting_started.md +8 -8
  9. kash/docs/markdown/topics/a4_elements.md +3 -3
  10. kash/docs/markdown/topics/a5_tips_for_use_with_other_tools.md +12 -12
  11. kash/docs/markdown/topics/b0_philosophy_of_kash.md +17 -17
  12. kash/docs/markdown/topics/b1_kash_overview.md +7 -7
  13. kash/docs/markdown/topics/b2_workspace_and_file_formats.md +1 -1
  14. kash/docs/markdown/topics/b3_modern_shell_tool_recommendations.md +1 -1
  15. kash/docs/markdown/topics/b4_faq.md +7 -7
  16. kash/docs/markdown/welcome.md +1 -1
  17. kash/embeddings/embeddings.py +110 -39
  18. kash/embeddings/text_similarity.py +2 -2
  19. kash/exec/shell_callable_action.py +4 -3
  20. kash/help/help_embeddings.py +5 -2
  21. kash/model/graph_model.py +2 -0
  22. kash/model/items_model.py +3 -3
  23. kash/shell/output/shell_output.py +2 -2
  24. kash/utils/file_utils/csv_utils.py +105 -0
  25. kash/utils/rich_custom/multitask_status.py +19 -5
  26. kash/web_gen/templates/base_styles.css.jinja +348 -23
  27. kash/web_gen/templates/base_webpage.html.jinja +11 -0
  28. kash/web_gen/templates/components/toc_styles.css.jinja +15 -3
  29. kash/web_gen/templates/components/tooltip_styles.css.jinja +1 -0
  30. kash/web_gen/templates/content_styles.css.jinja +23 -9
  31. kash/web_gen/templates/item_view.html.jinja +12 -4
  32. kash/web_gen/templates/simple_webpage.html.jinja +2 -2
  33. kash/xonsh_custom/custom_shell.py +7 -4
  34. {kash_shell-0.3.25.dist-info → kash_shell-0.3.26.dist-info}/METADATA +58 -55
  35. {kash_shell-0.3.25.dist-info → kash_shell-0.3.26.dist-info}/RECORD +38 -36
  36. {kash_shell-0.3.25.dist-info → kash_shell-0.3.26.dist-info}/WHEEL +0 -0
  37. {kash_shell-0.3.25.dist-info → kash_shell-0.3.26.dist-info}/entry_points.txt +0 -0
  38. {kash_shell-0.3.25.dist-info → kash_shell-0.3.26.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from pathlib import Path
5
+ from typing import NamedTuple
6
+
7
+
8
+ class CsvMetadata(NamedTuple):
9
+ """
10
+ Result of CSV analysis, containing skip rows, metadata, and dialect.
11
+ """
12
+
13
+ skip_rows: int
14
+ metadata: dict[str, str]
15
+ dialect: type[csv.Dialect]
16
+
17
+
18
+ def sniff_csv_metadata(
19
+ file_path: Path,
20
+ *,
21
+ max_scan_lines: int = 500,
22
+ threshold_ratio: float = 0.8,
23
+ min_columns: int = 3,
24
+ sample_size: int = 32768,
25
+ ) -> CsvMetadata:
26
+ """
27
+ Detect CSV metadata and where the table data starts by finding the first row that looks
28
+ like proper headers.
29
+
30
+ This function handles various CSV formats:
31
+ - Normal CSV files: returns skip_rows=0 (no rows to skip)
32
+ - Files with metadata: detects the first row with multiple columns that looks like headers
33
+ - Survey exports: handles key-value metadata followed by proper CSV structure
34
+
35
+ Args:
36
+ file_path: Path to the CSV file to analyze
37
+ max_scan_lines: Maximum number of lines to scan before giving up
38
+ threshold_ratio: Minimum ratio of max columns a row must have to be considered headers
39
+ min_columns: Minimum number of columns required to be considered headers
40
+ sample_size: Number of bytes to read for dialect detection
41
+
42
+ Returns:
43
+ CsvMetadata with skip_rows, metadata dict, and detected dialect
44
+ """
45
+ # Read sample for dialect detection
46
+ sample_text = file_path.read_text(encoding="utf-8", errors="replace")[:sample_size]
47
+
48
+ # Detect CSV dialect
49
+ try:
50
+ dialect = csv.Sniffer().sniff(sample_text)
51
+ except csv.Error:
52
+ # Fall back to default dialect if detection fails
53
+ dialect = csv.excel
54
+
55
+ # Analyze file structure
56
+ with open(file_path, encoding="utf-8", errors="replace") as file:
57
+ reader = csv.reader(file, dialect=dialect)
58
+
59
+ max_columns = 0
60
+ header_candidates = []
61
+ metadata = {}
62
+
63
+ for line_num, row in enumerate(reader):
64
+ # Stop scanning if we've looked at too many lines
65
+ if line_num >= max_scan_lines:
66
+ break
67
+
68
+ # Skip completely empty rows
69
+ non_empty_cells = [cell.strip() for cell in row if cell.strip()]
70
+ if not non_empty_cells:
71
+ continue
72
+
73
+ column_count = len(non_empty_cells)
74
+
75
+ # Track the maximum number of columns seen
76
+ if column_count > max_columns:
77
+ max_columns = column_count
78
+
79
+ # Collect potential key-value metadata (exactly 2 columns)
80
+ # Only collect metadata before we find any header candidates with min_columns
81
+ if column_count == 2 and not any(hc[1] >= min_columns for hc in header_candidates):
82
+ key, value = non_empty_cells[0], non_empty_cells[1]
83
+ # Simple heuristic: if it looks like a key-value pair, store it
84
+ if not key.isdigit() and not value.replace(".", "").replace(",", "").isdigit():
85
+ metadata[key] = value
86
+
87
+ # Consider this a potential header if it has minimum required columns
88
+ if column_count >= min_columns:
89
+ header_candidates.append((line_num, column_count, row))
90
+
91
+ # If no multi-column rows found, assume it's a normal CSV starting at line 0
92
+ if not header_candidates:
93
+ return CsvMetadata(skip_rows=0, metadata=metadata, dialect=dialect)
94
+
95
+ # Look for the first row that has close to the maximum number of columns
96
+ # This helps distinguish metadata (usually fewer columns) from real headers (many columns)
97
+ threshold = max(min_columns, max_columns * threshold_ratio)
98
+
99
+ for line_num, column_count, _row in header_candidates:
100
+ if column_count >= threshold:
101
+ return CsvMetadata(skip_rows=line_num, metadata=metadata, dialect=dialect)
102
+
103
+ # If no clear header found but we have candidates, return the first multi-column row
104
+ first_candidate_line = header_candidates[0][0]
105
+ return CsvMetadata(skip_rows=first_candidate_line, metadata=metadata, dialect=dialect)
@@ -4,7 +4,7 @@ import asyncio
4
4
  from contextlib import AbstractAsyncContextManager
5
5
  from dataclasses import dataclass
6
6
  from types import TracebackType
7
- from typing import TYPE_CHECKING, TypeVar
7
+ from typing import TYPE_CHECKING, Any, TypeVar
8
8
 
9
9
  from strif import abbrev_str, single_line
10
10
  from typing_extensions import override
@@ -17,6 +17,7 @@ from rich.progress import BarColumn, Progress, ProgressColumn, Task, TaskID
17
17
  from rich.spinner import Spinner
18
18
  from rich.text import Text
19
19
 
20
+ from kash.config.unified_live import get_unified_live
20
21
  from kash.utils.api_utils.progress_protocol import (
21
22
  EMOJI_FAILURE,
22
23
  EMOJI_RETRY,
@@ -229,7 +230,7 @@ class TruncatedLabelColumn(ProgressColumn):
229
230
  def __init__(self, console_width: int):
230
231
  super().__init__()
231
232
  # Reserve half the console width for labels/status messages
232
- self.max_label_width = console_width // 2
233
+ self.max_label_width: int = console_width // 2
233
234
 
234
235
  @override
235
236
  def render(self, task: Task) -> Text:
@@ -298,6 +299,9 @@ class MultiTaskStatus(AbstractAsyncContextManager):
298
299
  self._next_id: int = 1
299
300
  self._rich_task_ids: dict[int, TaskID] = {} # Map our IDs to Rich Progress IDs
300
301
 
302
+ # Unified live integration
303
+ self._unified_live: Any | None = None # Reference to the global unified live
304
+
301
305
  # Calculate spinner width for consistent spacing
302
306
  self._spinner_width = _get_spinner_width(SPINNER_NAME)
303
307
 
@@ -367,7 +371,13 @@ class MultiTaskStatus(AbstractAsyncContextManager):
367
371
  @override
368
372
  async def __aenter__(self) -> MultiTaskStatus:
369
373
  """Start the live display."""
370
- self._progress.__enter__()
374
+ # Try to integrate with unified live display
375
+
376
+ # Always integrate with unified live display (auto-initialized)
377
+ unified_live = get_unified_live()
378
+ self._unified_live = unified_live
379
+ # Register our progress display with the unified live
380
+ unified_live.set_multitask_display(self._progress)
371
381
  return self
372
382
 
373
383
  @override
@@ -378,9 +388,13 @@ class MultiTaskStatus(AbstractAsyncContextManager):
378
388
  exc_tb: TracebackType | None,
379
389
  ) -> None:
380
390
  """Stop the live display and show automatic summary if enabled."""
381
- self._progress.__exit__(exc_type, exc_val, exc_tb)
391
+ # Always clean up unified live integration
392
+ if self._unified_live is not None:
393
+ # Remove our display from the unified live
394
+ self._unified_live.set_multitask_display(None)
395
+ self._unified_live = None
382
396
 
383
- # Show automatic summary if enabled
397
+ # Show automatic summary if enabled (always print to console now)
384
398
  if self.auto_summary:
385
399
  summary = self.get_summary()
386
400
  self.console.print(summary)
@@ -3,12 +3,22 @@
3
3
  font-size: 16px;
4
4
  /* Adding Hack Nerd Font to all fonts for icon support, if it is installed. */
5
5
  --font-sans: "Source Sans 3 Variable", sans-serif, "Hack Nerd Font";
6
- --font-serif: "PT Serif", serif, "Hack Nerd Font";
6
+ --font-serif: "LocalPunct", "PT Serif", serif, "Hack Nerd Font";
7
7
  /* Source Sans 3 Variable better at these weights. */
8
8
  --font-weight-sans-medium: 550;
9
9
  --font-weight-sans-bold: 650;
10
10
  --font-mono: "Hack Nerd Font", "Menlo", "DejaVu Sans Mono", Consolas, "Lucida Console", monospace;
11
11
 
12
+ --font-features-sans: normal;
13
+ {#
14
+ /* TODO: FontSource builds don't seem to support these. Might be nice to use. */
15
+ --font-features-sans:
16
+ "ss01" 0, /* Flat-tailed l */
17
+ "ss02" 1, /* Single-storey a */
18
+ "ss03" 0, /* Single-storey g */
19
+ "ss04" 0; /* Sans-bar I */
20
+ #}
21
+
12
22
  --font-size-large: 1.2rem;
13
23
  --font-size-normal: 1rem;
14
24
  --font-size-small: 0.95rem;
@@ -17,6 +27,21 @@
17
27
  --font-size-mono-small: 0.75rem;
18
28
  --font-size-mono-tiny: 0.7rem;
19
29
 
30
+ /* Both Source Sans 3 and PT Serif have small caps, so we use this instead of text-transform. */
31
+ --caps-transform: none;
32
+ --caps-caps-variant: all-small-caps;
33
+ --caps-spacing: 0.025em;
34
+ /* Compensate for small caps (Source Sans small caps are quite small) */
35
+ --caps-heading-size-multiplier: 1.42;
36
+ --caps-heading-line-height: calc(1.2 / var(--caps-heading-size-multiplier));
37
+
38
+ {#
39
+ /* Option to handle small caps manually. */
40
+ --caps-transform: uppercase;
41
+ --caps-caps-variant: none;
42
+ --caps-spacing: 0.025em;
43
+ #}
44
+
20
45
  --console-char-width: 88;
21
46
  --console-width: calc(var(--console-char-width) + 2rem);
22
47
  {% endblock root_variables %}
@@ -116,12 +141,7 @@ a:hover {
116
141
  transition: all 0.15s ease-in-out;
117
142
  }
118
143
 
119
- h1,
120
- h2,
121
- h3,
122
- h4,
123
- h5,
124
- h6 {
144
+ h1, h2, h3, h4, h5, h6 {
125
145
  line-height: 1.2;
126
146
  }
127
147
 
@@ -132,7 +152,7 @@ h1 {
132
152
  }
133
153
 
134
154
  h2 {
135
- font-size: 1.42rem;
155
+ font-size: 1.32rem;
136
156
  margin-top: 2rem;
137
157
  margin-bottom: 1rem;
138
158
  }
@@ -152,7 +172,7 @@ h3 {
152
172
  }
153
173
 
154
174
  h4 {
155
- font-size: 1.1rem;
175
+ font-size: 1.12rem;
156
176
  margin-top: 1rem;
157
177
  margin-bottom: 0.7rem;
158
178
  }
@@ -301,13 +321,17 @@ img {
301
321
 
302
322
  details {
303
323
  font-family: var(--font-sans);
324
+ font-feature-settings: var(--font-features-sans);
304
325
  color: var(--color-text);
305
326
 
306
327
  border: 1px solid var(--color-hint-gentle);
307
- border-radius: 3px;
308
328
  margin: 0.75rem 0;
309
329
  }
310
330
 
331
+ details > :not(summary) {
332
+ padding: 0 0.75rem;
333
+ }
334
+
311
335
  summary {
312
336
  color: var(--color-secondary);
313
337
  padding: .5rem 1rem;
@@ -317,9 +341,12 @@ summary {
317
341
  transition: all 0.15s ease-in-out;
318
342
  }
319
343
 
344
+ summary::marker {
345
+ font-size: 0.85rem;
346
+ }
347
+
320
348
  summary:hover {
321
349
  color: var(--color-primary-light);
322
- {# background: var(--color-hover-bg); #}
323
350
  }
324
351
 
325
352
  /* keep the border on the summary when open so it blends */
@@ -332,6 +359,36 @@ summary:focus-visible {
332
359
  outline-offset: 2px;
333
360
  }
334
361
 
362
+ /* Special formatting for document metadata details */
363
+ details.metadata {
364
+ color: var(--color-tertiary);
365
+ }
366
+
367
+ details.metadata > :not(summary) {
368
+ color: var(--color-secondary);
369
+ }
370
+
371
+ details.metadata summary {
372
+ font-weight: 550;
373
+ font-size: calc(var(--font-size-small) * var(--caps-heading-size-multiplier));
374
+ line-height: var(--caps-heading-line-height);
375
+ text-transform: var(--caps-transform);
376
+ font-variant-caps: var(--caps-caps-variant);
377
+ letter-spacing: var(--caps-spacing);
378
+ color: var(--color-tertiary);
379
+ }
380
+
381
+ details.metadata summary:hover {
382
+ color: var(--color-primary-light);
383
+ }
384
+
385
+ details.metadata blockquote {
386
+ border-left: none;
387
+ margin: 0 0.75rem;
388
+ font-size: var(--font-size-small);
389
+ font-style: italic;
390
+ }
391
+
335
392
 
336
393
  hr {
337
394
  border: none;
@@ -375,15 +432,21 @@ hr:before {
375
432
 
376
433
  .long-text h3 {
377
434
  font-family: var(--font-sans);
435
+ font-feature-settings: var(--font-features-sans);
378
436
  font-weight: 550;
379
- text-transform: uppercase;
380
- letter-spacing: 0.025em;
437
+ font-size: calc(1.15rem * var(--caps-heading-size-multiplier));
438
+ line-height: var(--caps-heading-line-height);
439
+ text-transform: var(--caps-transform);
440
+ font-variant-caps: var(--caps-caps-variant);
441
+ letter-spacing: var(--caps-spacing);
381
442
  }
382
443
 
383
444
  .long-text h4 {
384
- font-family: var(--font-serif);
385
- font-weight: 400;
445
+ font-family: var(--font-sans);
446
+ font-feature-settings: var(--font-features-sans);
447
+ font-weight: 540;
386
448
  font-style: italic;
449
+ letter-spacing: 0.015em;
387
450
  }
388
451
 
389
452
  .long-text h5 {
@@ -407,6 +470,7 @@ hr:before {
407
470
 
408
471
  .long-text .sans-text {
409
472
  font-family: var(--font-sans);
473
+ font-feature-settings: var(--font-features-sans);
410
474
  }
411
475
 
412
476
  .long-text .sans-text p {
@@ -416,6 +480,7 @@ hr:before {
416
480
 
417
481
  .long-text .sans-text h1 {
418
482
  font-family: var(--font-sans);
483
+ font-feature-settings: var(--font-features-sans);
419
484
  font-size: 1.75rem;
420
485
  font-weight: 380;
421
486
  margin-top: 1rem;
@@ -424,6 +489,7 @@ hr:before {
424
489
 
425
490
  .long-text .sans-text h2 {
426
491
  font-family: var(--font-sans);
492
+ font-feature-settings: var(--font-features-sans);
427
493
  font-size: 1.25rem;
428
494
  font-weight: 440;
429
495
  margin-top: 1rem;
@@ -432,10 +498,13 @@ hr:before {
432
498
 
433
499
  .long-text .sans-text h3 {
434
500
  font-family: var(--font-sans);
435
- font-size: 1.1rem;
501
+ font-feature-settings: var(--font-features-sans);
436
502
  font-weight: var(--font-weight-sans-bold);
437
- text-transform: uppercase;
438
- letter-spacing: 0.03em;
503
+ font-size: calc(1.1rem * var(--caps-heading-size-multiplier));
504
+ line-height: var(--caps-heading-line-height);
505
+ text-transform: var(--caps-transform);
506
+ font-variant-caps: var(--caps-caps-variant);
507
+ letter-spacing: var(--caps-spacing);
439
508
  margin-top: 1rem;
440
509
  margin-bottom: 0.8rem;
441
510
  }
@@ -448,6 +517,7 @@ table, th, td, tbody tr {
448
517
 
449
518
  table {
450
519
  font-family: var(--font-sans);
520
+ font-feature-settings: var(--font-features-sans);
451
521
  font-size: var(--font-size-small);
452
522
  width: auto;
453
523
  margin-left: auto;
@@ -460,10 +530,12 @@ table {
460
530
 
461
531
  th {
462
532
  font-weight: var(--font-weight-sans-bold);
463
- text-transform: uppercase;
464
- letter-spacing: 0.03em;
533
+ font-size: calc(var(--font-size-small) * var(--caps-heading-size-multiplier));
534
+ line-height: var(--caps-heading-line-height);
535
+ text-transform: var(--caps-transform);
536
+ font-variant-caps: var(--caps-caps-variant);
537
+ letter-spacing: var(--caps-spacing);
465
538
  border-bottom: 1px solid var(--color-border-hint);
466
- line-height: 1.2;
467
539
  background-color: var(--color-bg-alt-solid);
468
540
  }
469
541
 
@@ -527,12 +599,16 @@ sup {
527
599
 
528
600
  .footnote-ref a, .footnote {
529
601
  font-family: var(--font-sans);
602
+ font-feature-settings: var(--font-features-sans);
603
+ background-color: var(--color-bg-meta-solid);
604
+ color: var(--color-hint-strong);
530
605
  text-decoration: none;
531
606
  padding: 0 0.15rem;
532
- border-radius: 4px;
607
+ margin-right: 0.15rem;
608
+ border-radius: 6px;
533
609
  transition: all 0.15s ease-in-out;
534
610
  font-style: normal;
535
- font-weight: 500;
611
+ font-weight: 600;
536
612
  }
537
613
 
538
614
  .footnote-ref a:hover, .footnote:hover {
@@ -540,8 +616,257 @@ sup {
540
616
  color: var(--color-primary-light);
541
617
  text-decoration: none;
542
618
  }
619
+
620
+ @media print {
621
+ sup {
622
+ font-size: 70% !important;
623
+ }
624
+
625
+ /* Don't use stylized footnotes in print. */
626
+ .footnote-ref a, .footnote {
627
+ font-family: var(--font-serif);
628
+ font-feature-settings: normal !important;
629
+ background-color: transparent !important;
630
+ color: var(--color-text) !important;
631
+ padding: 0 0.05rem !important;
632
+ font-weight: 400 !important;
633
+ }
634
+
635
+ .footnote-ref a:hover, .footnote:hover {
636
+ background-color: transparent !important;
637
+ color: var(--color-text) !important;
638
+ }
639
+
640
+ /* Hide footnote return arrows/links in print */
641
+ .footnote-backref,
642
+ .footnote-return,
643
+ a[href^="#fnref"],
644
+ .reversefootnote {
645
+ display: none !important;
646
+ }
647
+
648
+ /* Also hide common up arrow characters */
649
+ .footnote::after[content*="↩"],
650
+ .footnote::after[content*="↑"] {
651
+ display: none !important;
652
+ }
653
+ }
654
+
543
655
  {% endblock footnote_styles %}
544
656
 
657
+ {% block print_styles %}
658
+ /* Print media adjustments for better readability and layout */
659
+ @media print {
660
+ @page {
661
+ /* Set page margins for physical page */
662
+ margin: 0.7in 0.95in 0.8in 0.95in;
663
+
664
+ @top-center {
665
+ content: "";
666
+ }
667
+
668
+ @bottom-left {
669
+ content: "Formatted by Kash — github.com/jlevy/kash";
670
+ font-family: var(--font-sans) !important;
671
+ font-size: var(--font-size-small);
672
+ color: var(--color-tertiary) !important;
673
+ margin: 0 0 0.2in 0 !important;
674
+ }
675
+
676
+ @bottom-right {
677
+ content: counter(page);
678
+ font-family: var(--font-sans) !important;
679
+ font-size: var(--font-size-small);
680
+ color: var(--color-tertiary) !important;
681
+ margin: 0 0 0.2in 0 !important;
682
+ }
683
+ }
684
+
685
+ :root {
686
+ /* Slightly larger fonts for print readability */
687
+ --font-size-normal: 1.1rem;
688
+ --font-size-small: 1.0rem;
689
+ --font-size-smaller: 0.9rem;
690
+ --font-size-mono: 0.9rem;
691
+ }
692
+
693
+ body {
694
+ /* Remove body margin since @page handles it */
695
+ margin: 0;
696
+ /* Ensure good line height for print */
697
+ line-height: 1.6;
698
+ }
699
+
700
+ /* Enable hyphenation and justification for main text content only */
701
+ p, blockquote {
702
+ hyphens: auto;
703
+ text-align: justify;
704
+ }
705
+
706
+
707
+
708
+ .long-text {
709
+ /* Remove shadows and borders that don't work well in print */
710
+ box-shadow: none !important;
711
+ border: none !important;
712
+ /* Add print-specific margins */
713
+ padding: 0;
714
+ }
715
+
716
+ /* Ensure tables don't break layout in print */
717
+ .table-container {
718
+ position: static;
719
+ transform: none;
720
+ left: auto;
721
+ width: 100%;
722
+ max-width: 100%;
723
+ overflow: visible;
724
+ }
725
+
726
+ table {
727
+ width: 100%;
728
+ max-width: 100%;
729
+ font-size: 0.9rem;
730
+ }
731
+
732
+ /* Adjust code blocks for print */
733
+ pre {
734
+ white-space: pre-wrap;
735
+ word-wrap: break-word;
736
+ }
737
+
738
+ /* Hide interactive elements that don't work in print */
739
+ .code-copy-button {
740
+ display: none !important;
741
+ }
742
+
743
+ /* Page break controls */
744
+ h1, h2, h3, h4, h5, h6 {
745
+ break-after: avoid;
746
+ page-break-after: avoid; /* Fallback for older browsers */
747
+ break-inside: avoid;
748
+ page-break-inside: avoid; /* Fallback for older browsers */
749
+ }
750
+
751
+ /* Avoid breaking these elements */
752
+ blockquote, pre, .code-block-wrapper, figure, .table-container {
753
+ break-inside: avoid;
754
+ page-break-inside: avoid; /* Fallback for older browsers */
755
+ }
756
+
757
+ /* Control text flow */
758
+ p {
759
+ orphans: 3; /* Minimum lines at bottom of page */
760
+ widows: 3; /* Minimum lines at top of page */
761
+ }
762
+
763
+ /* Keep list items together when reasonable */
764
+ li {
765
+ break-inside: avoid-page;
766
+ page-break-inside: avoid; /* Fallback for older browsers */
767
+ }
768
+
769
+ /* Hide doc metadata details in print (for now) */
770
+ details.metadata {
771
+ display: none !important;
772
+ }
773
+
774
+ /* XXX: long endnote lists cut off the numbers. This is a workaround:
775
+ * Custom numbering system for ordered lists using Grid for alignment */
776
+ ol {
777
+ list-style: none;
778
+ counter-reset: list-counter;
779
+ padding-left: 0;
780
+ margin-left: 1rem;
781
+ }
782
+
783
+ ol > li {
784
+ display: grid;
785
+ /* col 1: fixed width for numbers up to 999. col 2: for the content */
786
+ grid-template-columns: 2.5rem 1fr;
787
+ gap: 0 0.5rem; /* Space between number and content */
788
+ align-items: baseline; /* Aligns number with first line of text */
789
+ counter-increment: list-counter;
790
+ /* Override global li styles for grid layout */
791
+ margin-top: 0;
792
+ margin-bottom: 0.5rem;
793
+ position: static;
794
+ }
795
+
796
+ ol > li::before {
797
+ content: counter(list-counter) ".";
798
+ text-align: right;
799
+ font-family: var(--font-serif);
800
+ }
801
+
802
+ /* Place all direct children of li into the second grid column */
803
+ /* This makes p, ul, etc. stack vertically as intended */
804
+ ol > li > * {
805
+ grid-column: 2;
806
+ word-break: break-word;
807
+ overflow-wrap: break-word;
808
+ hyphens: auto;
809
+ }
810
+
811
+ /* Override justification for content within list items */
812
+ ol > li p,
813
+ ol > li blockquote {
814
+ text-align: left !important;
815
+ text-justify: none !important;
816
+ margin-top: 0; /* Tighter spacing inside list items */
817
+ }
818
+
819
+ /* --- Reset for Nested Lists --- */
820
+ /* This prevents nested lists from inheriting the grid layout */
821
+ ol ol {
822
+ margin-left: 1.5rem;
823
+ margin-top: 0.5rem;
824
+ list-style: decimal outside;
825
+ counter-reset: initial; /* Don't inherit parent counter */
826
+ }
827
+
828
+ ol ul {
829
+ margin-left: 1.5rem;
830
+ margin-top: 0.5rem;
831
+ list-style: none; /* We'll restore bullets with ::before */
832
+ }
833
+
834
+ ol ol > li {
835
+ display: list-item !important; /* Revert from grid to standard list item */
836
+ padding-left: 0.25rem;
837
+ margin-top: 0.4rem; /* Override global li margin for tighter spacing */
838
+ position: static; /* Override global li position */
839
+ /* Override justification from parent rules */
840
+ text-align: left !important;
841
+ text-justify: none !important;
842
+ }
843
+
844
+ ol ul > li {
845
+ display: list-item !important; /* Revert from grid to standard list item */
846
+ padding-left: 0.25rem;
847
+ margin-top: 0.4rem; /* Override global li margin for tighter spacing */
848
+ position: relative; /* Need relative positioning for bullet ::before */
849
+ /* Override justification from parent rules */
850
+ text-align: left !important;
851
+ text-justify: none !important;
852
+ }
853
+
854
+ /* Remove the custom counter from nested ol */
855
+ ol ol > li::before {
856
+ content: "" !important;
857
+ }
858
+
859
+ /* Restore bullets for nested ul within ol */
860
+ ol ul > li::before {
861
+ content: "▪︎";
862
+ position: absolute;
863
+ left: -.85rem;
864
+ top: .25rem;
865
+ font-size: 0.62rem;
866
+ }
867
+ }
868
+ {% endblock print_styles %}
869
+
545
870
  {% block responsive_styles %}
546
871
  /* Bleed wide on larger screens. */
547
872
  /* TODO: Don't make so wide if table itself isn't large? */
@@ -152,6 +152,17 @@
152
152
  src: url(https://cdn.jsdelivr.net/fontsource/fonts/pt-serif@latest/latin-700-italic.woff2) format('woff2');
153
153
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
154
154
  }
155
+ /* PT Serif has a known bug with quote alignment that can look quite ugly.
156
+ * https://nedbatchelder.com/blog/201809/fixing_pt_serif.html?utm_source=chatgpt.com
157
+ * So we use a workaround to use punctuation from a different local font.
158
+ * After trying a few, it seems Georgia is workable for quote marks an non-oriented ASCII quotes. */
159
+ @font-face {
160
+ font-family: 'LocalPunct';
161
+ src: local('Georgia');
162
+ unicode-range:
163
+ U+0022, U+0027, /* " ' */
164
+ U+2018, U+2019, U+201C, U+201D; /* ‘ ’ “ ” */
165
+ }
155
166
  /* https://fontsource.org/fonts/source-sans-3/cdn */
156
167
  /* source-sans-3-latin-wght-normal */
157
168
  @font-face {