cctally 1.93.0 → 1.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/_lib_share.py CHANGED
@@ -8,15 +8,18 @@ Spec: docs/superpowers/specs/2026-05-08-shareable-reports-design.md
8
8
  from __future__ import annotations
9
9
 
10
10
  import base64
11
+ import calendar
11
12
  import dataclasses
12
13
  import hashlib
13
14
  import json
14
15
  import math
15
16
  import re
17
+ import unicodedata
16
18
  from collections.abc import Callable, Mapping, Sequence
17
19
  from dataclasses import dataclass, field
18
- from datetime import datetime
20
+ from datetime import datetime, timezone
19
21
  from typing import Literal
22
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
20
23
 
21
24
 
22
25
  # --- Version + digest ---
@@ -107,10 +110,51 @@ class Totalled:
107
110
 
108
111
  @dataclass(frozen=True)
109
112
  class PeriodSpec:
113
+ """The window an artifact covers, and the zone its dates are stated in.
114
+
115
+ `display_tz` is a concrete IANA zone name. The configuration TOKENS
116
+ `local` and `utc` are resolved by the entry point that builds the
117
+ snapshot and must never reach here (#503 S2 D7).
118
+
119
+ `civil_bucket` discriminates the two kinds of boundary this field
120
+ carries. False (the default) means `start`/`end` are real instants, so
121
+ the civil dates an artifact states are obtained by converting them into
122
+ `display_tz`. True means they are ALREADY civil calendar labels — a
123
+ `daily` bucket named `2026-05-04`, lifted to a UTC-midnight sentinel —
124
+ and converting one shifts it by a day in every zone west of UTC.
125
+ """
110
126
  start: datetime
111
127
  end: datetime
112
128
  display_tz: str
113
129
  label: str
130
+ civil_bucket: bool = False
131
+
132
+
133
+ def period_civil_dates(period: PeriodSpec) -> tuple[str, str]:
134
+ """Return `(start_civil, end_civil)` as ISO dates in `period.display_tz`.
135
+
136
+ The one place a `PeriodSpec` becomes the pair of dates an artifact
137
+ states about itself. Never reads `period.label`: every dashboard
138
+ builder sets a SEMANTIC label (`This week`, `Last 8 weeks`, `Recent
139
+ sessions`), and none of them names a date.
140
+
141
+ A `civil_bucket` period is returned verbatim, because its boundaries
142
+ are already calendar labels; anything else is converted into the
143
+ labelled zone, because its boundaries are instants.
144
+
145
+ An unloadable zone name falls back to the datetimes as they are rather
146
+ than raising. `_verify_output` turns a raise inside a renderer into a
147
+ refused artifact, so a stale label must degrade to a slightly-wrong
148
+ date rather than an outage.
149
+ """
150
+ if period.civil_bucket:
151
+ return period.start.date().isoformat(), period.end.date().isoformat()
152
+ try:
153
+ zone = ZoneInfo(period.display_tz)
154
+ except (ZoneInfoNotFoundError, ValueError, OSError):
155
+ return period.start.date().isoformat(), period.end.date().isoformat()
156
+ return (period.start.astimezone(zone).date().isoformat(),
157
+ period.end.astimezone(zone).date().isoformat())
114
158
 
115
159
 
116
160
  # --- Chart primitives ---
@@ -457,21 +501,174 @@ _PADDING_BOTTOM = 30 # x-tick labels
457
501
  _PADDING_TOP = 10
458
502
  _PADDING_RIGHT = 10
459
503
 
460
- _HBAR_LABEL_GUTTER = 120.0 # left gutter for project labels (anonymized fits; long revealed labels may overflow)
504
+ # The MINIMUM left gutter for hbar project labels. It used to be the
505
+ # only gutter, so a revealed `risk-analysis-toolkit-2026` started 36px
506
+ # left of the canvas and was clipped (#503 S2 F16).
507
+ _HBAR_LABEL_GUTTER = 120.0
461
508
  _HBAR_RIGHT_PAD = 10.0 # right-side breathing room for value labels
509
+ _HBAR_LABEL_PAD = 4.0 # gap between a gutter label and its bar
510
+ _HBAR_VALUE_PAD = 4.0 # gap between a bar's end and its value label
511
+ _HBAR_LABEL_FONT = 11.0
512
+ _HBAR_VALUE_FONT = 10.0
513
+
514
+ # Distance from the plot's left edge back to the end-anchored y-axis
515
+ # label. With no reservation for the label's own width, `projected %`
516
+ # starts at -6px in every shipped forecast SVG.
517
+ _Y_AXIS_LABEL_GAP = 10.0
518
+ _Y_AXIS_LABEL_FONT = 10.0
462
519
 
463
520
 
464
521
  def _chart_inner_box(
465
522
  x: float, y: float, width: float, height: float,
523
+ pad_left: float = _PADDING_LEFT,
466
524
  ) -> tuple[float, float, float, float]:
467
- """Compute (ix, iy, iw, ih) — the inner plot area inside chart padding."""
468
- ix = x + _PADDING_LEFT
525
+ """Compute (ix, iy, iw, ih) — the inner plot area inside chart padding.
526
+
527
+ `pad_left` defaults to `_PADDING_LEFT` so every existing caller is
528
+ unchanged; the line and bar renderers widen it when their y-axis
529
+ label needs more room than 50px.
530
+ """
531
+ ix = x + pad_left
469
532
  iy = y + _PADDING_TOP
470
- iw = width - _PADDING_LEFT - _PADDING_RIGHT
533
+ iw = width - pad_left - _PADDING_RIGHT
471
534
  ih = height - _PADDING_TOP - _PADDING_BOTTOM
472
535
  return ix, iy, iw, ih
473
536
 
474
537
 
538
+ def _y_axis_pad_left(chart: "ChartSpec") -> float:
539
+ """Left padding wide enough to hold the y-axis label inside the plot.
540
+
541
+ The label is end-anchored at `ix - _Y_AXIS_LABEL_GAP`, so it needs
542
+ `gap + its own width` of padding. Never narrower than `_PADDING_LEFT`,
543
+ which is what the axis tick labels already assume.
544
+ """
545
+ label = getattr(chart, "y_label", "") or ""
546
+ if not label:
547
+ return _PADDING_LEFT
548
+ return max(_PADDING_LEFT,
549
+ _Y_AXIS_LABEL_GAP + _svg_text_width(label, _Y_AXIS_LABEL_FONT))
550
+
551
+
552
+ def _hbar_visible_points(chart: "HorizontalBarChart") -> tuple:
553
+ return chart.points[:chart.cap] if chart.cap is not None else chart.points
554
+
555
+
556
+ def _hbar_label_gutter(points) -> float:
557
+ """Left gutter wide enough for the widest gutter label."""
558
+ if not points:
559
+ return _HBAR_LABEL_GUTTER
560
+ widest = max(_svg_text_width(p.x_label, _HBAR_LABEL_FONT) for p in points)
561
+ return max(_HBAR_LABEL_GUTTER, widest + _HBAR_LABEL_PAD)
562
+
563
+
564
+ def _hbar_value_text(point: "ChartPoint") -> str:
565
+ """The value label a bar carries — one definition, so the width the
566
+ layout reserves and the string the renderer emits cannot diverge."""
567
+ return f"${point.y_value:,.2f}"
568
+
569
+
570
+ def _hbar_right_reserve(points) -> float:
571
+ """Right reserve wide enough for the widest value label plus padding."""
572
+ if not points:
573
+ return _HBAR_RIGHT_PAD
574
+ widest = max(_svg_text_width(_hbar_value_text(p), _HBAR_VALUE_FONT)
575
+ for p in points)
576
+ return _HBAR_VALUE_PAD + widest + _HBAR_RIGHT_PAD
577
+
578
+
579
+ def chart_required_width(chart: "ChartSpec | None", *,
580
+ nominal_width: float) -> float:
581
+ """The canvas width this chart needs so no label leaves the plot.
582
+
583
+ The canvas WIDENS; bars are not shrunk and labels are not truncated
584
+ (#503 S2 F16). The nominal plot width is preserved by adding exactly
585
+ the extra gutter and reserve the labels require on top of it.
586
+ """
587
+ if chart is None:
588
+ return nominal_width
589
+ if isinstance(chart, HorizontalBarChart):
590
+ points = _hbar_visible_points(chart)
591
+ if not points:
592
+ return nominal_width
593
+ nominal_plot_w = (
594
+ nominal_width - _HBAR_LABEL_GUTTER - _HBAR_RIGHT_PAD)
595
+ required = (_hbar_label_gutter(points) + nominal_plot_w
596
+ + _hbar_right_reserve(points))
597
+ return max(nominal_width, required)
598
+ return nominal_width + max(0.0, _y_axis_pad_left(chart) - _PADDING_LEFT)
599
+
600
+
601
+ _AXIS_TICK_MIN_GAP = 4.0
602
+
603
+
604
+ def _axis_tick_box(x: float, anchor: str, text: str,
605
+ font_size: float) -> tuple[float, float]:
606
+ """The horizontal extent one x-tick label occupies."""
607
+ width = _svg_text_width(text, font_size)
608
+ if anchor == "end":
609
+ return x - width, x
610
+ if anchor == "middle":
611
+ return x - width / 2.0, x + width / 2.0
612
+ return x, x + width
613
+
614
+
615
+ def _visible_axis_ticks(ticks: "Sequence[tuple[float, str, str, float]]") -> set:
616
+ """Indices of the x-tick labels that fit without overprinting.
617
+
618
+ F16 widened the canvas so no chart label leaves the plot, and left
619
+ the other half of the same rule unaddressed: a label can sit inside
620
+ the viewBox and still print on top of its neighbour. In the `blocks`
621
+ templates the ticks are full ISO timestamps 102.3 units wide at 108.0
622
+ units of centre-to-centre spacing, which leaves 5.7 units between
623
+ consecutive interior labels — but the LAST tick is anchored inward at
624
+ the plot's right edge, so that one pair overlapped by 45.4 units and
625
+ rendered as one unreadable string (#503 S2 review M4). One pair, not
626
+ every pair: the earlier account of this conflated the last pair's
627
+ 56-unit spacing with the general spacing, and the corrected figures
628
+ are re-measured against the current glyph table (#503 S2 second
629
+ review N8).
630
+
631
+ Widening the canvas cannot fix this: tick spacing is a fraction of
632
+ the plot width, so fitting sixteen ISO dates would need a canvas far
633
+ past the 680px page the HTML artifact is laid out in. Dropping the
634
+ labels that do not fit is the conventional axis answer, and the row
635
+ data the ticks index is in the table beside the chart.
636
+
637
+ THE LEFTMOST AND RIGHTMOST labels are the ones that name the window
638
+ the chart covers, so the rightmost is reserved before the interior
639
+ ticks are walked. It is DROPPED — leaving a single-label axis — when
640
+ it cannot coexist with the leftmost, which is what two or three tight
641
+ ISO ticks produce. Describing both ends as reserved would overstate
642
+ the guarantee.
643
+
644
+ Ticks are swept in left-to-right order rather than in the order the
645
+ caller supplies them. `_render_line_chart_svg` does not sort its
646
+ points, so an unsorted series made the sweep drop the actual leftmost
647
+ label while keeping a later one.
648
+ """
649
+ if len(ticks) <= 1:
650
+ return set(range(len(ticks)))
651
+ boxes = [_axis_tick_box(*tick) for tick in ticks]
652
+ # By the tick's own axis position, not by its box: an end-anchored
653
+ # edge tick starts further left than the sample before it, and
654
+ # ordering by box would call it the leftmost label.
655
+ order = sorted(range(len(ticks)), key=lambda i: (ticks[i][0], i))
656
+ first, last = order[0], order[-1]
657
+ keep = {first}
658
+ if boxes[first][1] + _AXIS_TICK_MIN_GAP <= boxes[last][0]:
659
+ keep.add(last)
660
+ limit = boxes[last][0] - _AXIS_TICK_MIN_GAP
661
+ else:
662
+ limit = float("inf")
663
+ prev_right = boxes[first][1]
664
+ for index in order[1:-1]:
665
+ left, right = boxes[index]
666
+ if left >= prev_right + _AXIS_TICK_MIN_GAP and right <= limit:
667
+ keep.add(index)
668
+ prev_right = right
669
+ return keep
670
+
671
+
475
672
  def _scale_y(
476
673
  values: list[float], ih: float,
477
674
  ) -> tuple[float, Callable[[float], float]]:
