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/CHANGELOG.md +48 -0
- package/bin/_cctally_cache.py +55 -6
- package/bin/_cctally_codex.py +35 -3
- package/bin/_cctally_config.py +108 -2
- package/bin/_cctally_core.py +37 -8
- package/bin/_cctally_dashboard.py +6 -0
- package/bin/_cctally_dashboard_share.py +744 -137
- package/bin/_cctally_dashboard_sources.py +9 -0
- package/bin/_cctally_db.py +315 -40
- package/bin/_cctally_doctor.py +188 -3
- package/bin/_cctally_five_hour.py +39 -12
- package/bin/_cctally_forecast.py +29 -47
- package/bin/_cctally_journal.py +79 -7
- package/bin/_cctally_parser.py +71 -1
- package/bin/_cctally_project.py +0 -2
- package/bin/_cctally_record.py +18 -0
- package/bin/_cctally_reporting.py +0 -9
- package/bin/_cctally_retention.py +2890 -0
- package/bin/_cctally_share.py +97 -92
- package/bin/_cctally_source_analytics.py +44 -16
- package/bin/_cctally_store.py +194 -14
- package/bin/_lib_artifact_retention.py +1645 -0
- package/bin/_lib_display_tz.py +18 -0
- package/bin/_lib_doctor.py +378 -24
- package/bin/_lib_share.py +1359 -183
- package/bin/_lib_share_templates.py +181 -69
- package/bin/_lib_view_models.py +12 -0
- package/bin/cctally +36 -4
- package/dashboard/static/assets/{index-HlIK7k8Q.js → index-CaUQziq_.js} +48 -48
- package/dashboard/static/assets/index-DUQjFSX7.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +0 -1
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
589
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
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
|
-
|
|
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 -
|
|
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
|
|
724
|
-
elements.append(svg_text(ix -
|
|
725
|
-
|
|
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 +
|
|
728
|
-
|
|
729
|
-
font_size=
|
|
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
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
767
|
-
|
|
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
|
-
|
|
771
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2338
|
-
#
|
|
2339
|
-
#
|
|
2340
|
-
#
|
|
2341
|
-
#
|
|
2342
|
-
|
|
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 +
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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 +
|
|
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=
|
|
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
|
|
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'<
|
|
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
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
|
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>
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
2629
|
-
|
|
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
|
-
|
|
2638
|
-
|
|
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
|
|
2641
|
-
provenance marker preparation stamped: `not reveal_projects`. It
|
|
2642
|
-
be INFERRED by regex-matching `project-\\d+` over the labels,
|
|
2643
|
-
wrong three ways — it never inspected the chart, so
|
|
2644
|
-
stamped `false` onto a demonstrably scrubbed
|
|
2645
|
-
named `project-1` reported as anonymized; and
|
|
2646
|
-
producing conforming labels was
|
|
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
|
-
|
|
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(
|
|
2655
|
-
f"{_format_generated_at_iso(
|
|
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(
|
|
2662
|
-
f"generated_at: {_format_generated_at_iso(
|
|
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: {
|
|
3751
|
+
f"panel: {meta.panel}",
|
|
2665
3752
|
]
|
|
2666
|
-
if
|
|
2667
|
-
lines.append(f"template_id: {_yaml_scalar(
|
|
2668
|
-
lines.
|
|
2669
|
-
|
|
2670
|
-
f"cctally_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
|
|
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(
|
|
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(
|
|
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
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
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
|
|
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)
|
|
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>`.
|
|
2759
|
-
|
|
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
|
-
|
|
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(
|
|
2827
|
-
|
|
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 ·
|
|
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 +
|
|
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
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
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
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
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
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
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
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
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
|
|
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)}'
|