@@ -502,7 +699,8 @@ def _render_chart_no_data(palette: Mapping[str, str], *,
502
699
  # Line chart.
503
700
  def _render_line_chart_svg(chart: LineChart, *, palette: dict,
504
701
  x: float, y: float, width: float, height: float) -> str:
505
- ix, iy, iw, ih = _chart_inner_box(x, y, width, height)
702
+ ix, iy, iw, ih = _chart_inner_box(x, y, width, height,
703
+ pad_left=_y_axis_pad_left(chart))
506
704
  pts = chart.points
507
705
  if not pts:
508
706
  return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
@@ -582,11 +780,17 @@ def _render_line_chart_svg(chart: LineChart, *, palette: dict,
582
780
  # projected ray extending past it via multi_series — keeps that tick
583
781
  # centered rather than mis-anchoring a non-edge label.
584
782
  right_edge_x = ix + iw
783
+ ticks = []
585
784
  for p in pts:
586
785
  tx = ix + scale_x(p.x_value)
587
786
  anchor = "end" if tx >= right_edge_x - 1e-6 else "middle"
588
- elements.append(svg_text(tx, iy + ih + 14, p.x_label,
589
- font_size=10, fill=palette["muted"], anchor=anchor))
787
+ ticks.append((tx, anchor, p.x_label, 10.0))
788
+ # Thinned, not truncated (#503 S2 review M4) — see `_visible_axis_ticks`.
789
+ for index in sorted(_visible_axis_ticks(ticks)):
790
+ tx, anchor, label, size = ticks[index]
791
+ elements.append(svg_text(tx, iy + ih + 14, label,
792
+ font_size=size, fill=palette["muted"],
793
+ anchor=anchor))
590
794
 
591
795
  # Y-axis label.
592
796
  elements.append(svg_text(ix - 10, iy + ih / 2, chart.y_label,
@@ -598,7 +802,8 @@ def _render_line_chart_svg(chart: LineChart, *, palette: dict,
598
802
  # Bar chart (vertical).
599
803
  def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
600
804
  x: float, y: float, width: float, height: float) -> str:
601
- ix, iy, iw, ih = _chart_inner_box(x, y, width, height)
805
+ ix, iy, iw, ih = _chart_inner_box(x, y, width, height,
806
+ pad_left=_y_axis_pad_left(chart))
602
807
  pts = chart.points
603
808
  if not pts:
604
809
  return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
@@ -636,6 +841,14 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
636
841
 
637
842
  series_palette = palette["series_palette"]
638
843
 
844
+ # Tick geometry is decided before the bars are drawn so the thinning
845
+ # decision sees every label at once (#503 S2 review M4).
846
+ ticks = [
847
+ (ix + i * (bar_w + bar_gap) + bar_w / 2, "middle", p.x_label, 10.0)
848
+ for i, p in enumerate(pts)
849
+ ]
850
+ visible_ticks = _visible_axis_ticks(ticks)
851
+
639
852
  for i, p in enumerate(pts):
640
853
  bx = ix + i * (bar_w + bar_gap)
641
854
  if has_stacks:
@@ -659,9 +872,10 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
659
872
  bh = (iy + ih) - by
660
873
  elements.append(svg_rect(bx, by, bar_w, bh, fill=palette["series_primary"]))
661
874
  # X-tick label centered under bar.
662
- tx = bx + bar_w / 2
663
- elements.append(svg_text(tx, iy + ih + 14, p.x_label,
664
- font_size=10, fill=palette["muted"], anchor="middle"))
875
+ if i in visible_ticks:
876
+ elements.append(svg_text(ticks[i][0], iy + ih + 14, p.x_label,
877
+ font_size=10, fill=palette["muted"],
878
+ anchor="middle"))
665
879
 
666
880
  # Legend (top-right of inner box, only when stacks are present).
667
881
  # SVG is the only artifact where the table doesn't double as a key, so
@@ -694,16 +908,22 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
694
908
  # Horizontal bar chart (top-N with cap).
695
909
  def _render_hbar_chart_svg(chart: HorizontalBarChart, *, palette: dict,
696
910
  x: float, y: float, width: float, height: float) -> str:
697
- pts = chart.points
698
- if chart.cap is not None:
699
- pts = pts[:chart.cap]
911
+ pts = _hbar_visible_points(chart)
700
912
  if not pts:
701
913
  return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
702
914
 
703
- label_w = _HBAR_LABEL_GUTTER
915
+ # Both edges are MEASURED (#503 S2 F16). The gutter was a fixed 120px,
916
+ # which clipped any revealed project label wider than that, and the
917
+ # right side reserved a fixed 10px, which was narrower than every
918
+ # value label the chart emits — so even `$0.01` ran off the canvas.
919
+ # `_render_svg` widens the canvas by the same amounts through
920
+ # `chart_required_width`, so the plot keeps its nominal width and the
921
+ # bars are not shrunk to pay for the labels.
922
+ label_w = _hbar_label_gutter(pts)
923
+ right_reserve = _hbar_right_reserve(pts)
704
924
  ix = x + label_w
705
925
  iy = y + 6
706
- iw = width - label_w - _HBAR_RIGHT_PAD
926
+ iw = width - label_w - right_reserve
707
927
  ih = height - 12
708
928
 
709
929
  n = len(pts)
@@ -720,13 +940,15 @@ def _render_hbar_chart_svg(chart: HorizontalBarChart, *, palette: dict,
720
940
  ry = iy + i * row_h + bar_gap
721
941
  bw = (p.y_value / x_max) * iw
722
942
  elements.append(svg_rect(ix, ry, bw, bar_h, fill=palette["series_primary"]))
723
- # Label gutter (right-aligned to ix - 4).
724
- elements.append(svg_text(ix - 4, ry + bar_h / 2 + 3, p.x_label,
725
- font_size=11, fill=palette["fg"], anchor="end"))
943
+ # Label gutter (right-aligned to the bar's left edge).
944
+ elements.append(svg_text(ix - _HBAR_LABEL_PAD, ry + bar_h / 2 + 3,
945
+ p.x_label, font_size=_HBAR_LABEL_FONT,
946
+ fill=palette["fg"], anchor="end"))
726
947
  # Value label at end of bar.
727
- elements.append(svg_text(ix + bw + 4, ry + bar_h / 2 + 3,
728
- f"${p.y_value:,.2f}",
729
- font_size=10, fill=palette["muted"], anchor="start"))
948
+ elements.append(svg_text(ix + bw + _HBAR_VALUE_PAD, ry + bar_h / 2 + 3,
949
+ _hbar_value_text(p),
950
+ font_size=_HBAR_VALUE_FONT,
951
+ fill=palette["muted"], anchor="start"))
730
952
 
731
953
  return svg_group(elements)
732
954
 
@@ -744,46 +966,104 @@ def _version_label(version: str) -> str:
744
966
  return f"v{version}" if version else "dev"
745
967
 
746
968
 
969
+ _SVG_HEADER_ROW_H = 18.0
970
+
971
+
972
+ def _svg_header_rows(snap: ShareSnapshot, *,
973
+ shows_table: bool) -> "list[tuple[str, float]]":
974
+ """The `(text, font_size)` rows stacked under the SVG title.
975
+
976
+ A FLOW, not fixed slots. The subtitle used to be pinned at `y + 36`
977
+ and the provider line at `y + 54`, so a snapshot with a provider and
978
+ no subtitle rendered a blank 18px band between them — and the #503 S2
979
+ subtitle cleanup makes exactly that shape the common one. Stacking the
980
+ rows in order keeps the header tight whichever of them are present,
981
+ and `_svg_header_height` counts the same list.
982
+
983
+ The facts strip is always the last row, because it is unconditional.
984
+ """
985
+ rows: list[tuple[str, float]] = []
986
+ if snap.subtitle:
987
+ rows.append((snap.subtitle, 12.0))
988
+ source_label, availability = _source_chrome(snap)
989
+ if source_label:
990
+ rows.append((
991
+ source_label if availability is None
992
+ else f"{source_label} · {availability}",
993
+ 10.0,
994
+ ))
995
+ rows.append((share_facts_line(snap, shows_chart=snap.chart is not None,
996
+ shows_table=shows_table), 10.0))
997
+ return rows
998
+
999
+
747
1000
  def _render_svg_header(snap: ShareSnapshot, *, palette: dict,
748
- x: float, y: float, width: float) -> str:
1001
+ x: float, y: float, width: float,
1002
+ shows_table: bool) -> str:
749
1003
  elements = []
750
1004
  elements.append(svg_text(x, y + 18, snap.title,
751
1005
  font_size=18, fill=palette["fg"], weight="bold"))
752
- if snap.subtitle:
753
- elements.append(svg_text(x, y + 36, snap.subtitle,
754
- font_size=12, fill=palette["muted"]))
755
- source_label, availability = _source_chrome(snap)
756
- if source_label:
757
- source_text = source_label if availability is None else f"{source_label} · {availability}"
758
- elements.append(svg_text(x, y + 54, source_text,
759
- font_size=10, fill=palette["muted"]))
1006
+ baseline = y + 18
1007
+ for text, font_size in _svg_header_rows(snap, shows_table=shows_table):
1008
+ baseline += _SVG_HEADER_ROW_H
1009
+ elements.append(svg_text(x, baseline, text,
1010
+ font_size=font_size, fill=palette["muted"]))
760
1011
  elements.append(svg_text(x + width, y + 18,
761
1012
  _format_generated_at_iso(snap.generated_at),
762
1013
  font_size=10, fill=palette["muted"], anchor="end"))
763
1014
  return svg_group(elements)
764
1015
 
765
1016
 
766
- def _svg_header_height(snap: ShareSnapshot, *, include_chrome: bool) -> float:
767
- """Reserve the added source/availability line without perturbing v1 SVGs."""
1017
+ def _svg_header_height(snap: ShareSnapshot, *, include_chrome: bool,
1018
+ shows_table: bool) -> float:
1019
+ """Reserve one band per row `_render_svg_header` actually emits.
1020
+
1021
+ `_SVG_HEADER_H` covers the title plus ONE stacked row; every further
1022
+ row adds another band. A height that under-counts puts the last row
1023
+ below the declared canvas, where it is invisible.
1024
+ """
768
1025
  if not include_chrome:
769
1026
  return 0.0
770
- source_label, _availability = _source_chrome(snap)
771
- return _SVG_HEADER_H + (18.0 if source_label else 0.0)
1027
+ extra_rows = max(0, len(_svg_header_rows(
1028
+ snap, shows_table=shows_table)) - 1)
1029
+ return _SVG_HEADER_H + _SVG_HEADER_ROW_H * extra_rows
1030
+
1031
+
1032
+ def _attribution_text(version: str) -> str:
1033
+ """The one provenance sentence a branded artifact carries.
1034
+
1035
+ Shared by the standalone SVG footer and the composed SVG footer
1036
+ (#503 S2 review F7): the composed one read the bare `cctally ·
1037
+ composed`, so a composed artifact dropped the project and the version
1038
+ a standalone one states. `--no-branding` removes the whole footer in
1039
+ both, which is D2's split for HTML and SVG — there the footer IS the
1040
+ advertisement, and the provenance a reader needs (period, zone,
1041
+ privacy mode, generated-at) is in each section's facts strip.
1042
+ """
1043
+ return ("Generated by cctally · github.com/omrikais/cctally · "
1044
+ + _version_label(version))
772
1045
 
773
1046
 
774
1047
  def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
775
1048
  x: float, y: float, width: float, branding: bool) -> str:
776
1049
  if not branding:
777
1050
  return ""
778
- label = (
779
- "Generated by cctally · github.com/omrikais/cctally · "
780
- + _version_label(snap.version)
781
- )
1051
+ label = _attribution_text(snap.version)
782
1052
  return svg_group([
783
1053
  svg_text(x, y, label, font_size=10, fill=palette["footer_link"]),
784
1054
  ])
785
1055
 
786
1056
 
1057
+ def _provider_label(snap: ShareSnapshot) -> str:
1058
+ """The authoritative visible provider name for a snapshot.
1059
+
1060
+ Factored out of `_source_chrome` so `compose()` can put it in a
1061
+ section heading (#503 S2 D6) without a second definition of which
1062
+ string names the provider.
1063
+ """
1064
+ return snap.source_label or ("Claude" if snap.source == "claude" else "Codex")
1065
+
1066
+
787
1067
  def _source_chrome(snap: ShareSnapshot) -> tuple[str | None, str | None]:
788
1068
  """Return privacy-safe provider and availability text for new snapshots.
789
1069
 
@@ -795,7 +1075,7 @@ def _source_chrome(snap: ShareSnapshot) -> tuple[str | None, str | None]:
795
1075
  or snap.source_label is not None
796
1076
  or snap.availability != "ok"
797
1077
  )
798
- label = snap.source_label or ("Claude" if snap.source == "claude" else "Codex")
1078
+ label = _provider_label(snap)
799
1079
  status = (
800
1080
  "No data" if snap.availability == "empty" else
801
1081
  f"Unavailable: {snap.availability_reason}"
@@ -1981,9 +2261,278 @@ def _encode_probe_identity_key() -> str:
1981
2261
  return "v1." + base64.urlsafe_b64encode(canonical).decode("ascii").rstrip("=")
1982
2262
 
1983
2263
 
2264
+ # --- The provenance facts strip (#503 S2 D1/D5) ---
2265
+
2266
+ # A displayed value counts as a date only when the WHOLE of it is one.
2267
+ # Anchored deliberately: a substring match would read a date out of a
2268
+ # title such as `Weekly recap — week of 2026-05-04`, which names what
2269
+ # the artifact is ABOUT rather than what it displays.
2270
+ _DISPLAYED_DATE_RE = re.compile(
2271
+ r"^(\d{4}-\d{2})(-\d{2})?"
2272
+ r"(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$")
2273
+
2274
+
2275
+ def _displayed_date_token(text: str) -> "str | None":
2276
+ """`YYYY-MM` or `YYYY-MM-DD` when `text` is entirely a date."""
2277
+ match = _DISPLAYED_DATE_RE.match(text.strip())
2278
+ if match is None:
2279
+ return None
2280
+ return match.group(1) + (match.group(2) or "")
2281
+
2282
+
2283
+ def _chart_points(chart) -> list:
2284
+ """Every point a chart draws — primary series, rays and stacks.
2285
+
2286
+ All three series channels this kernel defines are read, because the
2287
+ docstring's claim of completeness has to be true of the code. Two of
2288
+ them were not read at all before (#503 S2 fourth review):
2289
+
2290
+ `BarChart.stacks` was never visited, although `_render_bar_chart_svg`
2291
+ draws it as cumulative segments. And `LineChart.multi_series` is a
2292
+ `Mapping[str, tuple[ChartPoint, ...]]`, so iterating it bare yielded
2293
+ its KEYS — plain strings, whose `getattr(series, "points", ())` is
2294
+ empty — which made the projected forecast ray contribute nothing.
2295
+ Every other reader of these two fields in this module uses
2296
+ `.values()`; this one alone did not.
2297
+
2298
+ No shipped builder puts a label in a stack or a ray that is absent
2299
+ from `points`, so correcting both changes no artifact today. Reading
2300
+ one of three channels while claiming all of them is the same
2301
+ blindness that made the predecessor of `displayed_dates` see nothing
2302
+ at all in the two `sessions` templates.
2303
+ """
2304
+ if chart is None:
2305
+ return []
2306
+ points = list(getattr(chart, "points", ()) or ())
2307
+ for series in (getattr(chart, "multi_series", None) or {}).values():
2308
+ points += list(series or ())
2309
+ for series in (getattr(chart, "stacks", None) or {}).values():
2310
+ points += list(series or ())
2311
+ return points
2312
+
2313
+
2314
+ def displayed_dates(snap: ShareSnapshot, *, shows_chart: bool,
2315
+ shows_table: bool) -> list:
2316
+ """Every date this rendering DISPLAYS, as a `YYYY-MM[-DD]` token.
2317
+
2318
+ Read from the text the renderers themselves print — `_render_cell_text`
2319
+ for a cell, `x_label` for a chart point — rather than from the fields
2320
+ behind them. That is what keeps this in step with the artifact: a
2321
+ `DateCell` carries a `datetime` and prints `%Y-%m-%d`, so a walker
2322
+ over string attributes saw nothing at all in the two `sessions`
2323
+ templates (#503 S2 third review).
2324
+
2325
+ Only cells reachable through `snap.columns` are visited, because only
2326
+ those are rendered.
2327
+ """
2328
+ tokens: list[str] = []
2329
+ if shows_table and _has_table(snap):
2330
+ for column in snap.columns:
2331
+ token = _displayed_date_token(column.label or "")
2332
+ if token:
2333
+ tokens.append(token)
2334
+ for row in snap.rows:
2335
+ for column in snap.columns:
2336
+ cell = row.cells.get(column.key)
2337
+ if cell is None:
2338
+ continue
2339
+ token = _displayed_date_token(_render_cell_text(cell))
2340
+ if token:
2341
+ tokens.append(token)
2342
+ if shows_chart:
2343
+ for point in _chart_points(snap.chart):
2344
+ token = _displayed_date_token(point.x_label or "")
2345
+ if token:
2346
+ tokens.append(token)
2347
+ return tokens
2348
+
2349
+
2350
+ def _period_boundary_at(period: PeriodSpec, iso_date: str, *,
2351
+ side: str) -> datetime:
2352
+ """A boundary of the same KIND as `period`, naming `iso_date`.
2353
+
2354
+ A civil-bucket period's boundaries are calendar labels lifted to
2355
+ UTC-midnight sentinels, so the widened one must be the same shape or
2356
+ `period_civil_dates` would convert it and name another day. An
2357
+ instant-based period's boundary must instead be midnight IN THE
2358
+ LABELLED ZONE, which is what converts back to `iso_date`.
2359
+
2360
+ `side` is required because the two ends are not symmetric. A START is
2361
+ the first instant of its civil day. An END of an INSTANT period is
2362
+ the LAST instant of its civil day: an artifact may display a full ISO
2363
+ timestamp — the Codex quota `blocks` panel's `Resets` column prints
2364
+ `2026-05-07T18:00:00+00:00` — and midnight of that same day precedes
2365
+ it, so the Markdown frontmatter's machine-readable bound excluded
2366
+ content the strip's civil date admits (#503 S2 fourth review). The
2367
+ next day's midnight is NOT the answer: `period_civil_dates` would
2368
+ then name a day the artifact does not display.
2369
+
2370
+ A civil-bucket END stays a midnight sentinel, because there the
2371
+ datetime is a calendar LABEL rather than an instant, and every
2372
+ declared civil-bucket boundary in the tree carries that shape.
2373
+ """
2374
+ year, month, day = (int(part) for part in iso_date.split("-"))
2375
+ zone = timezone.utc
2376
+ if not period.civil_bucket:
2377
+ try:
2378
+ zone = ZoneInfo(period.display_tz)
2379
+ except (ZoneInfoNotFoundError, ValueError, OSError):
2380
+ zone = timezone.utc
2381
+ if side == "end" and not period.civil_bucket:
2382
+ return datetime(year, month, day, 23, 59, 59, 999999, tzinfo=zone)
2383
+ return datetime(year, month, day, tzinfo=zone)
2384
+
2385
+
2386
+ def _whole_date(token: str, *, side: str) -> str:
2387
+ """A month token resolved to a day, in the direction of its bound.
2388
+
2389
+ `2026-05` opens on the first of May and closes on the last of it. The
2390
+ single first-day answer was right as a start bound and backwards as
2391
+ an end bound: a displayed May bucket above an earlier-ending period
2392
+ ended the stated window on `2026-05-01`, before most of the month it
2393
+ was widened to cover. A day token needs no resolution.
2394
+ """
2395
+ if len(token) == 10:
2396
+ return token
2397
+ year, month = (int(part) for part in token.split("-"))
2398
+ if side == "start":
2399
+ return f"{token}-01"
2400
+ return f"{token}-{calendar.monthrange(year, month)[1]:02d}"
2401
+
2402
+
2403
+ def effective_period(snap: ShareSnapshot, *, shows_chart: bool,
2404
+ shows_table: bool) -> PeriodSpec:
2405
+ """`snap.period`, widened to cover what THIS rendering displays.
2406
+
2407
+ A builder declares the window its artifact is about — the focal week,
2408
+ the current block, the range the user asked for. What a given FORMAT
2409
+ then puts on the page is not the same thing: Markdown draws no chart,
2410
+ and a chart-only template therefore displays nothing dated at all
2411
+ while its HTML and SVG forms display eight weeks of it.
2412
+
2413
+ So the period is completed here rather than at the builder, and the
2414
+ two failures that motivated it are opposite. UNDERSTATEMENT: four
2415
+ `source-aware` artifacts stated a period narrower than their own
2416
+ table, one of them a zero-width instant above a row dated three days
2417
+ earlier — a reader taking the header at face value would conclude the
2418
+ row could not be there. OVERSTATEMENT: the previous pass widened the
2419
+ `weekly` and `blocks` BUILDERS to the span their charts cover, which
2420
+ made every Markdown artifact from those panels claim eight weeks
2421
+ above one week's spend.
2422
+
2423
+ Doing it per rendering closes both at once and closes them for all 43
2424
+ construction sites rather than for the ones anybody enumerated. It
2425
+ never NARROWS: a report that covered nine days and found rows on five
2426
+ still covered nine, and an empty artifact must still say what it
2427
+ looked at.
2428
+ """
2429
+ period = snap.period
2430
+ tokens = displayed_dates(snap, shows_chart=shows_chart,
2431
+ shows_table=shows_table)
2432
+ if not tokens:
2433
+ return period
2434
+ start_civil, end_civil = period_civil_dates(period)
2435
+ # Compared at the token's OWN precision, so a `2026-05` month bucket
2436
+ # is covered by any period whose bounds fall in that month.
2437
+ below = [t for t in tokens if t < start_civil[:len(t)]]
2438
+ above = [t for t in tokens if t > end_civil[:len(t)]]
2439
+ if not below and not above:
2440
+ return period
2441
+ start = (_period_boundary_at(period, _whole_date(min(below), side="start"),
2442
+ side="start")
2443
+ if below else period.start)
2444
+ end = (_period_boundary_at(period, _whole_date(max(above), side="end"),
2445
+ side="end")
2446
+ if above else period.end)
2447
+ return dataclasses.replace(period, start=start, end=end)
2448
+
2449
+
2450
+ def _md_effective_period(snap: ShareSnapshot) -> PeriodSpec:
2451
+ """The period a MARKDOWN rendering of `snap` states.
2452
+
2453
+ One spelling for the frontmatter, the composite frontmatter union
2454
+ and the strip, because a document that stated two different
2455
+ periods about itself would be the defect this closes.
2456
+ """
2457
+ return effective_period(snap, shows_chart=False,
2458
+ shows_table=_has_table(snap))
2459
+
2460
+
2461
+ def _md_facts_line(snap: ShareSnapshot) -> str:
2462
+ return share_facts_line(snap, shows_chart=False,
2463
+ shows_table=_has_table(snap))
2464
+
2465
+
2466
+ def share_facts_line(snap: ShareSnapshot, *, shows_chart: bool,
2467
+ shows_table: bool) -> str:
2468
+ """The one sentence every artifact states about itself.
2469
+
2470
+ Reads `{start} → {end} ({zone})`, plus `· projects anonymized` or
2471
+ `· real project names` when the document actually contains project
2472
+ names. Public because the three fragment renderers are the only
2473
+ callers today, but the string is part of the artifact contract.
2474
+
2475
+ `shows_chart` and `shows_table` say what THIS rendering puts on the
2476
+ page, and both are required keywords with no default — the period is
2477
+ completed from them through `effective_period`, and a default would
2478
+ let a new caller silently claim a span its format does not draw.
2479
+
2480
+ THE PRIVACY CLAUSE COMES FROM THE PROVENANCE MARKER, never from the
2481
+ rendered output. `_prepare` records which project display labels it
2482
+ consumed and which privacy mode it ran under; inferring either from
2483
+ the emitted labels is exactly the defect #503 S1 F5 was, because a
2484
+ project legitimately named `project-1` is indistinguishable from an
2485
+ alias by shape alone.
2486
+
2487
+ `originals - {ANON_UNKNOWN}` is the has-project-names predicate. It
2488
+ reads `originals` rather than `allowed`, because composed preparation
2489
+ supplies a DOCUMENT-WIDE mapping while `originals` stays specific to
2490
+ this section — a Forecast section composed beside a Projects section
2491
+ must not claim the Projects section's anonymization. It inspects no
2492
+ columns, rows, charts or aliases.
2493
+
2494
+ An UNPREPARED snapshot — reachable only from tests, never from
2495
+ `render()` or `compose()` — carries no marker, so it can truthfully
2496
+ claim neither state and the clause is omitted entirely.
2497
+ """
2498
+ period = effective_period(snap, shows_chart=shows_chart,
2499
+ shows_table=shows_table)
2500
+ start_civil, end_civil = period_civil_dates(period)
2501
+ line = f"{start_civil} → {end_civil} ({period.display_tz})"
2502
+ prov = _provenance_of(snap)
2503
+ if prov is None or not (prov.originals - {ANON_UNKNOWN}):
2504
+ return line
2505
+ clause = "real project names" if prov.reveal_projects else "projects anonymized"
2506
+ return f"{line} · {clause}"
2507
+
2508
+
2509
+ def _has_table(snap: ShareSnapshot) -> bool:
2510
+ """Whether this artifact carries a table at all.
2511
+
2512
+ ONE predicate for all three renderers. It requires both columns and
2513
+ rows: a header row with no body is a frame around nothing, and every
2514
+ artifact that drew one was an empty-result artifact whose title
2515
+ already said so — `share/report-empty-html` drew a four-cell header
2516
+ under a title reading `— no data`, and seventeen
2517
+ `tests/fixtures/source-aware/*-empty` goldens did the same, including
2518
+ one that printed `Codex quota state is unavailable.` and then drew a
2519
+ `Quota Series | % Used | $ Cost` frame anyway (#503 S2 review F3).
2520
+
2521
+ ONE builder does produce the schema-only shape: `budget`'s no-budget
2522
+ snapshot passes a `Metric | Value` column pair with no rows. Its
2523
+ artifact is titled `Budget — no budget set` and carries a note
2524
+ telling the reader how to set one, so the header row was a frame
2525
+ around nothing there too, and it is suppressed with the rest.
2526
+ """
2527
+ return bool(snap.columns) and bool(snap.rows)
2528
+
2529
+
1984
2530
  # --- Format renderers ---
1985
2531
 
1986
- def _render_md_fragment(snap: ShareSnapshot, *, branding: bool) -> str:
2532
+ def _render_md_fragment(snap: ShareSnapshot, *, branding: bool,
2533
+ heading_level: int = 1,
2534
+ heading_override: "str | None" = None,
2535
+ suppress_provider: bool = False) -> str:
1987
2536
  """Render the MD section body.
1988
2537
 
1989
2538
  M1.2 contract: returns the full current `_render_md` body. Frontmatter
@@ -1991,18 +2540,45 @@ def _render_md_fragment(snap: ShareSnapshot, *, branding: bool) -> str:
1991
2540
  + `_wrap_document`. Fragment shape is body-only by definition; even
1992
2541
  without frontmatter the wrap layer remains the single chrome chokepoint
1993
2542
  so future surfaces (compose, history) extend it once.
2543
+
2544
+ `heading_level` defaults to 1, which is what every standalone caller
2545
+ passes, so their output is byte-identical. `compose()` passes 2 so a
2546
+ composed document has one H1 and one H2 per section (#503 S2 F12).
2547
+
2548
+ `heading_override` and `suppress_provider` implement D6: in an
2549
+ all-source document the heading reads `<title> — <provider>` and the
2550
+ section's separate provider line is dropped, because otherwise both
2551
+ sections carry the same title and only a small line below tells them
2552
+ apart. `suppress_provider` removes ONLY the provider name; the
2553
+ availability text (`No data`, `Unavailable: …`) is preserved.
1994
2554
  """
1995
- parts = [f"# {_md_escape(snap.title)}"]
2555
+ heading = snap.title if heading_override is None else heading_override
2556
+ parts = [f"{'#' * heading_level} {_md_escape(heading)}"]
1996
2557
  source_label, availability = _source_chrome(snap)
2558
+ if suppress_provider:
2559
+ source_label = None
1997
2560
  if source_label:
1998
2561
  parts.extend(["", f"**{_md_escape(source_label)}**"])
1999
2562
  if availability:
2000
2563
  parts.extend(["", _md_escape(availability)])
2001
2564
  if snap.subtitle:
2002
2565
  parts.append(f"_{_md_escape(snap.subtitle)}_")
2003
- parts.append(f"_{_format_generated_at_iso(snap.generated_at)}_")
2004
- parts.append("") # blank line before table
2005
- parts.append(_render_md_table(snap))
2566
+ # UNCONDITIONAL (#503 S2 D1): the subtitle is optional editorial
2567
+ # content and may be absent, but the facts strip and the generation
2568
+ # timestamp are what the artifact states about itself, so neither may
2569
+ # be gated on it.
2570
+ # Markdown draws NO chart, so its period is completed from its own
2571
+ # table alone (#503 S2 third review).
2572
+ parts.append(f"_{_md_escape(_md_facts_line(snap))}_")
2573
+ parts.append(f"_{_format_generated_at_iso(snap.generated_at)}_")
2574
+ # Gated on `_has_table` — columns AND rows — in all three formats
2575
+ # (#503 S2 F17, closed for the empty case by the S2 review). Ungated,
2576
+ # the eight chart-only `*-visual` templates appended a blank line and
2577
+ # then a table that renders as the empty string, producing a
2578
+ # four-newline run in the middle of the document.
2579
+ if _has_table(snap):
2580
+ parts.append("") # blank line before table
2581
+ parts.append(_render_md_table(snap))
2006
2582
 
2007
2583
  if snap.totals:
2008
2584
  parts.append("")
@@ -2028,6 +2604,59 @@ def _render_md_fragment(snap: ShareSnapshot, *, branding: bool) -> str:
2028
2604
  # --- SVG composition ---
2029
2605
 
2030
2606
  _SVG_WIDTH = 600
2607
+ # `font-family` (#503 S2 review F5) is carried by EVERY `<svg>` root this
2608
+ # kernel emits: only the 24 SVG table cells declared one, so the title,
2609
+ # subtitle, facts strip, timestamp, footer and every chart label fell back
2610
+ # to the viewer default — Times in Chromium. The same report was therefore
2611
+ # typographically different as `.svg` and as `.html`, and
2612
+ # `_svg_text_width`'s sans-serif calibration did not apply to the
2613
+ # standalone form at all. `font-family` is an inherited SVG presentation
2614
+ # attribute, so declaring it once on the root reaches every `<text>`; the
2615
+ # per-cell declarations stay and agree with it.
2616
+ _SVG_ROOT_COMMON_ATTRS = (
2617
+ 'xmlns="http://www.w3.org/2000/svg" font-family="sans-serif"'
2618
+ )
2619
+ # A DOCUMENT root — a standalone `.svg` artifact or a composed stack.
2620
+ # Scaling here is correct: the whole artifact is the viewport's content,
2621
+ # so shrinking it to fit shrinks everything together and the reader can
2622
+ # zoom. `height:auto` keeps the aspect ratio while `max-width:100%`
2623
+ # bounds the width.
2624
+ _SVG_ROOT_ATTRS = (
2625
+ f'{_SVG_ROOT_COMMON_ATTRS} style="max-width:100%;height:auto"'
2626
+ )
2627
+ # The chart EMBEDDED in an HTML document, which is a different problem
2628
+ # with the opposite answer (#503 S2 second review N1).
2629
+ #
2630
+ # `_wrap_document` caps the HTML body at `max-width:680px` with 20px
2631
+ # padding. Under the default `content-box` sizing that padding lies
2632
+ # OUTSIDE the cap, so the content box is 680px wide, not 640 — the figure
2633
+ # an earlier pass recorded (#503 S2 fourth review). `chart_required_width`
2634
+ # widens the canvas past it for a long label; the widest embedded chart in
2635
+ # the committed goldens declares 670.9, and real `project
2636
+ # --reveal-projects` data produces 755.3. Carrying `max-width:100%` scaled
2637
+ # the chart down to fit, which is why the surrounding `overflow:auto`
2638
+ # container could never activate: measured in a browser,
2639
+ # `scrollWidth - clientWidth` was 0 in all 582 document-by-viewport
2640
+ # checks. The cost was legibility rather than layout — at a 420px
2641
+ # viewport the chart scaled to 0.593 and its smallest axis text rendered
2642
+ # at 5.93 CSS px, with 111 chart instances below 9px across the tested
2643
+ # viewports and a worst desktop case of 8.80px at 1280px.
2644
+ #
2645
+ # So the embedded chart keeps its intrinsic size and its container
2646
+ # scrolls. `display:block` removes the inline baseline strut under a
2647
+ # replaced element, which would otherwise make the container's content
2648
+ # taller than its box and raise a vertical scrollbar with no content to
2649
+ # reveal.
2650
+ _SVG_EMBED_ROOT_ATTRS = f'{_SVG_ROOT_COMMON_ATTRS} style="display:block"'
2651
+ # Both the chart and the table are laid out at their own intrinsic width
2652
+ # and scroll inside their own box, so the DOCUMENT never scrolls
2653
+ # horizontally. `overflow` is declared on both axes deliberately: CSS
2654
+ # computes `overflow-y: visible` to `auto` whenever `overflow-x` is not
2655
+ # `visible`, so `overflow-x:auto` alone already made these elements
2656
+ # scroll containers on both axes. Writing it out says so, and it avoids
2657
+ # `overflow-y:hidden`, which clips the bottom of the content by the
2658
+ # height of a classic (non-overlay) horizontal scrollbar.
2659
+ _HTML_SCROLL_BOX_STYLE = "overflow:auto"
2031
2660
  _SVG_HEADER_H = 60
2032
2661
  _SVG_CHART_H = 220
2033
2662
  _SVG_FOOTER_H = 30
@@ -2040,6 +2669,15 @@ _SVG_FOOTER_BASELINE = 18
2040
2669
  # Vertical padding between stacked sections in `_stitch_svg`.
2041
2670
  _SVG_SECTION_GAP = 20.0
2042
2671
 
2672
+ # --- Composed SVG chrome (#503 S2 F13b) ---
2673
+ # The composite title band and the branding-gated composite footer. Sized
2674
+ # to match the standalone SVG's own title (18pt at a +30 baseline inside a
2675
+ # 48px band) and footer (10pt at a +18 baseline inside a 30px band).
2676
+ _SVG_COMPOSITE_HEADER_H = 48.0
2677
+ _SVG_COMPOSITE_TITLE_BASELINE = 30.0
2678
+ _SVG_COMPOSITE_FOOTER_H = 30.0
2679
+ _SVG_COMPOSITE_FOOTER_BASELINE = 18.0
2680
+
2043
2681
  # --- SVG table geometry (issue #38) ---
2044
2682
  _SVG_TABLE_FONT = 11
2045
2683
  _SVG_TABLE_CELL_PAD_X = 8
@@ -2048,23 +2686,157 @@ _SVG_TABLE_LINE_H_MULT = 1.4
2048
2686
  _SVG_TABLE_GAP = 16
2049
2687
  _SVG_TABLE_MAX_WRAP_LINES = 3
2050
2688
  _SVG_TABLE_MIN_COL_W = 24
2051
- _SVG_AVG_GLYPH_WIDTH_FRACTION = 0.6
2689
+ _SVG_AVG_GLYPH_WIDTH_FRACTION = 0.6 # fallback for an unclassified glyph
2690
+ # Advance width as a fraction of the font size, per printable ASCII
2691
+ # character, from the Helvetica AFM (`sans-serif` resolves to Helvetica in
2692
+ # the browser these were measured in). Written as the AFM's per-mille
2693
+ # integers so each value can be checked against the font's own table
2694
+ # rather than against a class the implementor invented.
2695
+ #
2696
+ # This replaced a four-class approximation whose two re-tuned entries are
2697
+ # the reason it is now per-character: `M` was 0.95 against Helvetica's
2698
+ # 0.833 and `i` was 0.28 against 0.222, and both errors reached the SVG
2699
+ # table's column widths as well as the chart gutters.
2700
+ _SVG_GLYPH_ADVANCE_PER_MILLE: dict[str, int] = {
2701
+ " ": 278, "!": 278, '"': 355, "#": 556, "$": 556, "%": 889, "&": 667,
2702
+ "'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
2703
+ ".": 278, "/": 278,
2704
+ "0": 556, "1": 556, "2": 556, "3": 556, "4": 556, "5": 556, "6": 556,
2705
+ "7": 556, "8": 556, "9": 556,
2706
+ ":": 278, ";": 278, "<": 584, "=": 584, ">": 584, "?": 556, "@": 1015,
2707
+ "A": 667, "B": 667, "C": 722, "D": 722, "E": 667, "F": 611, "G": 778,
2708
+ "H": 722, "I": 278, "J": 500, "K": 667, "L": 556, "M": 833, "N": 722,
2709
+ "O": 778, "P": 667, "Q": 778, "R": 722, "S": 667, "T": 611, "U": 722,
2710
+ "V": 667, "W": 944, "X": 667, "Y": 667, "Z": 611,
2711
+ "[": 278, "\\": 278, "]": 278, "^": 469, "_": 556, "`": 333,
2712
+ "a": 556, "b": 556, "c": 500, "d": 556, "e": 556, "f": 278, "g": 556,
2713
+ "h": 556, "i": 222, "j": 222, "k": 500, "l": 222, "m": 833, "n": 556,
2714
+ "o": 556, "p": 556, "q": 556, "r": 333, "s": 500, "t": 278, "u": 556,
2715
+ "v": 500, "w": 722, "x": 500, "y": 500, "z": 500,
2716
+ "{": 334, "|": 260, "}": 334, "~": 584,
2717
+ }
2718
+ _SVG_GLYPH_WIDTH_FRACTIONS: dict[str, float] = {
2719
+ char: per_mille / 1000.0
2720
+ for char, per_mille in _SVG_GLYPH_ADVANCE_PER_MILLE.items()
2721
+ }
2722
+ # A wide-script character (CJK ideograph, kana, hangul, fullwidth form) is
2723
+ # a full em by definition, and the fallback fonts that render it measured
2724
+ # 1.02 em for CJK and 1.01 for kana at font-size 11. Reserving 1.05
2725
+ # over-reserves those slightly and hangul more (it measured 0.865), which
2726
+ # is the safe direction: under-reservation OVERPRINTS. Against the old
2727
+ # 0.6-em estimate a twelve-character Chinese project name reserved 79.20
2728
+ # where the browser drew 134.91, so the `$ Cost` column began at x=129.4
2729
+ # while the name ran to x=162.8 — 27.5px of value printed on top of name.
2730
+ _SVG_WIDE_GLYPH_WIDTH_FRACTION = 1.05
2731
+ # Emoji are wider still: eight rockets measured 112.00 at font-size 11,
2732
+ # which is 1.27 em each, against the 52.80 the flat estimate reserved.
2733
+ _SVG_EMOJI_WIDTH_FRACTION = 1.3
2734
+ # Codepoint ranges treated as emoji. Deliberately coarse and deliberately
2735
+ # NOT the whole of East_Asian_Width=W, which these also carry: the arrow
2736
+ # in the facts strip and the ellipsis are neither emoji nor wide.
2737
+ _SVG_EMOJI_RANGES: tuple[tuple[int, int], ...] = (
2738
+ (0x1F000, 0x1FAFF), # mahjong/domino/cards through symbols & pictographs
2739
+ (0x2600, 0x27BF), # misc symbols and dingbats
2740
+ (0x2B00, 0x2BFF), # misc symbols and arrows (the emoji stars live here)
2741
+ )
2742
+ # A combining mark, an enclosing mark and a format character (ZWJ, the
2743
+ # variation selectors) all advance the pen by zero. Twelve acute accents
2744
+ # over twelve `e` measured 73.42 at font-size 11 — exactly the twelve `e`
2745
+ # — where treating each mark as a glyph reserved 153.12, over-reserving by
2746
+ # 108.6% and wasting canvas the chart could have used.
2747
+ _SVG_ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Me", "Cf"})
2748
+ # Two characters this kernel emits itself, and neither is emoji nor
2749
+ # East_Asian_Width=W, so both fell to the 0.6-em fallback while Chromium
2750
+ # advances them at exactly one em: measured 11.00 at font-size 11, 13.00
2751
+ # at 13 and 18.00 at 18 for both. The arrow appears once per artifact in
2752
+ # the facts strip, and the ellipsis GATES the SVG table's truncation
2753
+ # decision, so under-reserving it by 0.40 em under-reserves a truncated
2754
+ # cell (#503 S2 third review). Live impact was sub-pixel — worst
2755
+ # synthetic case −0.45px, zero viewBox escapes across 255 charts — but a
2756
+ # known-wrong entry in a table whose whole point is correctness is worth
2757
+ # more than the delta it moves.
2758
+ _SVG_FULL_EM_CHARS = frozenset({"\u2192", "\u2026"})
2759
+ # A ZWJ sequence renders as ONE glyph. The joiner itself is `Cf` and
2760
+ # already advances zero, but each joined codepoint was reserved a full
2761
+ # emoji width, so a family sequence reserved ~3.9 em for one rendered
2762
+ # character.
2763
+ _SVG_ZWJ = "\u200d"
2052
2764
  _SVG_WRAP_BREAK_CHARS = (" ", "/", "-", "_")
2053
2765
  _SVG_ELLIPSIS = "…"
2054
2766
  _SVG_NOTE_FONT = 11
2055
2767
  _SVG_NOTE_LINE_H = 16
2056
2768
  _SVG_NOTE_GAP = 14
2057
2769
 
2770
+ # --- SVG totals band (#503 S2 D3) ---
2771
+ _SVG_TOTALS_FONT = 11
2772
+ _SVG_TOTALS_LINE_H = 16
2773
+ _SVG_TOTALS_GAP = 14
2774
+
2775
+
2776
+ def _svg_glyph_width_fraction(char: str) -> float:
2777
+ """The advance width of one character, as a fraction of the font size.
2778
+
2779
+ Five cases, in the order they are tested. Printable ASCII comes from
2780
+ the Helvetica AFM. A combining or format character advances zero. The
2781
+ arrow and the ellipsis this kernel emits itself are a measured full
2782
+ em. An emoji is the widest class. A wide-script character is a full
2783
+ em. Every other character — `é`, Cyrillic, Greek — keeps the
2784
+ historical 0.6-em fallback, which is where the estimator is still an
2785
+ estimate.
2786
+ """
2787
+ fraction = _SVG_GLYPH_WIDTH_FRACTIONS.get(char)
2788
+ if fraction is not None:
2789
+ return fraction
2790
+ if unicodedata.category(char) in _SVG_ZERO_WIDTH_CATEGORIES:
2791
+ return 0.0
2792
+ if char in _SVG_FULL_EM_CHARS:
2793
+ return 1.0
2794
+ code = ord(char)
2795
+ for low, high in _SVG_EMOJI_RANGES:
2796
+ if low <= code <= high:
2797
+ return _SVG_EMOJI_WIDTH_FRACTION
2798
+ if unicodedata.east_asian_width(char) in ("W", "F"):
2799
+ return _SVG_WIDE_GLYPH_WIDTH_FRACTION
2800
+ return _SVG_AVG_GLYPH_WIDTH_FRACTION
2801
+
2058
2802
 
2059
2803
  def _svg_text_width(text: str, font_size: float) -> float:
2060
2804
  """Estimate rendered width of `text` at `font_size` in a sans-serif font.
2061
2805
 
2062
- Heuristic-only: actual width depends on the UA-selected font. SVG
2063
- goldens are diffed as source text, so this function's job is
2064
- determinism, not pixel-perfect measurement. Wrap-then-ellipsize
2065
- layout tolerates moderate over/under-allocation.
2806
+ Still a heuristic — the actual width depends on the UA-selected font,
2807
+ and this stays a table rather than a font-metrics dependency. But the
2808
+ flat 0.6-em estimate it replaced was wrong by a factor at both ends:
2809
+ 20 `W` at font-size 11 reserved 132.0 against a measured 207.66, so
2810
+ the label began at `x = -75.66` and was cut off, while 36 `i` reserved
2811
+ 237.6 against a measured 87.98. The same estimate sizes the SVG
2812
+ table's columns and gates its ellipsis decision, so an under-estimate
2813
+ did not merely clip a chart label — it let two table values print on
2814
+ top of each other (#503 S2 review F6).
2815
+
2816
+ A four-class approximation closed the ASCII ends and left the
2817
+ non-Latin ones open, which is the same defect in the same function:
2818
+ against real Chromium a twelve-character Chinese project name was
2819
+ under-reserved by 41.3%, kana by 40.7%, hangul by 30.6% and emoji by
2820
+ 52.9%, while a combining mark was over-reserved by 108.6% (#503 S2
2821
+ second review N6). `_svg_glyph_width_fraction` classifies those.
2822
+
2823
+ A ZWJ SEQUENCE IS ONE GLYPH, and the per-character sum cannot see
2824
+ that on its own: the joiner advances zero, but each joined codepoint
2825
+ was reserved a full emoji width, so a family sequence reserved about
2826
+ 3.9 em for a single rendered character (#503 S2 third review). A
2827
+ codepoint that follows a joiner is therefore skipped.
2066
2828
  """
2067
- return len(text) * font_size * _SVG_AVG_GLYPH_WIDTH_FRACTION
2829
+ total = 0.0
2830
+ joined = False
2831
+ for char in text:
2832
+ if joined:
2833
+ joined = char == _SVG_ZWJ
2834
+ continue
2835
+ if char == _SVG_ZWJ:
2836
+ joined = True
2837
+ continue
2838
+ total += _svg_glyph_width_fraction(char)
2839
+ return total * font_size
2068
2840
 
2069
2841
 
2070
2842
  def _wrap_for_width(text: str, content_w: float, font_size: float) -> list[str]:
@@ -2149,6 +2921,30 @@ def _render_svg_notes(
2149
2921
  return body, float(len(lines) * _SVG_NOTE_LINE_H)
2150
2922
 
2151
2923
 
2924
+ def _render_svg_totals(
2925
+ snap: "ShareSnapshot", *, palette: dict, x: float, y: float,
2926
+ ) -> tuple[str, float]:
2927
+ """Render `snap.totals` as one `Label: value` row each (#503 S2 D3).
2928
+
2929
+ Returns `("", 0.0)` when there is nothing to render, so the caller's
2930
+ height arithmetic and the emitted band stay derived from one decision.
2931
+ SVG carried no totals at all before this, which is why `cctally budget
2932
+ --format svg` showed neither its `ok`/`warn`/`over` verdict nor its
2933
+ target: both live in `totals` and in no table row.
2934
+ """
2935
+ if not snap.totals:
2936
+ return "", 0.0
2937
+ body = svg_group([
2938
+ svg_text(
2939
+ x, y + ((idx + 1) * _SVG_TOTALS_LINE_H),
2940
+ f"{total.label}: {total.value}",
2941
+ font_size=_SVG_TOTALS_FONT, fill=palette["fg"], weight="bold",
2942
+ )
2943
+ for idx, total in enumerate(snap.totals)
2944
+ ])
2945
+ return body, float(len(snap.totals) * _SVG_TOTALS_LINE_H)
2946
+
2947
+
2152
2948
  def _svg_table_anchor_and_x(align: str, col_x: float, col_w: float,
2153
2949
  pad_x: float) -> tuple[str, float]:
2154
2950
  """Map `ColumnSpec.align` to SVG text-anchor + x-coordinate."""
@@ -2321,9 +3117,10 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
2321
3117
  chart-slot embed stays table-free (HTML <table>
2322
3118
  is rendered separately as a sibling element).
2323
3119
  """
2324
- has_table = include_table and bool(snap.columns)
3120
+ has_table = include_table and _has_table(snap)
2325
3121
  chart_h = _SVG_CHART_H if snap.chart is not None else 0
2326
- header_h = _svg_header_height(snap, include_chrome=include_chrome)
3122
+ header_h = _svg_header_height(snap, include_chrome=include_chrome,
3123
+ shows_table=has_table)
2327
3124
 
2328
3125
  # Pre-layout the table (we need its height before declaring outer SVG height).
2329
3126
  if has_table:
@@ -2334,32 +3131,54 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
2334
3131
  else:
2335
3132
  table_svg, table_h, table_w = "", 0.0, 0.0
2336
3133
 
2337
- # Canvas grows when the table's used width exceeds _SVG_WIDTH — happens
2338
- # only when the min-col-w clamp fired (pathological top_n). Header /
2339
- # chart / footer keep their original positioning anchored at _SVG_WIDTH;
2340
- # the canvas just extends to the right so wide tables aren't clipped at
2341
- # the viewBox. Issue #38 follow-up (Codex PR #40 P2).
2342
- content_w = max(_SVG_WIDTH, table_w)
3134
+ # Canvas grows when its content needs more than _SVG_WIDTH. Two
3135
+ # sources: a table whose min-col-w clamp fired (issue #38 follow-up,
3136
+ # Codex PR #40 P2), and a chart whose labels do not fit the nominal
3137
+ # gutters (#503 S2 F16). The chart, header, notes and footer all lay
3138
+ # out against `content_w` so a widened canvas keeps its chart's
3139
+ # nominal plot width and right-aligns its timestamp at the real edge.
3140
+ # The TABLE is deliberately excluded: its own width is an input to
3141
+ # this maximum, so laying it out against the result would be circular.
3142
+ content_w = max(_SVG_WIDTH, table_w,
3143
+ chart_required_width(snap.chart, nominal_width=_SVG_WIDTH))
2343
3144
 
2344
3145
  table_block_h = (_SVG_TABLE_GAP + table_h) if has_table else 0
3146
+
3147
+ # Totals sit between the table and the notes, matching the Markdown
3148
+ # order. GATED ON `include_chrome` for the same reason the facts strip
3149
+ # is: `_render_html_fragment` embeds this renderer with
3150
+ # `include_chrome=False` to get the chart alone, and an ungated band
3151
+ # would print every total twice in every HTML artifact.
3152
+ totals_block_h = 0.0
3153
+ totals_svg = ""
3154
+ if include_chrome and snap.totals:
3155
+ totals_y = (
3156
+ _SVG_PADDING + header_h + chart_h + table_block_h + _SVG_TOTALS_GAP
3157
+ )
3158
+ totals_svg, totals_h = _render_svg_totals(
3159
+ snap, palette=palette, x=_SVG_PADDING, y=totals_y,
3160
+ )
3161
+ totals_block_h = _SVG_TOTALS_GAP + totals_h
3162
+
2345
3163
  note_block_h = 0.0
2346
3164
  note_svg = ""
2347
3165
  if include_chrome and snap.notes:
2348
3166
  note_y = (
2349
- _SVG_PADDING + header_h + chart_h + table_block_h + _SVG_NOTE_GAP
3167
+ _SVG_PADDING + header_h + chart_h + table_block_h + totals_block_h
3168
+ + _SVG_NOTE_GAP
2350
3169
  )
2351
3170
  note_svg, note_h = _render_svg_notes(
2352
3171
  snap,
2353
3172
  palette=palette,
2354
3173
  x=_SVG_PADDING,
2355
3174
  y=note_y,
2356
- width=_SVG_WIDTH,
3175
+ width=content_w,
2357
3176
  )
2358
3177
  note_block_h = _SVG_NOTE_GAP + note_h
2359
3178
 
2360
3179
  if include_chrome:
2361
3180
  height = (
2362
- header_h + chart_h + table_block_h + note_block_h
3181
+ header_h + chart_h + table_block_h + totals_block_h + note_block_h
2363
3182
  + _SVG_FOOTER_H + (_SVG_PADDING * 2)
2364
3183
  )
2365
3184
  else:
@@ -2370,7 +3189,8 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
2370
3189
  if include_chrome:
2371
3190
  pieces.append(_render_svg_header(
2372
3191
  snap, palette=palette,
2373
- x=_SVG_PADDING, y=_SVG_PADDING, width=_SVG_WIDTH,
3192
+ x=_SVG_PADDING, y=_SVG_PADDING, width=content_w,
3193
+ shows_table=has_table,
2374
3194
  ))
2375
3195
 
2376
3196
  chart_y = _SVG_PADDING + header_h
@@ -2378,41 +3198,50 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
2378
3198
  if isinstance(snap.chart, LineChart):
2379
3199
  pieces.append(_render_line_chart_svg(
2380
3200
  snap.chart, palette=palette,
2381
- x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
3201
+ x=_SVG_PADDING, y=chart_y, width=content_w, height=_SVG_CHART_H,
2382
3202
  ))
2383
3203
  elif isinstance(snap.chart, BarChart):
2384
3204
  pieces.append(_render_bar_chart_svg(
2385
3205
  snap.chart, palette=palette,
2386
- x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
3206
+ x=_SVG_PADDING, y=chart_y, width=content_w, height=_SVG_CHART_H,
2387
3207
  ))
2388
3208
  elif isinstance(snap.chart, HorizontalBarChart):
2389
3209
  pieces.append(_render_hbar_chart_svg(
2390
3210
  snap.chart, palette=palette,
2391
- x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
3211
+ x=_SVG_PADDING, y=chart_y, width=content_w, height=_SVG_CHART_H,
2392
3212
  ))
2393
3213
 
2394
3214
  if has_table:
2395
3215
  pieces.append(table_svg)
2396
3216
 
3217
+ if totals_svg:
3218
+ pieces.append(totals_svg)
3219
+
2397
3220
  if note_svg:
2398
3221
  pieces.append(note_svg)
2399
3222
 
2400
3223
  if include_chrome:
2401
3224
  footer_y = (
2402
- _SVG_PADDING + header_h + chart_h + table_block_h + note_block_h
2403
- + _SVG_FOOTER_BASELINE
3225
+ _SVG_PADDING + header_h + chart_h + table_block_h + totals_block_h
3226
+ + note_block_h + _SVG_FOOTER_BASELINE
2404
3227
  )
2405
3228
  pieces.append(_render_svg_footer(
2406
3229
  snap, palette=palette,
2407
3230
  x=_SVG_PADDING, y=footer_y,
2408
- width=_SVG_WIDTH, branding=branding,
3231
+ width=content_w, branding=branding,
2409
3232
  ))
2410
3233
 
2411
3234
  total_w = content_w + (_SVG_PADDING * 2)
2412
3235
  bg_rect = svg_rect(0, 0, total_w, height, fill=palette["bg"])
2413
3236
  inner = bg_rect + "".join(pieces)
3237
+ # `include_chrome=False` IS the HTML-embedded chart contract (see the
3238
+ # docstring), and it is the only way this root tag reaches an
3239
+ # artifact: the chrome-bearing form is always re-wrapped by
3240
+ # `_wrap_document` or `_stitch_svg` after `_strip_outer_svg_tag`. An
3241
+ # embedded chart must not be scaled down to fit — it scrolls instead.
3242
+ root_attrs = _SVG_ROOT_ATTRS if include_chrome else _SVG_EMBED_ROOT_ATTRS
2414
3243
  return (
2415
- f'<svg xmlns="http://www.w3.org/2000/svg" '
3244
+ f'<svg {root_attrs} '
2416
3245
  f'viewBox="0 0 {_fmt_num(total_w)} {_fmt_num(height)}" '
2417
3246
  f'width="{_fmt_num(total_w)}" height="{_fmt_num(height)}">'
2418
3247
  f'{inner}'
@@ -2462,6 +3291,16 @@ def _render_cell_md(cell: Cell) -> str:
2462
3291
  # --- HTML chrome and table ---
2463
3292
 
2464
3293
  def _render_html_table(snap: ShareSnapshot, palette: dict) -> str:
3294
+ """The data table, inside its own horizontal scroll box.
3295
+
3296
+ The table — not the chart — is what actually overflowed the 680px
3297
+ content box (#503 S2 second review N1). Measured at a 420px viewport,
3298
+ `documentElement.scrollWidth - clientWidth` was 129px in three of the
3299
+ `daily` detail artifacts, 76px in a fourth and 53px in a `blocks` one,
3300
+ and the rightmost columns were reachable only by scrolling the whole
3301
+ document sideways. A table shrink-wraps to its content and has no cap
3302
+ to give it, so the box around it is the mechanism.
3303
+ """
2465
3304
  th_cells = "".join(
2466
3305
  f'<th style="text-align:{c.align};padding:6px 10px;background:{palette["table_header_bg"]};color:{palette["fg"]}">{_xml_escape(c.label)}</th>'
2467
3306
  for c in snap.columns
@@ -2475,31 +3314,53 @@ def _render_html_table(snap: ShareSnapshot, palette: dict) -> str:
2475
3314
  )
2476
3315
  body_rows.append(f"<tr>{td_cells}</tr>")
2477
3316
  return (
2478
- f'<table style="border-collapse:collapse;font-family:system-ui,-apple-system,sans-serif;font-size:13px;margin-top:12px">'
3317
+ f'<div style="margin-top:12px;{_HTML_SCROLL_BOX_STYLE}">'
3318
+ f'<table style="border-collapse:collapse;font-family:system-ui,-apple-system,sans-serif;font-size:13px">'
2479
3319
  f'<thead><tr>{th_cells}</tr></thead>'
2480
3320
  f'<tbody>{"".join(body_rows)}</tbody>'
2481
3321
  f'</table>'
3322
+ f'</div>'
2482
3323
  )
2483
3324
 
2484
3325
 
2485
- def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool) -> str:
3326
+ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool,
3327
+ heading_level: int = 1,
3328
+ heading_override: "str | None" = None,
3329
+ suppress_provider: bool = False) -> str:
2486
3330
  """Render the HTML body fragment — header + chart + table + (branded footer).
2487
3331
 
2488
3332
  Document chrome (<!DOCTYPE>/<html>/<head>/<body>) is layered on at the wrap
2489
3333
  step via `_wrap_document`, keeping body-only content composable for v2's
2490
3334
  multi-section stitcher.
3335
+
3336
+ `heading_level`, `heading_override` and `suppress_provider` mean the
3337
+ same as in `_render_md_fragment`. HTML had the INVERTED form of the
3338
+ Markdown heading defect: `_stitch_html` wrapped one composite `<h1>`
3339
+ around one `<h1>` per section, so a composed document declared several
3340
+ top-level headings.
2491
3341
  """
2492
3342
  # `_share_apply_content_toggles` sets `snap.chart=None` for show_chart=False
2493
3343
  # and `snap.columns=()`/`snap.rows=()` for show_table=False. Gate the chart
2494
3344
  # wrapper div + the table chrome on those, so disabled sections drop entirely
2495
3345
  # rather than rendering empty chrome (an empty `<svg>` chart area or an
2496
3346
  # `<table>` with no `<th>`/`<td>`).
3347
+ # The chart keeps its intrinsic size and this box scrolls (#503 S2
3348
+ # second review N1). The earlier pairing — a `max-width:100%` cap on
3349
+ # the `<svg>` inside an `overflow-x:auto` box — could not scroll at
3350
+ # all, because the cap guaranteed the content already fitted.
2497
3351
  chart_html = (
2498
- f'<div style="margin-top:12px">{_render_svg(snap, palette=palette, branding=False, include_chrome=False, include_table=False)}</div>'
3352
+ f'<div style="margin-top:12px;{_HTML_SCROLL_BOX_STYLE}">{_render_svg(snap, palette=palette, branding=False, include_chrome=False, include_table=False)}</div>'
2499
3353
  if snap.chart is not None else ""
2500
3354
  )
2501
- title_html = f'<h1 style="font-size:20px;color:{palette["fg"]};margin:0">{_xml_escape(snap.title)}</h1>'
3355
+ heading = snap.title if heading_override is None else heading_override
3356
+ heading_tag = f"h{heading_level}"
3357
+ title_html = (
3358
+ f'<{heading_tag} style="font-size:20px;color:{palette["fg"]};margin:0">'
3359
+ f'{_xml_escape(heading)}</{heading_tag}>'
3360
+ )
2502
3361
  source_label, availability = _source_chrome(snap)
3362
+ if suppress_provider:
3363
+ source_label = None
2503
3364
  source_html = (
2504
3365
  f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">{_xml_escape(source_label)}</div>'
2505
3366
  if source_label else ""
@@ -2512,11 +3373,29 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
2512
3373
  f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">{_xml_escape(snap.subtitle)}</div>'
2513
3374
  if snap.subtitle else ""
2514
3375
  )
3376
+ # Unconditional (#503 S2 D1) — see `share_facts_line`.
3377
+ facts_html = (
3378
+ f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">'
3379
+ f'{_xml_escape(share_facts_line(snap, shows_chart=snap.chart is not None, shows_table=_has_table(snap)))}</div>'
3380
+ )
2515
3381
  timestamp_html = (
2516
3382
  f'<div style="font-size:11px;color:{palette["muted"]};margin-top:4px">'
2517
3383
  f'{_format_generated_at_iso(snap.generated_at)}</div>'
2518
3384
  )
2519
- table_html = _render_html_table(snap, palette) if snap.columns else ""
3385
+ table_html = _render_html_table(snap, palette) if _has_table(snap) else ""
3386
+ # #503 S2 D3 — totals render in all three formats. `budget`'s verdict
3387
+ # and target live only here, so HTML omitted both entirely.
3388
+ totals_html = (
3389
+ f'<ul aria-label="Totals" style="margin-top:12px;padding-left:18px;'
3390
+ f'font-size:13px;color:{palette["fg"]}">'
3391
+ + "".join(
3392
+ f'<li><strong>{_xml_escape(total.label)}:</strong> '
3393
+ f'{_xml_escape(total.value)}</li>'
3394
+ for total in snap.totals
3395
+ )
3396
+ + "</ul>"
3397
+ if snap.totals else ""
3398
+ )
2520
3399
  notes_html = (
2521
3400
  f'<aside aria-label="Data notes" '
2522
3401
  f'style="margin-top:14px;padding:8px 10px;border-left:3px solid {palette["ref_warn"]};'
@@ -2541,9 +3420,11 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
2541
3420
  else:
2542
3421
  footer_html = ""
2543
3422
  return (
2544
- f'<header>{title_html}{source_html}{availability_html}{subtitle_html}{timestamp_html}</header>'
3423
+ f'<header>{title_html}{source_html}{availability_html}{subtitle_html}'
3424
+ f'{facts_html}{timestamp_html}</header>'
2545
3425
  f'{chart_html}'
2546
3426
  f'{table_html}'
3427
+ f'{totals_html}'
2547
3428
  f'{notes_html}'
2548
3429
  f'{footer_html}'
2549
3430
  )
@@ -2553,7 +3434,7 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
2553
3434
 
2554
3435
  def _render_md_table(snap: ShareSnapshot) -> str:
2555
3436
  """Markdown table per ColumnSpec + Row contract."""
2556
- if not snap.columns:
3437
+ if not _has_table(snap):
2557
3438
  return ""
2558
3439
  head = "| " + " | ".join(_md_escape(c.label) for c in snap.columns) + " |"
2559
3440
  sep = "|" + "|".join(
@@ -2607,70 +3488,274 @@ def _render_svg_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
2607
3488
  # --- Print stylesheet + MD frontmatter (placeholders for M2.x layering) ---
2608
3489
 
2609
3490
  def _print_stylesheet() -> str:
2610
- """Print-only CSS injected into HTML <head> for PDF export polish (spec §11.2).
2611
-
2612
- M1.2 stub returned this string but it was NOT wired into `_wrap_document`
2613
- yet to keep v1 HTML goldens byte-stable through M1-M3. M4.2 wires it in
2614
- so Print → PDF on a dark-theme export renders as black-on-white instead
2615
- of a solid-black page, and forces page-break-inside avoidance on
2616
- semantic blocks. v1 + v2 HTML goldens re-baseline once on first run
2617
- after this change and are byte-stable thereafter; MD + SVG goldens are
2618
- unaffected (the stylesheet only lives in the HTML document head).
3491
+ """Print-only CSS injected into HTML <head> so a dark export prints.
3492
+
3493
+ `body * { color: #000 !important }` is the rule that reaches the
3494
+ document (#503 S2 F14). `!important` resolves the cascade among
3495
+ declarations for ONE element and does not propagate through
3496
+ inheritance, and every descendant of `body` here carries an inline
3497
+ `color`, so each has a specified value and never inherits — a rule on
3498
+ `body` alone leaves the dark palette at 1.24:1.
3499
+
3500
+ The embedded chart SVG needs its own rules twice over: its text is
3501
+ coloured by `fill`, which no rule about `color` touches, and it paints
3502
+ a full-canvas rectangle plus `fill` / `stroke` presentation attributes
3503
+ that are not `color` either.
3504
+
3505
+ THE DARK-TO-LIGHT MAP IS NOT VALUE-TO-VALUE, and cannot be. `#1f2937`
3506
+ is both `grid` and `table_row_alt`, whose light counterparts are
3507
+ `#e5e7eb` and `#f9fafb`; and the dark `fg` `#e5e7eb` is itself the
3508
+ light `grid`. So the selectors discriminate by CONTEXT — `rect` fill
3509
+ versus `line` stroke — rather than by value alone.
3510
+
3511
+ THE DATA COLOURS ARE MAPPED TOO (#503 S2 review F1/F2), because they
3512
+ are not legible on white: dark `ref_warn` #fbbf24 measures 1.67:1
3513
+ where its light counterpart #d97706 measures 3.19:1, dark `ref_alarm`
3514
+ #f87171 measures 2.77:1 against 4.83:1, and dark `series_primary`
3515
+ #60a5fa measures 2.54:1 against 5.17:1. A reference LABEL is mapped
3516
+ alongside its LINE, so the colour that encodes severity stays paired;
3517
+ the per-value `text[fill=…]` rules outrank the blanket `svg text` one
3518
+ on specificity.
3519
+
3520
+ THAT PAIRING IS DECLARED FOR BOTH THEMES, not only the dark one. The
3521
+ blanket `svg text` rule is theme-blind and a map keyed only on the
3522
+ dark values is not, so a LIGHT forecast printed a black `90%` above
3523
+ an amber reference line while the dark forecast of the same report
3524
+ printed both amber. The light entries are identity rules whose only
3525
+ job is to outrank the blanket rule (#503 S2 second review N7).
3526
+
3527
+ `print-color-adjust: exact` appears only to stop the browser dropping
3528
+ the corrected colours and light backgrounds; it is never the
3529
+ correction itself.
3530
+
3531
+ The zebra stripe is preserved through `nth-child`, which reproduces the
3532
+ light palette's own alternation, rather than through a fragile
3533
+ `[style*=…]` match on the inline background.
3534
+
3535
+ THE TWO SCREEN SCROLL BOXES ARE RELEASED HERE, and the embedded chart
3536
+ is given back the proportional sizing the screen answer took away
3537
+ (#503 S2 third review). On screen the chart and the table each keep
3538
+ their intrinsic width inside an `overflow:auto` box, so the document
3539
+ never scrolls sideways. Paper has no scrolling, and a print engine
3540
+ CLIPS a scroll container to its box, so the chart lost everything
3541
+ past the content box — the bar value labels and the last axis tick.
3542
+ `overflow: visible` answers that, and `max-width: 100%; height: auto`
3543
+ on the chart restores the scaling it had before the screen fix split
3544
+ the two roots apart.
3545
+
3546
+ THE CONTENT BOX IS 680px, NOT 640 (#503 S2 fourth review). `body`
3547
+ declares `padding:20px; max-width:680px` under the default
3548
+ `content-box` sizing, so the padding lies OUTSIDE the 680px content
3549
+ box rather than inside it, and no rule in an emitted artifact sets
3550
+ `box-sizing`. Measured against the true box, NONE of the 83 embedded
3551
+ charts in the committed goldens overflows: the widest declares 670.9.
3552
+ An earlier record of this fix read "35 of 83 above 640", which is
3553
+ true against 640 and false against the real box; the count is
3554
+ corrected here rather than the claim, because THE FIX IS STILL
3555
+ NECESSARY and was verified independently of the goldens. Real data
3556
+ produces a 755.3px chart from `project --reveal-projects`, and a
3557
+ real-PDF A/B showed the control losing the top bar's `$2,814.88`
3558
+ value label while the corrected version keeps it.
3559
+
3560
+ THE TABLE HALF IS SPECIFICATION-CONFORMANCE INSURANCE, NOT AN
3561
+ OBSERVED CHROMIUM FIX. CSS fragmentation makes a box whose `overflow`
3562
+ is not `visible` monolithic, which is what the rule is written
3563
+ against — but Chromium 151 fragments an `overflow:auto` box across
3564
+ pages anyway: a 240-row table paginates to 8 pages carrying all 240
3565
+ rows, identically with and without this rule, and identically inside
3566
+ a `break-inside: avoid` section. The rule is kept because it matches
3567
+ the specification and other engines may follow it; do not cite it as
3568
+ a defect anybody has seen.
3569
+
3570
+ The scroll-box selector is BUILT FROM `_HTML_SCROLL_BOX_STYLE`, the
3571
+ same constant the two wrappers are emitted with, so the rule cannot
3572
+ drift away from the markup it exists to reach. That is what
3573
+ distinguishes it from the `[style*=…]` match rejected for the zebra
3574
+ stripe, which would have keyed on a palette VALUE that varies per row
3575
+ and per theme.
3576
+
3577
+ Kept to ONE `<style>` element on one line, so the delta in each
3578
+ affected HTML golden is a single replaced line that can be audited
3579
+ mechanically. A per-element inline-colour change would produce a
3580
+ per-element delta across all of them.
3581
+
3582
+ SCOPE: printed HTML, including its embedded chart SVG. A standalone
3583
+ `.svg` artifact carries no document head and no stylesheet, so it is
3584
+ out of scope.
2619
3585
  """
3586
+ light = PALETTE_LIGHT
3587
+ dark = PALETTE_DARK
3588
+ # Data roles, mapped index-wise so a re-picked palette stays paired.
3589
+ data_pairs = [(dark[role], light[role])
3590
+ for role in ("series_primary", "series_secondary",
3591
+ "ref_warn", "ref_alarm")]
3592
+ data_pairs += list(zip(dark["series_palette"], light["series_palette"]))
3593
+ seen: set = set()
3594
+ fills = []
3595
+ strokes = []
3596
+ for dark_value, light_value in data_pairs:
3597
+ if dark_value in seen:
3598
+ continue
3599
+ seen.add(dark_value)
3600
+ # `rect` is the only element this kernel currently fills with a
3601
+ # data colour, but `path` and `polyline` are filled elements too,
3602
+ # so a future filled area chart would otherwise print dark. Named
3603
+ # here rather than recorded as a known gap (#503 S2 second review
3604
+ # N8).
3605
+ fills.append(
3606
+ f' svg rect[fill="{dark_value}"],'
3607
+ f' svg path[fill="{dark_value}"],'
3608
+ f' svg polyline[fill="{dark_value}"]'
3609
+ f' {{ fill: {light_value} !important; }}')
3610
+ strokes.append(
3611
+ f' svg line[stroke="{dark_value}"] {{ stroke: {light_value} !important; }}'
3612
+ f' svg polyline[stroke="{dark_value}"] {{ stroke: {light_value} !important; }}'
3613
+ f' svg path[stroke="{dark_value}"] {{ stroke: {light_value} !important; }}')
3614
+ # Text roles. The per-value rules outrank the blanket `svg text` one
3615
+ # on specificity, so a reference label prints the colour its line does.
3616
+ #
3617
+ # BOTH palettes are listed, because the blanket rule is theme-blind
3618
+ # while the map was not: a LIGHT artifact already carries the light
3619
+ # `ref_warn`, which no dark-keyed rule matches, so `svg text` won and
3620
+ # printed a black `90%` above an amber reference line while the dark
3621
+ # artifact of the same report printed both in amber (#503 S2 second
3622
+ # review N7). The light entry is an identity rule — it re-states the
3623
+ # colour the element already has, at a specificity that beats the
3624
+ # blanket one.
3625
+ # Deduplicated by SOURCE VALUE, exactly as the fill map is. Keying
3626
+ # on the (source, light) PAIR would let one source value carry two
3627
+ # rules with the same selector and different values, where the later
3628
+ # one silently wins — the shape the fill map avoids by construction
3629
+ # (#503 S2 third review). No collision exists in the shipped
3630
+ # palettes, so this changes no byte today.
3631
+ text_roles = ("muted", "footer_link", "ref_warn", "ref_alarm")
3632
+ text_values: list[tuple[str, str]] = []
3633
+ text_seen: set = set()
3634
+ for role in text_roles:
3635
+ for source_value in (dark[role], light[role]):
3636
+ if source_value in text_seen:
3637
+ continue
3638
+ text_seen.add(source_value)
3639
+ text_values.append((source_value, light[role]))
3640
+ text_rules = "".join(
3641
+ f' svg text[fill="{source_value}"] {{ fill: {light_value} !important; }}'
3642
+ for source_value, light_value in text_values
3643
+ )
2620
3644
  return (
2621
3645
  '<style>@media print {'
2622
3646
  ' body { color-scheme: light; background: #fff !important; color: #000 !important; }'
2623
- ' header, footer, section { page-break-inside: avoid; }'
3647
+ ' body * { color: #000 !important; }'
3648
+ f' th {{ background: {light["table_header_bg"]} !important; }}'
3649
+ f' td {{ background: {light["bg"]} !important; }}'
3650
+ f' tbody tr:nth-child(even) td {{ background: {light["table_row_alt"]} !important; }}'
3651
+ ' th, td { print-color-adjust: exact; -webkit-print-color-adjust: exact; }'
3652
+ ' header, footer, section { break-inside: avoid; page-break-inside: avoid; }'
3653
+ f' div[style*="{_HTML_SCROLL_BOX_STYLE}"] {{ overflow: visible !important; }}'
3654
+ ' svg { max-width: 100% !important; height: auto !important; }'
3655
+ f' svg text {{ fill: {light["fg"]} !important; }}'
3656
+ f'{text_rules}'
3657
+ f' svg rect[fill="{dark["bg"]}"] {{ fill: {light["bg"]} !important; }}'
3658
+ f' svg rect[fill="{dark["table_header_bg"]}"] {{ fill: {light["table_header_bg"]} !important; }}'
3659
+ f' svg rect[fill="{dark["table_row_alt"]}"] {{ fill: {light["table_row_alt"]} !important; }}'
3660
+ f' svg line[stroke="{dark["grid"]}"] {{ stroke: {light["grid"]} !important; }}'
3661
+ f' svg line[stroke="{dark["axis"]}"] {{ stroke: {light["axis"]} !important; }}'
3662
+ + "".join(fills) + "".join(strokes) +
3663
+ ' svg rect, svg path, svg polyline, svg line'
3664
+ ' { print-color-adjust: exact; -webkit-print-color-adjust: exact; }'
2624
3665
  '}</style>'
2625
3666
  )
2626
3667
 
2627
3668
 
2628
- def _build_md_frontmatter(snap: ShareSnapshot) -> str:
2629
- """YAML frontmatter prepended to MD exports (spec §11.5).
3669
+ @dataclass(frozen=True)
3670
+ class _FrontmatterMeta:
3671
+ """What a Markdown document states about itself in its frontmatter.
3672
+
3673
+ An explicit argument rather than a `ShareSnapshot`, because a composed
3674
+ document has no single snapshot to read: its title comes from
3675
+ `ComposeOptions`, its period is the union across sections, its panel
3676
+ is the literal `composed`, and it deliberately carries no template id.
3677
+ `_stitch_md` used to build that block by hand, so the two frontmatter
3678
+ writers could drift; now both go through one builder.
3679
+ """
3680
+ title: str
3681
+ generated_at: datetime
3682
+ period_start: datetime
3683
+ period_end: datetime
3684
+ panel: str
3685
+ template_id: str | None
3686
+ anonymized: bool
3687
+ version: str
2630
3688
 
2631
- Byte-stable: key order is fixed (title -> generated_at -> period ->
2632
- panel -> optional template_id -> anonymized -> cctally_version);
2633
- single-line values; no eolian formatting. `_wrap_document` strips this when
2634
- `branding=False` so `--no-branding` behaves consistently with the
2635
- HTML/SVG footer-link stripping.
2636
3689
 
2637
- `template_id` is present for dashboard share-v2 snapshots and omitted
2638
- for legacy CLI snapshots that have no template recipe.
3690
+ def _frontmatter_meta_for_snapshot(snap: ShareSnapshot) -> _FrontmatterMeta:
3691
+ """Frontmatter metadata for a single prepared snapshot.
2639
3692
 
2640
- `anonymized` reports the MODE the document was rendered in, read off the
2641
- provenance marker preparation stamped: `not reveal_projects`. It used to
2642
- be INFERRED by regex-matching `project-\\d+` over the labels, which was
2643
- wrong three ways — it never inspected the chart, so `sessions-visual`
2644
- stamped `false` onto a demonstrably scrubbed snapshot; a real project
2645
- named `project-1` reported as anonymized; and a silently failed scrub
2646
- producing conforming labels was indistinguishable from a successful one.
3693
+ `anonymized` reports the MODE the document was rendered in, read off
3694
+ the provenance marker preparation stamped: `not reveal_projects`. It
3695
+ used to be INFERRED by regex-matching `project-\\d+` over the labels,
3696
+ which was wrong three ways — it never inspected the chart, so
3697
+ `sessions-visual` stamped `false` onto a demonstrably scrubbed
3698
+ snapshot; a real project named `project-1` reported as anonymized; and
3699
+ a silently failed scrub producing conforming labels was
3700
+ indistinguishable from a successful one.
2647
3701
 
2648
3702
  An UNPREPARED snapshot never went through the privacy contract, so the
2649
3703
  document cannot claim anonymization and reports `false`. That reaches
2650
3704
  only the `_render_md` back-compat shim; `render()` always prepares.
2651
3705
  """
2652
- period = snap.period
3706
+ prov = _provenance_of(snap)
3707
+ # Markdown's own period, so the frontmatter and the facts strip in
3708
+ # the SAME document cannot disagree (#503 S2 third review).
3709
+ period = _md_effective_period(snap)
3710
+ return _FrontmatterMeta(
3711
+ title=snap.title,
3712
+ generated_at=snap.generated_at,
3713
+ period_start=period.start,
3714
+ period_end=period.end,
3715
+ panel=snap.cmd,
3716
+ template_id=snap.template_id,
3717
+ anonymized=prov is not None and not prov.reveal_projects,
3718
+ version=snap.version,
3719
+ )
3720
+
3721
+
3722
+ def _build_md_frontmatter(meta: _FrontmatterMeta, *, branding: bool) -> str:
3723
+ """YAML frontmatter prepended to MD exports (spec §11.5).
3724
+
3725
+ Byte-stable: key order is fixed (title -> generated_at -> period ->
3726
+ panel -> optional template_id -> anonymized -> cctally_version);
3727
+ single-line values; no eolian formatting.
3728
+
3729
+ `branding=False` removes `cctally_version` AND NOTHING ELSE (#503 S2
3730
+ D2). It used to suppress the whole block, justified as consistency
3731
+ with the HTML and SVG footer-link stripping — but those lose exactly
3732
+ one `<footer>` element each and keep their timestamps, while Markdown
3733
+ lost its title, its period, its panel and its privacy mode. A reader
3734
+ of an unbranded export could no longer tell what the file covered.
3735
+ `--no-branding` removes the advertisement; the provenance stays.
3736
+
3737
+ `panel` and `template_id` are artifact identity, not branding, so the
3738
+ gate leaves both. `template_id` is present for dashboard share-v2
3739
+ snapshots and absent for legacy CLI and composed documents, which have
3740
+ no single template recipe.
3741
+ """
2653
3742
  period_iso = (
2654
- f"{_format_generated_at_iso(period.start)}.."
2655
- f"{_format_generated_at_iso(period.end)}"
3743
+ f"{_format_generated_at_iso(meta.period_start)}.."
3744
+ f"{_format_generated_at_iso(meta.period_end)}"
2656
3745
  )
2657
- prov = _provenance_of(snap)
2658
- anonymized = "true" if (prov is not None and not prov.reveal_projects) else "false"
2659
3746
  lines = [
2660
3747
  "---",
2661
- f"title: {_yaml_scalar(snap.title)}",
2662
- f"generated_at: {_format_generated_at_iso(snap.generated_at)}",
3748
+ f"title: {_yaml_scalar(meta.title)}",
3749
+ f"generated_at: {_format_generated_at_iso(meta.generated_at)}",
2663
3750
  f"period: {period_iso}",
2664
- f"panel: {snap.cmd}",
3751
+ f"panel: {meta.panel}",
2665
3752
  ]
2666
- if snap.template_id:
2667
- lines.append(f"template_id: {_yaml_scalar(snap.template_id)}")
2668
- lines.extend([
2669
- f"anonymized: {anonymized}",
2670
- f"cctally_version: {snap.version}",
2671
- "---",
2672
- "",
2673
- ])
3753
+ if meta.template_id:
3754
+ lines.append(f"template_id: {_yaml_scalar(meta.template_id)}")
3755
+ lines.append(f"anonymized: {'true' if meta.anonymized else 'false'}")
3756
+ if branding:
3757
+ lines.append(f"cctally_version: {meta.version}")
3758
+ lines.extend(["---", ""])
2674
3759
  return "\n".join(lines)
2675
3760
 
2676
3761
 
@@ -2693,7 +3778,10 @@ def _yaml_scalar(s: str) -> str:
2693
3778
  # --- Fragment + wrap ---
2694
3779
 
2695
3780
  def _render_fragment(snap: ShareSnapshot, *, format: str,
2696
- palette: Mapping[str, str], branding: bool) -> "str | tuple[str, float, float]":
3781
+ palette: Mapping[str, str], branding: bool,
3782
+ heading_level: int = 1,
3783
+ heading_override: "str | None" = None,
3784
+ suppress_provider: bool = False) -> "str | tuple[str, float, float]":
2697
3785
  """Body-only render — no document chrome.
2698
3786
 
2699
3787
  Returns:
@@ -2704,13 +3792,23 @@ def _render_fragment(snap: ShareSnapshot, *, format: str,
2704
3792
  Callers compose this into either:
2705
3793
  - render(): wraps in full document chrome via `_wrap_document`.
2706
3794
  - compose(): stitches multiple fragments under one wrapper (M3.x).
3795
+
3796
+ The three heading arguments reach Markdown and HTML only. SVG has no
3797
+ heading rank, and D6 excludes it from provider qualification because
3798
+ its provider and availability text are one text node.
2707
3799
  """
2708
3800
  if format == "html":
2709
- return _render_html_fragment(snap, palette=palette, branding=branding)
3801
+ return _render_html_fragment(
3802
+ snap, palette=palette, branding=branding,
3803
+ heading_level=heading_level, heading_override=heading_override,
3804
+ suppress_provider=suppress_provider)
2710
3805
  if format == "svg":
2711
3806
  return _render_svg_fragment(snap, palette=palette, branding=branding)
2712
3807
  if format == "md":
2713
- return _render_md_fragment(snap, branding=branding)
3808
+ return _render_md_fragment(
3809
+ snap, branding=branding, heading_level=heading_level,
3810
+ heading_override=heading_override,
3811
+ suppress_provider=suppress_provider)
2714
3812
  raise ValueError(f"unknown format: {format!r}")
2715
3813
 
2716
3814
 
@@ -2723,10 +3821,12 @@ def _wrap_document(fragment, *, format: str, palette: Mapping[str, str] | None,
2723
3821
  character-for-character. The v1 share goldens (`bin/cctally-share-test`)
2724
3822
  are the gate.
2725
3823
 
2726
- MD: prepends `_build_md_frontmatter(snap)` when `branding=True` (spec
2727
- §11.5). Suppressed when `branding=False` -- same surface as the
2728
- HTML/SVG footer-link strip done inside the per-format renderers --
2729
- so `--no-branding` behaves consistently across all three formats.
3824
+ MD: always prepends `_build_md_frontmatter` (spec §11.5). Under
3825
+ `branding=False` the builder drops `cctally_version` and keeps every
3826
+ other key (#503 S2 D2), which is what makes the Markdown strip the
3827
+ same size as the HTML and SVG ones: those lose one `<footer>` element
3828
+ and keep their timestamps. Suppressing the whole block cost an
3829
+ unbranded export its title, period, panel and privacy mode.
2730
3830
  """
2731
3831
  if format == "html":
2732
3832
  return (
@@ -2745,19 +3845,19 @@ def _wrap_document(fragment, *, format: str, palette: Mapping[str, str] | None,
2745
3845
  # `_fmt_num`) so single-section wraps are byte-identical to the v1
2746
3846
  # producer. The 0 0 origin matches `_render_svg`'s `viewBox` literal.
2747
3847
  return (
2748
- f'<svg xmlns="http://www.w3.org/2000/svg" '
3848
+ f'<svg {_SVG_ROOT_ATTRS} '
2749
3849
  f'viewBox="0 0 {_fmt_num(w)} {_fmt_num(h)}" '
2750
3850
  f'width="{_fmt_num(w)}" height="{_fmt_num(h)}">'
2751
3851
  f'{inner}'
2752
3852
  f'</svg>'
2753
3853
  )
2754
3854
  if format == "md":
2755
- front = _build_md_frontmatter(snap) if branding else ""
3855
+ front = _build_md_frontmatter(_frontmatter_meta_for_snapshot(snap),
3856
+ branding=branding)
2756
3857
  # Frontmatter already ends with "---\n" (trailing "" in the join
2757
3858
  # adds the separator newline); concat directly so the byte shape
2758
- # is `---\n...---\n<fragment>`. When branding=False, front is
2759
- # "" and the fragment passes through untouched.
2760
- return (front + fragment) if front else fragment
3859
+ # is `---\n...---\n<fragment>`.
3860
+ return front + fragment
2761
3861
  raise ValueError(f"unknown format: {format!r}")
2762
3862
 
2763
3863
 
@@ -2794,11 +3894,26 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
2794
3894
  )
2795
3895
  for sec in sections
2796
3896
  )
3897
+ # D6 — an all-source document qualifies its section headings by
3898
+ # provider. Decided HERE, once, rather than by each caller: both the
3899
+ # dashboard and the CLI all-source path pass source-bearing snapshots
3900
+ # into `compose()`, so caller-side qualification would duplicate the
3901
+ # policy and could disagree between them. The predicate is "more than
3902
+ # one distinct `snap.source`", because a single-provider composition
3903
+ # needs no disambiguation and must stay byte-identical.
3904
+ qualify_providers = len({sec.snap.source for sec in prepared}) > 1
2797
3905
  if fmt == "html":
2798
- body = _stitch_html(prepared, opts=opts)
3906
+ body = _stitch_html(prepared, opts=opts,
3907
+ qualify_providers=qualify_providers)
2799
3908
  elif fmt == "md":
2800
- body = _stitch_md(prepared, opts=opts)
3909
+ body = _stitch_md(prepared, opts=opts,
3910
+ qualify_providers=qualify_providers)
2801
3911
  elif fmt == "svg":
3912
+ # SVG is excluded from D6 deliberately: its provider and
3913
+ # availability text share one text node, so suppressing the
3914
+ # provider would also lose `No data` / `Unavailable: <reason>`.
3915
+ # Composed SVG already renders per-section provider chrome, so its
3916
+ # sections are distinguishable without the qualifier.
2802
3917
  body = _stitch_svg(prepared, opts=opts)
2803
3918
  else:
2804
3919
  raise ValueError(f"unknown format: {fmt!r}")
@@ -2809,7 +3924,7 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
2809
3924
 
2810
3925
 
2811
3926
  def _stitch_html(sections: tuple[ComposedSection, ...], *,
2812
- opts: ComposeOptions) -> str:
3927
+ opts: ComposeOptions, qualify_providers: bool = False) -> str:
2813
3928
  """HTML compose: single ``<html><body>`` wrapper, sections as ``<section>`` blocks."""
2814
3929
  palette = PALETTE_LIGHT if opts.theme == "light" else PALETTE_DARK
2815
3930
  body_open = (
@@ -2817,18 +3932,38 @@ def _stitch_html(sections: tuple[ComposedSection, ...], *,
2817
3932
  f'font-family:system-ui,-apple-system,sans-serif;'
2818
3933
  f'padding:20px;max-width:680px;margin:auto">'
2819
3934
  )
2820
- header = f'<header><h1>{_xml_escape(opts.title)}</h1></header>'
3935
+ # #503 S2 F13a — this `<h1>` was the ONLY text-bearing element in the
3936
+ # whole composed HTML document with no specified colour, so it
3937
+ # resolved to the user agent's default black and rendered invisible on
3938
+ # the dark palette's #0b0f17 background. Every other element in both
3939
+ # the stitcher and the fragment carries an explicit inline colour.
3940
+ header = (
3941
+ f'<header><h1 style="color:{palette["fg"]}">'
3942
+ f'{_xml_escape(opts.title)}</h1></header>'
3943
+ )
2821
3944
  blocks = []
2822
3945
  for sec in sections:
2823
3946
  # branding here is for the *fragment* — composite footer is one
2824
3947
  # level up, so per-section branding is unconditional False to
2825
3948
  # keep the chrome single.
2826
- frag = _render_fragment(sec.snap, format="html",
2827
- palette=palette, branding=False)
3949
+ frag = _render_fragment(
3950
+ sec.snap, format="html", palette=palette, branding=False,
3951
+ heading_level=2,
3952
+ heading_override=(
3953
+ f"{sec.snap.title} — {_provider_label(sec.snap)}"
3954
+ if qualify_providers else None),
3955
+ suppress_provider=qualify_providers)
2828
3956
  blocks.append(f'<section class="share-section">{frag}</section>')
3957
+ # #503 S2 review F7 — the same attribution the standalone footer
3958
+ # carries, in the same markup, so a composed artifact does not drop
3959
+ # provenance a single-panel one keeps. The version comes from the
3960
+ # first section, as the composite frontmatter's already does.
2829
3961
  footer = (
2830
3962
  f'<footer style="font-size:11px;color:{palette["muted"]};margin-top:24px">'
2831
- f'cctally · composed</footer>' if not opts.no_branding else ""
3963
+ f'Generated by cctally · '
3964
+ f'<a href="https://github.com/omrikais/cctally" style="color:{palette["footer_link"]}">github.com/omrikais/cctally</a>'
3965
+ f' · {_version_label(sections[0].snap.version)}'
3966
+ f'</footer>' if not opts.no_branding else ""
2832
3967
  )
2833
3968
  return (
2834
3969
  f'<!DOCTYPE html>'
@@ -2842,40 +3977,40 @@ def _stitch_html(sections: tuple[ComposedSection, ...], *,
2842
3977
 
2843
3978
 
2844
3979
  def _stitch_md(sections: tuple[ComposedSection, ...], *,
2845
- opts: ComposeOptions) -> str:
2846
- """MD compose: one composite frontmatter + ``## `` headers + bodies."""
3980
+ opts: ComposeOptions, qualify_providers: bool = False) -> str:
3981
+ """MD compose: one composite frontmatter + one H2-headed body per section."""
2847
3982
  parts: list[str] = []
2848
- if not opts.no_branding:
2849
- # Composite frontmatter: same key set as the single-section
2850
- # `_build_md_frontmatter` but `panel` becomes `composed` and
2851
- # `template_id` is omitted because one composed document can contain
2852
- # multiple section templates.
2853
- # `generated_at` and `cctally_version` are taken from the first
2854
- # section since the composite document has no independent
2855
- # provenance — every section was rendered in the same request.
2856
- first_snap = sections[0].snap
2857
- # `period` for the composite document = earliest start ..
2858
- # latest end across all sections (per spec §11.5 implied
2859
- # convention; reference test uses identical periods so the
2860
- # union collapses).
2861
- earliest = min(sec.snap.period.start for sec in sections)
2862
- latest = max(sec.snap.period.end for sec in sections)
2863
- # #503 S1: the composite frontmatter reports the composite MODE.
2864
- # `compose()` re-renders every section with `opts.reveal_projects`
2865
- # and discards each section's add-time value, so the composite flag
2866
- # is the whole truth about what the document contains.
2867
- anon_field = "false" if opts.reveal_projects else "true"
2868
- parts.append(
2869
- "---\n"
2870
- f"title: {_yaml_scalar(opts.title)}\n"
2871
- f"generated_at: {_format_generated_at_iso(first_snap.generated_at)}\n"
2872
- f"period: {_format_generated_at_iso(earliest)}.."
2873
- f"{_format_generated_at_iso(latest)}\n"
2874
- f"panel: composed\n"
2875
- f"anonymized: {anon_field}\n"
2876
- f"cctally_version: {first_snap.version}\n"
2877
- "---\n\n"
2878
- )
3983
+ # Composite frontmatter through the SAME builder the single-section
3984
+ # path uses, so the two writers cannot drift in key order or in what
3985
+ # the branding gate removes (#503 S2 D2).
3986
+ #
3987
+ # `panel` is the literal `composed` and there is no `template_id`,
3988
+ # because one composed document can contain several section templates.
3989
+ # `generated_at` and `version` come from the first section: the
3990
+ # composite has no independent provenance, since every section was
3991
+ # rendered in the same request. The period is the union — earliest
3992
+ # start to latest end.
3993
+ #
3994
+ # #503 S1: `anonymized` reports the COMPOSITE mode. `compose()`
3995
+ # re-renders every section with `opts.reveal_projects` and discards
3996
+ # each section's add-time value, so that flag is the whole truth about
3997
+ # what the document contains.
3998
+ first_snap = sections[0].snap
3999
+ parts.append(_build_md_frontmatter(
4000
+ _FrontmatterMeta(
4001
+ title=opts.title,
4002
+ generated_at=first_snap.generated_at,
4003
+ period_start=min(_md_effective_period(sec.snap).start
4004
+ for sec in sections),
4005
+ period_end=max(_md_effective_period(sec.snap).end
4006
+ for sec in sections),
4007
+ panel="composed",
4008
+ template_id=None,
4009
+ anonymized=not opts.reveal_projects,
4010
+ version=first_snap.version,
4011
+ ),
4012
+ branding=not opts.no_branding,
4013
+ ) + "\n")
2879
4014
  # Title as H1 (when frontmatter is present, this duplicates the
2880
4015
  # title key visually — accept the duplication; markdown readers
2881
4016
  # vary in how they render frontmatter and the H1 is the universal
@@ -2886,9 +4021,18 @@ def _stitch_md(sections: tuple[ComposedSection, ...], *,
2886
4021
  parts.append(f"# {_md_escape(opts.title)}\n\n")
2887
4022
  last_idx = len(sections) - 1
2888
4023
  for idx, sec in enumerate(sections):
2889
- frag = _render_fragment(sec.snap, format="md", palette=PALETTE_LIGHT,
2890
- branding=False)
2891
- parts.append(f"## {_md_escape(sec.snap.title)}\n\n")
4024
+ # The section heading is the FRAGMENT's own heading, rendered at
4025
+ # level 2. This function used to emit a `## <title>` of its own
4026
+ # ahead of a fragment that opened with `# <title>`, so every
4027
+ # composed section printed its title twice at two different ranks
4028
+ # (#503 S2 F12). Escaping still happens inside the fragment.
4029
+ frag = _render_fragment(
4030
+ sec.snap, format="md", palette=PALETTE_LIGHT, branding=False,
4031
+ heading_level=2,
4032
+ heading_override=(
4033
+ f"{sec.snap.title} — {_provider_label(sec.snap)}"
4034
+ if qualify_providers else None),
4035
+ suppress_provider=qualify_providers)
2892
4036
  parts.append(frag.rstrip("\n"))
2893
4037
  parts.append("\n\n" if idx < last_idx else "\n")
2894
4038
  return "".join(parts)
@@ -2898,9 +4042,13 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
2898
4042
  opts: ComposeOptions) -> str:
2899
4043
  """SVG compose: single outer ``<svg>``, sections positioned vertically.
2900
4044
 
2901
- `opts.no_branding` is intentionally unused: the SVG composite has no
2902
- chrome footer band, so there is nothing to strip. HTML stitcher uses
2903
- it to gate the `<footer>cctally · composed</footer>` line.
4045
+ Carries the same three pieces of chrome the standalone SVG and the
4046
+ composed HTML both carry (#503 S2 F13b).
4047
+
4048
+ Layout: one full-canvas background rect (the standalone SVG contract),
4049
+ then a title band, then the section stack, then a branding-gated
4050
+ footer whose text matches the composed HTML attribution. The title is
4051
+ NOT branding — it names the document — so `no_branding` leaves it.
2904
4052
  """
2905
4053
  palette = PALETTE_LIGHT if opts.theme == "light" else PALETTE_DARK
2906
4054
  inners: list[tuple[str, float, float]] = []
@@ -2908,17 +4056,45 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
2908
4056
  inner, w, h = _render_fragment(sec.snap, format="svg",
2909
4057
  palette=palette, branding=False)
2910
4058
  inners.append((inner, w, h))
2911
- total_w = max(w for _, w, _ in inners)
2912
- total_h = sum(h for _, _, h in inners) + _SVG_SECTION_GAP * (len(inners) - 1)
2913
- body_blocks: list[str] = []
2914
- y = 0.0
4059
+ footer_text = _attribution_text(sections[0].snap.version)
4060
+ # The composite title and footer CONTRIBUTE to the width (#503 S2
4061
+ # review F8). It used to be the section maximum alone, so an 18pt
4062
+ # title longer than the widest section ran off the viewBox — the
4063
+ # standalone bounds sweep covers templates only and could not see it.
4064
+ total_w = max(
4065
+ max(w for _, w, _ in inners),
4066
+ _SVG_PADDING * 2 + _svg_text_width(opts.title, 18.0),
4067
+ 0.0 if opts.no_branding
4068
+ else _SVG_PADDING * 2 + _svg_text_width(footer_text, 10.0),
4069
+ )
4070
+ stack_h = (sum(h for _, _, h in inners)
4071
+ + _SVG_SECTION_GAP * (len(inners) - 1))
4072
+ header_h = _SVG_COMPOSITE_HEADER_H
4073
+ footer_h = 0.0 if opts.no_branding else _SVG_COMPOSITE_FOOTER_H
4074
+ total_h = header_h + stack_h + footer_h
4075
+
4076
+ body_blocks: list[str] = [
4077
+ svg_rect(0, 0, total_w, total_h, fill=palette["bg"]),
4078
+ svg_group([
4079
+ svg_text(_SVG_PADDING, _SVG_COMPOSITE_TITLE_BASELINE, opts.title,
4080
+ font_size=18, fill=palette["fg"], weight="bold"),
4081
+ ]),
4082
+ ]
4083
+ y = header_h
2915
4084
  for inner, _w, h in inners:
2916
4085
  body_blocks.append(
2917
4086
  f'<g transform="translate(0,{_fmt_num(y)})">{inner}</g>'
2918
4087
  )
2919
4088
  y += h + _SVG_SECTION_GAP
4089
+ if not opts.no_branding:
4090
+ body_blocks.append(svg_group([
4091
+ svg_text(_SVG_PADDING,
4092
+ header_h + stack_h + _SVG_COMPOSITE_FOOTER_BASELINE,
4093
+ footer_text,
4094
+ font_size=10, fill=palette["footer_link"]),
4095
+ ]))
2920
4096
  return (
2921
- f'<svg xmlns="http://www.w3.org/2000/svg" '
4097
+ f'<svg {_SVG_ROOT_ATTRS} '
2922
4098
  f'viewBox="0 0 {_fmt_num(total_w)} {_fmt_num(total_h)}" '
2923
4099
  f'width="{_fmt_num(total_w)}" height="{_fmt_num(total_h)}">'
2924
4100
  f'{"".join(body_blocks)}'