cctally 1.6.0 → 1.6.2

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.
@@ -0,0 +1,1551 @@
1
+ """Pure-function render kernel for shareable reports.
2
+
3
+ Imported lazily from bin/cctally only when a headliner subcommand is invoked
4
+ with --format. Stdlib-only, no I/O, no DB, no filesystem, no locks.
5
+
6
+ Spec: docs/superpowers/specs/2026-05-08-shareable-reports-design.md
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import math
13
+ import re
14
+ from collections.abc import Callable, Mapping
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+
18
+
19
+ # --- Version + digest ---
20
+ #
21
+ # KERNEL_VERSION is the contract version of the share renderer. Bump when
22
+ # output shape changes in a way that requires re-rendering historical
23
+ # basket items / share-history entries. The dashboard composer reads this
24
+ # off basket snapshots and tags rows whose stored version != current.
25
+ KERNEL_VERSION: int = 1
26
+
27
+
28
+ def _data_digest(payload: object) -> str:
29
+ """Stable sha256 of a JSON-serializable payload.
30
+
31
+ Used by share-snapshot envelopes to let the composer detect data drift
32
+ between add-time and compose-time. Key ordering is sorted to make the
33
+ digest insensitive to dict construction order.
34
+
35
+ Payload must contain only JSON-native types or types with a stable
36
+ `str()` (e.g. `datetime`); arbitrary objects fall through `default=str`
37
+ and `<X object at 0x…>` reprs are per-process-unstable.
38
+ """
39
+ canon = json.dumps(payload, sort_keys=True, separators=(",", ":"),
40
+ default=str).encode("utf-8")
41
+ return "sha256:" + hashlib.sha256(canon).hexdigest()
42
+
43
+
44
+ # --- Cell tagged union ---
45
+
46
+ @dataclass(frozen=True)
47
+ class TextCell:
48
+ text: str
49
+
50
+ @dataclass(frozen=True)
51
+ class MoneyCell:
52
+ usd: float
53
+
54
+ @dataclass(frozen=True)
55
+ class PercentCell:
56
+ pct: float
57
+
58
+ @dataclass(frozen=True)
59
+ class DateCell:
60
+ when: datetime
61
+
62
+ @dataclass(frozen=True)
63
+ class DeltaCell:
64
+ value: float
65
+ unit: str # "%" | "$"
66
+
67
+ @dataclass(frozen=True)
68
+ class ProjectCell:
69
+ """Anonymization chokepoint — scrubber rewrites the `label` field."""
70
+ label: str
71
+
72
+ Cell = TextCell | MoneyCell | PercentCell | DateCell | DeltaCell | ProjectCell
73
+
74
+
75
+ # --- Table primitives ---
76
+
77
+ @dataclass(frozen=True)
78
+ class ColumnSpec:
79
+ key: str
80
+ label: str
81
+ align: str = "left" # "left" | "right" | "center"
82
+ emphasis: bool = False
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Row:
87
+ cells: Mapping[str, "Cell"]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Totalled:
92
+ label: str
93
+ value: str
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class PeriodSpec:
98
+ start: datetime
99
+ end: datetime
100
+ display_tz: str
101
+ label: str
102
+
103
+
104
+ # --- Chart primitives ---
105
+
106
+ @dataclass(frozen=True)
107
+ class ChartPoint:
108
+ x_label: str
109
+ x_value: float
110
+ y_value: float
111
+ project_label: str | None = None
112
+ series_key: str | None = None
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class LineChart:
117
+ points: tuple[ChartPoint, ...]
118
+ y_label: str
119
+ # Each reference line is (value, label, severity) where severity is "warn"|"alarm".
120
+ # Renderer unpacks the 3-tuple; bare-float form (Implementor 1's tightening) was
121
+ # incorrect — restored to the consumer-driven shape per Implementor Bundle 3.
122
+ reference_lines: tuple[tuple[float, str, str], ...] = ()
123
+ multi_series: Mapping[str, tuple[ChartPoint, ...]] | None = None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class BarChart:
128
+ points: tuple[ChartPoint, ...]
129
+ y_label: str
130
+ stacks: Mapping[str, tuple[ChartPoint, ...]] | None = None
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class HorizontalBarChart:
135
+ """Horizontal bar chart with top-N cap.
136
+
137
+ Contract: each point's `y_value` is treated as a non-negative magnitude.
138
+ Negative `y_value` would produce visually-misleading negative-width
139
+ rendering (silently zero in most SVG renderers); kernel-internal
140
+ callers must pre-filter or coerce.
141
+ """
142
+ points: tuple[ChartPoint, ...]
143
+ x_label: str
144
+ cap: int | None = None
145
+
146
+
147
+ ChartSpec = LineChart | BarChart | HorizontalBarChart
148
+
149
+
150
+ # --- Top-level snapshot ---
151
+ #
152
+ # Contract: ShareSnapshot and all nested dataclasses are nominally frozen.
153
+ # `frozen=True` blocks attribute rebinding (snap.cmd = ...) but cannot prevent
154
+ # mutation of the inner dict held by Row.cells or the inner tuple/dict held by
155
+ # chart fields. The scrubber and renderers MUST treat snapshots as read-only;
156
+ # the parameterized Mapping/tuple annotations exist to make a typechecker
157
+ # reject mutation attempts (e.g., dict assignment, list.append). Phase 4's
158
+ # scrubber returns a NEW snapshot rather than rewriting in place — see spec
159
+ # §5.3 (anonymization chokepoint) and Codex finding M6.
160
+
161
+ @dataclass(frozen=True)
162
+ class ShareSnapshot:
163
+ cmd: str
164
+ title: str
165
+ subtitle: str | None
166
+ period: PeriodSpec
167
+ columns: tuple[ColumnSpec, ...]
168
+ rows: tuple[Row, ...]
169
+ chart: ChartSpec | None
170
+ totals: tuple[Totalled, ...]
171
+ notes: tuple[str, ...]
172
+ generated_at: datetime
173
+ version: str
174
+ template_id: str | None = None
175
+
176
+
177
+ # --- Compose: multi-section stitching (M3.1) ---
178
+ #
179
+ # `compose()` is the multi-section counterpart of `render()`: every basket
180
+ # item is rendered via `_render_fragment` (the same body-only path the
181
+ # single-panel `render()` uses) and the fragments are stitched under one
182
+ # composite chrome — single <html>/<svg> wrapper or one MD frontmatter
183
+ # block. See `compose()` for the format-specific stitching rules; the
184
+ # dataclasses below pin the request shape.
185
+
186
+ @dataclass(frozen=True)
187
+ class ComposedSection:
188
+ """One section in a multi-section compose request.
189
+
190
+ `drift_detected` is metadata only — surfaced to the composer UI as the
191
+ "Outdated" badge (spec §7.7). It must NOT alter the rendered body;
192
+ the renderer ignores it. Compute it server-side by comparing the
193
+ section's `data_digest_at_add` against a fresh `_data_digest` over
194
+ the same panel_data slice.
195
+ """
196
+ snap: ShareSnapshot
197
+ drift_detected: bool
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class ComposeOptions:
202
+ """Composite knobs supplied by the composer modal (spec §8.5).
203
+
204
+ `theme`, `format`, `reveal_projects`, and `no_branding` are
205
+ single-source-of-truth: every section is re-rendered with these
206
+ values, regardless of what was captured per-section at add-time.
207
+ """
208
+ title: str
209
+ theme: str # "light" | "dark"
210
+ format: str # "md" | "html" | "svg"
211
+ no_branding: bool
212
+ # kernel: informational only — actual scrub happens upstream in
213
+ # the API layer before sections reach `compose()`.
214
+ reveal_projects: bool
215
+
216
+
217
+ # --- Escape helpers ---
218
+
219
+ _XML_ESCAPE_TABLE = {
220
+ "&": "&amp;",
221
+ "<": "&lt;",
222
+ ">": "&gt;",
223
+ '"': "&quot;",
224
+ "'": "&#39;",
225
+ }
226
+
227
+
228
+ def _xml_escape(s: str) -> str:
229
+ """Escape `&`, `<`, `>`, `"`, `'`. For SVG <text> content and HTML body text."""
230
+ out = []
231
+ for ch in s:
232
+ out.append(_XML_ESCAPE_TABLE.get(ch, ch))
233
+ return "".join(out)
234
+
235
+
236
+ def _attr_escape(s: str) -> str:
237
+ """Escape XML chars + collapse newlines to space. For SVG/HTML attribute values."""
238
+ s = s.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
239
+ return _xml_escape(s)
240
+
241
+
242
+ _MD_HTML_TABLE = {"&": "&amp;", "<": "&lt;", ">": "&gt;"}
243
+ _MD_FMT_CHARS = ("\\", "|", "*", "_", "`", "[", "]")
244
+
245
+
246
+ def _md_escape(s: str) -> str:
247
+ """Escape markdown formatting chars + HTML chars.
248
+
249
+ Markdown surfaces (GitHub, Slack, most renderers) interpret raw HTML inline,
250
+ so a revealed project name like 'Project<script>' would inject without
251
+ HTML-char escaping. Backslash is in _MD_FMT_CHARS so a literal `\\` becomes
252
+ `\\\\` — single-pass dispatch, each char checked independently.
253
+ """
254
+ out = []
255
+ for ch in s:
256
+ if ch in _MD_HTML_TABLE:
257
+ out.append(_MD_HTML_TABLE[ch])
258
+ elif ch in _MD_FMT_CHARS:
259
+ out.append("\\" + ch)
260
+ else:
261
+ out.append(ch)
262
+ return "".join(out)
263
+
264
+
265
+ # --- Palettes ---
266
+
267
+ PALETTE_LIGHT = {
268
+ "bg": "#ffffff",
269
+ "fg": "#1a1a1a",
270
+ "muted": "#6b7280",
271
+ "grid": "#e5e7eb",
272
+ "axis": "#9ca3af",
273
+ "series_primary": "#2563eb", # blue-600
274
+ "series_secondary": "#9333ea", # purple-600
275
+ # Cycled for stacked-bar segments by sorted-key index. Six entries cover
276
+ # typical model counts (4-6); overflow wraps. Palette ordering is part
277
+ # of the byte-stable contract — adding/reordering is a goldens churn.
278
+ "series_palette": (
279
+ "#2563eb", # blue-600
280
+ "#9333ea", # purple-600
281
+ "#059669", # emerald-600
282
+ "#d97706", # amber-600
283
+ "#dc2626", # red-600
284
+ "#0891b2", # cyan-600
285
+ ),
286
+ "ref_warn": "#d97706", # amber-600
287
+ "ref_alarm": "#dc2626", # red-600
288
+ "table_header_bg": "#f3f4f6",
289
+ "table_row_alt": "#f9fafb",
290
+ "footer_link": "#2563eb",
291
+ }
292
+
293
+ PALETTE_DARK = {
294
+ "bg": "#0b0f17",
295
+ "fg": "#e5e7eb",
296
+ "muted": "#9ca3af",
297
+ "grid": "#1f2937",
298
+ "axis": "#4b5563",
299
+ "series_primary": "#60a5fa", # blue-400
300
+ "series_secondary": "#c084fc", # purple-400
301
+ "series_palette": (
302
+ "#60a5fa", # blue-400
303
+ "#c084fc", # purple-400
304
+ "#34d399", # emerald-400
305
+ "#fbbf24", # amber-400
306
+ "#f87171", # red-400
307
+ "#22d3ee", # cyan-400
308
+ ),
309
+ "ref_warn": "#fbbf24", # amber-400
310
+ "ref_alarm": "#f87171", # red-400
311
+ "table_header_bg": "#111827",
312
+ "table_row_alt": "#1f2937",
313
+ "footer_link": "#60a5fa",
314
+ }
315
+
316
+
317
+ # --- SVG primitives ---
318
+
319
+ def _fmt_num(n: float) -> str:
320
+ """Format float with one decimal place, no scientific notation, no -0.0.
321
+
322
+ Byte-stability invariant — every coordinate / value in SVG output
323
+ routes through this so goldens are stable. Rejects non-finite inputs
324
+ (NaN/inf) loudly so chart-layer divide-by-zero or bad-data bugs surface
325
+ at the value site rather than rendering silently as a blank chart.
326
+ """
327
+ if not math.isfinite(n):
328
+ raise ValueError(f"_fmt_num requires finite input, got {n!r}")
329
+ out = f"{float(n):.1f}"
330
+ if out == "-0.0":
331
+ return "0.0"
332
+ return out
333
+
334
+
335
+ def _serialize_attrs(attrs: Mapping[str, object]) -> str:
336
+ """Serialize SVG/HTML attributes in lexical key order with escaped values.
337
+
338
+ Numbers go through _fmt_num; strings through _attr_escape. None values
339
+ skipped (lets primitives accept optional attributes uniformly).
340
+ """
341
+ parts = []
342
+ for key in sorted(attrs):
343
+ value = attrs[key]
344
+ if value is None:
345
+ continue
346
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
347
+ rendered = _fmt_num(value)
348
+ else:
349
+ rendered = _attr_escape(str(value))
350
+ parts.append(f'{key}="{rendered}"')
351
+ return " ".join(parts)
352
+
353
+
354
+ def svg_rect(x: float, y: float, w: float, h: float, *,
355
+ fill: str, stroke: str | None = None) -> str:
356
+ return f'<rect {_serialize_attrs({"x": x, "y": y, "width": w, "height": h, "fill": fill, "stroke": stroke})}/>'
357
+
358
+
359
+ def svg_text(x: float, y: float, text: str, *,
360
+ font_size: float, fill: str,
361
+ anchor: str = "start", weight: str = "normal") -> str:
362
+ attrs = {
363
+ "x": x,
364
+ "y": y,
365
+ "font-size": font_size,
366
+ "fill": fill,
367
+ "text-anchor": anchor,
368
+ }
369
+ if weight and weight != "normal":
370
+ attrs["font-weight"] = weight
371
+ return f'<text {_serialize_attrs(attrs)}>{_xml_escape(text)}</text>'
372
+
373
+
374
+ def svg_line(x1: float, y1: float, x2: float, y2: float, *,
375
+ stroke: str, width: float = 1) -> str:
376
+ return f'<line {_serialize_attrs({"x1": x1, "y1": y1, "x2": x2, "y2": y2, "stroke": stroke, "stroke-width": width})}/>'
377
+
378
+
379
+ def svg_polyline(points: list[tuple[float, float]], *, stroke: str,
380
+ width: float = 2, fill: str = "none") -> str:
381
+ pts_str = " ".join(f"{_fmt_num(x)},{_fmt_num(y)}" for x, y in points)
382
+ return f'<polyline {_serialize_attrs({"points": pts_str, "stroke": stroke, "stroke-width": width, "fill": fill})}/>'
383
+
384
+
385
+ def svg_path(d: str, *, stroke: str | None = None,
386
+ fill: str | None = None) -> str:
387
+ """SVG path element — `d` is the only opaque attribute in the kernel.
388
+
389
+ Byte-stability caveat: callers building `d` from coordinates MUST format
390
+ each numeric value through `_fmt_num` before stringification, e.g.,
391
+ `f"M{_fmt_num(x0)} {_fmt_num(y0)} L{_fmt_num(x1)} {_fmt_num(y1)}"`. The
392
+ `d` minilanguage is opaque to `_serialize_attrs`, so a stray `f"{x:.6f}"`
393
+ would diverge goldens silently.
394
+ """
395
+ attrs: dict[str, object] = {"d": d}
396
+ if stroke is not None:
397
+ attrs["stroke"] = stroke
398
+ if fill is not None:
399
+ attrs["fill"] = fill
400
+ return f'<path {_serialize_attrs(attrs)}/>'
401
+
402
+
403
+ def svg_group(children: list, *, transform: str | None = None) -> str:
404
+ attrs: dict = {}
405
+ if transform is not None:
406
+ attrs["transform"] = transform
407
+ open_tag = f'<g {_serialize_attrs(attrs)}>' if attrs else "<g>"
408
+ return open_tag + "".join(children) + "</g>"
409
+
410
+
411
+ # --- Chart layout helpers ---
412
+
413
+ _PADDING_LEFT = 50 # axis labels
414
+ _PADDING_BOTTOM = 30 # x-tick labels
415
+ _PADDING_TOP = 10
416
+ _PADDING_RIGHT = 10
417
+
418
+ _HBAR_LABEL_GUTTER = 120.0 # left gutter for project labels (anonymized fits; long revealed labels may overflow)
419
+ _HBAR_RIGHT_PAD = 10.0 # right-side breathing room for value labels
420
+
421
+
422
+ def _chart_inner_box(
423
+ x: float, y: float, width: float, height: float,
424
+ ) -> tuple[float, float, float, float]:
425
+ """Compute (ix, iy, iw, ih) — the inner plot area inside chart padding."""
426
+ ix = x + _PADDING_LEFT
427
+ iy = y + _PADDING_TOP
428
+ iw = width - _PADDING_LEFT - _PADDING_RIGHT
429
+ ih = height - _PADDING_TOP - _PADDING_BOTTOM
430
+ return ix, iy, iw, ih
431
+
432
+
433
+ def _scale_y(
434
+ values: list[float], ih: float,
435
+ ) -> tuple[float, Callable[[float], float]]:
436
+ """Return y_max and a scale function f(value) -> y-pixel (top-down)."""
437
+ if not values:
438
+ return 1.0, lambda v: 0.0
439
+ y_max = max(values)
440
+ y_min = min(0.0, min(values))
441
+ span = y_max - y_min if (y_max - y_min) > 1e-9 else 1.0
442
+ def f(v: float) -> float:
443
+ # Higher value → smaller y (SVG y axis is top-down).
444
+ norm = (v - y_min) / span
445
+ return ih - (norm * ih)
446
+ return y_max, f
447
+
448
+
449
+ def _render_chart_no_data(palette: Mapping[str, str], *,
450
+ x: float, y: float, width: float, height: float) -> str:
451
+ """Render the canonical '(no data)' placeholder for an empty chart."""
452
+ return svg_group([
453
+ svg_text(x + width / 2, y + height / 2, "(no data)",
454
+ font_size=12, fill=palette["muted"], anchor="middle"),
455
+ ])
456
+
457
+
458
+ # --- Chart renderers ---
459
+
460
+ # Line chart.
461
+ def _render_line_chart_svg(chart: LineChart, *, palette: dict,
462
+ x: float, y: float, width: float, height: float) -> str:
463
+ ix, iy, iw, ih = _chart_inner_box(x, y, width, height)
464
+ pts = chart.points
465
+ if not pts:
466
+ return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
467
+
468
+ # Y-domain spans primary + multi_series + reference_lines so projected
469
+ # values that exceed the actual-sample max don't clip past the inner box.
470
+ y_values = [p.y_value for p in pts]
471
+ if chart.multi_series:
472
+ for series_pts in chart.multi_series.values():
473
+ y_values.extend(p.y_value for p in series_pts)
474
+ y_values.extend(r[0] for r in chart.reference_lines)
475
+ _, scale_y = _scale_y(y_values, ih)
476
+
477
+ # X-domain spans primary + multi_series so a projected ray that extends
478
+ # past the latest actual sample (e.g. forecast `now` -> `week_end`) lands
479
+ # at its true x position rather than getting pinned to enumerate-index.
480
+ # When primary uses sequential `x_value=float(i)` (e.g. report trend),
481
+ # this collapses to the prior `iw / (n-1)` spacing.
482
+ x_values = [p.x_value for p in pts]
483
+ if chart.multi_series:
484
+ for series_pts in chart.multi_series.values():
485
+ x_values.extend(p.x_value for p in series_pts)
486
+ x_min = min(x_values)
487
+ x_max = max(x_values)
488
+ x_span = x_max - x_min
489
+ if x_span <= 1e-9:
490
+ # Degenerate: single point or zero-width domain — anchor at left edge.
491
+ def scale_x(_v: float) -> float:
492
+ return 0.0
493
+ else:
494
+ def scale_x(v: float) -> float:
495
+ return iw * (v - x_min) / x_span
496
+
497
+ # Axes.
498
+ elements = []
499
+ elements.append(svg_line(ix, iy + ih, ix + iw, iy + ih,
500
+ stroke=palette["axis"], width=1))
501
+ elements.append(svg_line(ix, iy, ix, iy + ih,
502
+ stroke=palette["axis"], width=1))
503
+
504
+ # Reference lines.
505
+ for (ref_value, ref_label, severity) in chart.reference_lines:
506
+ ref_color = palette["ref_warn"] if severity == "warn" else palette["ref_alarm"]
507
+ ry = iy + scale_y(ref_value)
508
+ elements.append(svg_line(ix, ry, ix + iw, ry, stroke=ref_color, width=1))
509
+ elements.append(svg_text(ix + iw - 4, ry - 3, ref_label,
510
+ font_size=10, fill=ref_color, anchor="end"))
511
+
512
+ # Series polyline (primary series).
513
+ poly_pts = [(ix + scale_x(p.x_value), iy + scale_y(p.y_value)) for p in pts]
514
+ elements.append(svg_polyline(poly_pts, stroke=palette["series_primary"], width=2))
515
+
516
+ # Optional multi-series (forecast actual + projected).
517
+ if chart.multi_series:
518
+ for series_key, series_pts in sorted(chart.multi_series.items()):
519
+ series_color = palette["series_secondary"]
520
+ spoly = [(ix + scale_x(p.x_value), iy + scale_y(p.y_value)) for p in series_pts]
521
+ # Dashed for "projected" — simple stroke-dasharray.
522
+ attrs = {
523
+ "points": " ".join(f"{_fmt_num(px)},{_fmt_num(py)}" for px, py in spoly),
524
+ "stroke": series_color,
525
+ "stroke-width": 2,
526
+ "stroke-dasharray": "4 3",
527
+ "fill": "none",
528
+ }
529
+ elements.append(f'<polyline {_serialize_attrs(attrs)}/>')
530
+
531
+ # X-tick labels (one per primary sample, positioned by x_value).
532
+ for p in pts:
533
+ tx = ix + scale_x(p.x_value)
534
+ elements.append(svg_text(tx, iy + ih + 14, p.x_label,
535
+ font_size=10, fill=palette["muted"], anchor="middle"))
536
+
537
+ # Y-axis label.
538
+ elements.append(svg_text(ix - 10, iy + ih / 2, chart.y_label,
539
+ font_size=10, fill=palette["muted"], anchor="end"))
540
+
541
+ return svg_group(elements)
542
+
543
+
544
+ # Bar chart (vertical).
545
+ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
546
+ x: float, y: float, width: float, height: float) -> str:
547
+ ix, iy, iw, ih = _chart_inner_box(x, y, width, height)
548
+ pts = chart.points
549
+ if not pts:
550
+ return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
551
+
552
+ n = len(pts)
553
+ bar_gap = 4.0
554
+ total_gap = bar_gap * (n - 1) if n > 1 else 0.0
555
+ bar_w = max(2.0, (iw - total_gap) / n)
556
+
557
+ has_stacks = bool(chart.stacks)
558
+ # Sorted keys give deterministic stack ordering; matches the
559
+ # `sorted(all_model_keys)` ordering builders use for table columns,
560
+ # so legend swatch -> table column line up by position.
561
+ series_keys = sorted(chart.stacks.keys()) if has_stacks else []
562
+
563
+ if has_stacks:
564
+ per_bar_totals: list[float] = []
565
+ for i in range(n):
566
+ total = 0.0
567
+ for k in series_keys:
568
+ sp = chart.stacks[k]
569
+ if i < len(sp):
570
+ total += sp[i].y_value
571
+ per_bar_totals.append(total)
572
+ y_values = per_bar_totals
573
+ else:
574
+ y_values = [p.y_value for p in pts]
575
+ _, scale_y = _scale_y(y_values, ih)
576
+
577
+ elements = []
578
+ elements.append(svg_line(ix, iy + ih, ix + iw, iy + ih,
579
+ stroke=palette["axis"], width=1))
580
+ elements.append(svg_line(ix, iy, ix, iy + ih,
581
+ stroke=palette["axis"], width=1))
582
+
583
+ series_palette = palette["series_palette"]
584
+
585
+ for i, p in enumerate(pts):
586
+ bx = ix + i * (bar_w + bar_gap)
587
+ if has_stacks:
588
+ # Cumulative bottom-up segments. Skip zero/negative segments so
589
+ # they don't emit a degenerate rect (and don't shift the next
590
+ # segment's baseline incorrectly).
591
+ y_running = 0.0
592
+ for k_idx, k in enumerate(series_keys):
593
+ sp = chart.stacks[k]
594
+ seg_v = sp[i].y_value if i < len(sp) else 0.0
595
+ if seg_v <= 0:
596
+ continue
597
+ seg_top_y = iy + scale_y(y_running + seg_v)
598
+ seg_bot_y = iy + scale_y(y_running)
599
+ seg_h = seg_bot_y - seg_top_y
600
+ color = series_palette[k_idx % len(series_palette)]
601
+ elements.append(svg_rect(bx, seg_top_y, bar_w, seg_h, fill=color))
602
+ y_running += seg_v
603
+ else:
604
+ by = iy + scale_y(p.y_value)
605
+ bh = (iy + ih) - by
606
+ elements.append(svg_rect(bx, by, bar_w, bh, fill=palette["series_primary"]))
607
+ # X-tick label centered under bar.
608
+ tx = bx + bar_w / 2
609
+ elements.append(svg_text(tx, iy + ih + 14, p.x_label,
610
+ font_size=10, fill=palette["muted"], anchor="middle"))
611
+
612
+ # Legend (top-right of inner box, only when stacks are present).
613
+ # SVG is the only artifact where the table doesn't double as a key, so
614
+ # the legend matters most for `--format svg` output. Placed inside the
615
+ # inner box so total chart dimensions stay byte-stable.
616
+ if has_stacks:
617
+ legend_swatch_w = 8.0
618
+ legend_swatch_h = 8.0
619
+ legend_row_h = 12.0
620
+ legend_col_w = 160.0
621
+ legend_left = ix + iw - legend_col_w
622
+ for k_idx, k in enumerate(series_keys):
623
+ row_y = iy + 4 + k_idx * legend_row_h
624
+ color = series_palette[k_idx % len(series_palette)]
625
+ elements.append(svg_rect(
626
+ legend_left, row_y, legend_swatch_w, legend_swatch_h,
627
+ fill=color,
628
+ ))
629
+ elements.append(svg_text(
630
+ legend_left + legend_swatch_w + 4, row_y + 8, k,
631
+ font_size=10, fill=palette["fg"], anchor="start",
632
+ ))
633
+
634
+ elements.append(svg_text(ix - 10, iy + ih / 2, chart.y_label,
635
+ font_size=10, fill=palette["muted"], anchor="end"))
636
+
637
+ return svg_group(elements)
638
+
639
+
640
+ # Horizontal bar chart (top-N with cap).
641
+ def _render_hbar_chart_svg(chart: HorizontalBarChart, *, palette: dict,
642
+ x: float, y: float, width: float, height: float) -> str:
643
+ pts = chart.points
644
+ if chart.cap is not None:
645
+ pts = pts[:chart.cap]
646
+ if not pts:
647
+ return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
648
+
649
+ label_w = _HBAR_LABEL_GUTTER
650
+ ix = x + label_w
651
+ iy = y + 6
652
+ iw = width - label_w - _HBAR_RIGHT_PAD
653
+ ih = height - 12
654
+
655
+ n = len(pts)
656
+ row_h = ih / n
657
+ bar_h = max(8.0, row_h * 0.7)
658
+ bar_gap = (row_h - bar_h) / 2
659
+
660
+ x_max = max(p.y_value for p in pts)
661
+ if x_max <= 0:
662
+ x_max = 1.0
663
+
664
+ elements = []
665
+ for i, p in enumerate(pts):
666
+ ry = iy + i * row_h + bar_gap
667
+ bw = (p.y_value / x_max) * iw
668
+ elements.append(svg_rect(ix, ry, bw, bar_h, fill=palette["series_primary"]))
669
+ # Label gutter (right-aligned to ix - 4).
670
+ elements.append(svg_text(ix - 4, ry + bar_h / 2 + 3, p.x_label,
671
+ font_size=11, fill=palette["fg"], anchor="end"))
672
+ # Value label at end of bar.
673
+ elements.append(svg_text(ix + bw + 4, ry + bar_h / 2 + 3,
674
+ f"${p.y_value:,.2f}",
675
+ font_size=10, fill=palette["muted"], anchor="start"))
676
+
677
+ return svg_group(elements)
678
+
679
+
680
+ # --- SVG chrome helpers ---
681
+
682
+ def _format_generated_at_iso(dt: datetime) -> str:
683
+ """ISO 8601, no microseconds. UTC datetimes use trailing 'Z' instead of '+00:00';
684
+ non-UTC datetimes keep their offset-suffix form (no Z substitution applies)."""
685
+ return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
686
+
687
+
688
+ def _version_label(version: str) -> str:
689
+ """v<X.Y.Z> when version is set; 'dev' otherwise (Section 6.9 fallback)."""
690
+ return f"v{version}" if version else "dev"
691
+
692
+
693
+ def _render_svg_header(snap: ShareSnapshot, *, palette: dict,
694
+ x: float, y: float, width: float) -> str:
695
+ elements = []
696
+ elements.append(svg_text(x, y + 18, snap.title,
697
+ font_size=18, fill=palette["fg"], weight="bold"))
698
+ if snap.subtitle:
699
+ elements.append(svg_text(x, y + 36, snap.subtitle,
700
+ font_size=12, fill=palette["muted"]))
701
+ elements.append(svg_text(x + width, y + 18,
702
+ _format_generated_at_iso(snap.generated_at),
703
+ font_size=10, fill=palette["muted"], anchor="end"))
704
+ return svg_group(elements)
705
+
706
+
707
+ def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
708
+ x: float, y: float, width: float, branding: bool) -> str:
709
+ if not branding:
710
+ return ""
711
+ label = (
712
+ "Generated by cctally · github.com/omrikais/cctally · "
713
+ + _version_label(snap.version)
714
+ )
715
+ return svg_group([
716
+ svg_text(x, y, label, font_size=10, fill=palette["footer_link"]),
717
+ ])
718
+
719
+
720
+ # --- Scrubber ---
721
+ #
722
+ # Anonymization chokepoint (spec Section 5.3 / 7 / 8.4). Operates on a
723
+ # ShareSnapshot before any renderer runs; returns a new snapshot with project
724
+ # labels rewritten everywhere they appear in the rendered output (ProjectCell
725
+ # in rows, ChartPoint.project_label / .x_label in chart points + multi-series
726
+ # + stacks). The Section 8.4 invariant — anonymized output contains zero
727
+ # original tokens across md/svg/html — is the canary; if any new project-
728
+ # label site is introduced in the data model later, both `_collect_project_
729
+ # costs` (gather) and `_apply_anon_mapping` (rewrite) must be extended.
730
+
731
+
732
+ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
733
+ """Walk rows: for each row containing a ProjectCell, sum MoneyCell values
734
+ in the same row under the project label.
735
+
736
+ Charts also contribute via ChartPoint.project_label + y_value (when y_value
737
+ is in $). For consistency we union both sources; rows take precedence on
738
+ duplicates."""
739
+ costs: dict[str, float] = {}
740
+ for row in snap.rows:
741
+ proj_label: str | None = None
742
+ money = 0.0
743
+ for cell in row.cells.values():
744
+ if isinstance(cell, ProjectCell):
745
+ proj_label = cell.label
746
+ elif isinstance(cell, MoneyCell):
747
+ money += cell.usd
748
+ if proj_label is not None:
749
+ costs[proj_label] = costs.get(proj_label, 0.0) + money
750
+
751
+ if snap.chart is not None:
752
+ chart_pts: list[ChartPoint] = []
753
+ if isinstance(snap.chart, LineChart):
754
+ chart_pts = list(snap.chart.points)
755
+ if snap.chart.multi_series:
756
+ for series in snap.chart.multi_series.values():
757
+ chart_pts.extend(series)
758
+ elif isinstance(snap.chart, BarChart):
759
+ chart_pts = list(snap.chart.points)
760
+ if snap.chart.stacks:
761
+ for series in snap.chart.stacks.values():
762
+ chart_pts.extend(series)
763
+ elif isinstance(snap.chart, HorizontalBarChart):
764
+ chart_pts = list(snap.chart.points)
765
+ # Chart-only fallback: tiebreaker only — `y_value` is dollars for project
766
+ # bar charts but may be a ratio for trend charts. Affects sort order of
767
+ # project-N labels, not anonymization correctness.
768
+ for p in chart_pts:
769
+ if p.project_label and p.project_label not in costs:
770
+ costs[p.project_label] = p.y_value
771
+
772
+ return costs
773
+
774
+
775
+ def _build_anon_mapping(project_costs: dict[str, float]) -> dict[str, str]:
776
+ """Sort labels by descending cost (lex tie-break); assign project-1, project-2, ...
777
+
778
+ "(unknown)" is never numbered — keeps its literal label.
779
+ """
780
+ items = [
781
+ (label, cost)
782
+ for label, cost in project_costs.items()
783
+ if label != "(unknown)"
784
+ ]
785
+ items.sort(key=lambda kv: (-kv[1], kv[0]))
786
+ mapping: dict[str, str] = {
787
+ label: f"project-{i + 1}" for i, (label, _cost) in enumerate(items)
788
+ }
789
+ if "(unknown)" in project_costs:
790
+ mapping["(unknown)"] = "(unknown)"
791
+ return mapping
792
+
793
+
794
+ def _apply_anon_mapping(
795
+ snap: ShareSnapshot, mapping: dict[str, str],
796
+ ) -> ShareSnapshot:
797
+ """Return a new ShareSnapshot with project labels replaced everywhere.
798
+
799
+ Walks: (a) every Row.cells dict — replaces ProjectCell.label;
800
+ (b) ChartPoint.project_label AND .x_label (when x_label == project_label,
801
+ i.e. project-axis charts) on chart.points + multi_series + stacks.
802
+ """
803
+ new_rows: list[Row] = []
804
+ for row in snap.rows:
805
+ new_cells: dict[str, Cell] = {}
806
+ for key, cell in row.cells.items():
807
+ if isinstance(cell, ProjectCell) and cell.label in mapping:
808
+ new_cells[key] = ProjectCell(mapping[cell.label])
809
+ else:
810
+ new_cells[key] = cell
811
+ new_rows.append(Row(cells=new_cells))
812
+
813
+ new_chart: ChartSpec | None = snap.chart
814
+ if snap.chart is not None:
815
+ def _rewrite_pt(p: ChartPoint) -> ChartPoint:
816
+ if p.project_label:
817
+ # Fail-safe: any label not in mapping (e.g., from drift between
818
+ # _collect and _apply, or a future code path that adds chart
819
+ # points after gather) is mapped to "(unknown)" rather than
820
+ # passed through. Privacy invariant: never leak a non-anonymized
821
+ # label, even if the gather pass missed it.
822
+ new_label = mapping.get(p.project_label, "(unknown)")
823
+ else:
824
+ new_label = None
825
+ # x_label rewrite stays guarded — only anonymize if x_label is the
826
+ # project axis AND the label is in mapping (preserves non-project
827
+ # x_label values like time labels).
828
+ if (p.project_label
829
+ and p.x_label == p.project_label
830
+ and p.x_label in mapping):
831
+ new_x = mapping[p.x_label]
832
+ else:
833
+ new_x = p.x_label
834
+ return ChartPoint(
835
+ x_label=new_x,
836
+ x_value=p.x_value,
837
+ y_value=p.y_value,
838
+ project_label=new_label,
839
+ series_key=p.series_key,
840
+ )
841
+
842
+ if isinstance(snap.chart, LineChart):
843
+ new_chart = LineChart(
844
+ points=tuple(_rewrite_pt(p) for p in snap.chart.points),
845
+ y_label=snap.chart.y_label,
846
+ reference_lines=snap.chart.reference_lines,
847
+ multi_series=(
848
+ {k: tuple(_rewrite_pt(p) for p in v)
849
+ for k, v in snap.chart.multi_series.items()}
850
+ if snap.chart.multi_series else None
851
+ ),
852
+ )
853
+ elif isinstance(snap.chart, BarChart):
854
+ new_chart = BarChart(
855
+ points=tuple(_rewrite_pt(p) for p in snap.chart.points),
856
+ y_label=snap.chart.y_label,
857
+ stacks=(
858
+ {k: tuple(_rewrite_pt(p) for p in v)
859
+ for k, v in snap.chart.stacks.items()}
860
+ if snap.chart.stacks else None
861
+ ),
862
+ )
863
+ elif isinstance(snap.chart, HorizontalBarChart):
864
+ new_chart = HorizontalBarChart(
865
+ points=tuple(_rewrite_pt(p) for p in snap.chart.points),
866
+ x_label=snap.chart.x_label,
867
+ cap=snap.chart.cap,
868
+ )
869
+
870
+ # When ShareSnapshot grows a new field, add it to this constructor — the
871
+ # scrubber must thread every field through to preserve frozen semantics.
872
+ return ShareSnapshot(
873
+ cmd=snap.cmd,
874
+ title=snap.title,
875
+ subtitle=snap.subtitle,
876
+ period=snap.period,
877
+ columns=snap.columns,
878
+ rows=tuple(new_rows),
879
+ chart=new_chart,
880
+ totals=snap.totals,
881
+ notes=snap.notes,
882
+ generated_at=snap.generated_at,
883
+ version=snap.version,
884
+ template_id=snap.template_id,
885
+ )
886
+
887
+
888
+ def _scrub(snap: ShareSnapshot, *, reveal_projects: bool) -> ShareSnapshot:
889
+ """Anonymize project labels unless reveal_projects is True.
890
+
891
+ When reveal_projects is True, returns the SAME instance (identity preserved
892
+ so callers can rely on `out is snap`). When False, returns a NEW snapshot
893
+ with ProjectCell labels and ChartPoint project/x labels rewritten via
894
+ `_build_anon_mapping`. If no project labels are present in the snapshot,
895
+ also returns the original instance.
896
+ """
897
+ if reveal_projects:
898
+ return snap
899
+ project_costs = _collect_project_costs(snap)
900
+ if not project_costs:
901
+ return snap
902
+ mapping = _build_anon_mapping(project_costs)
903
+ return _apply_anon_mapping(snap, mapping)
904
+
905
+
906
+ # --- Format renderers ---
907
+
908
+ def _render_md_fragment(snap: ShareSnapshot, *, branding: bool) -> str:
909
+ """Render the MD section body.
910
+
911
+ M1.2 contract: returns the full current `_render_md` body. Frontmatter
912
+ (added by M2.2) is layered on at the wrap step via `_build_md_frontmatter`
913
+ + `_wrap_document`. Fragment shape is body-only by definition; even
914
+ without frontmatter the wrap layer remains the single chrome chokepoint
915
+ so future surfaces (compose, history) extend it once.
916
+ """
917
+ parts = [f"# {_md_escape(snap.title)}"]
918
+ if snap.subtitle:
919
+ parts.append(f"_{_md_escape(snap.subtitle)}_")
920
+ parts.append(f"_{_format_generated_at_iso(snap.generated_at)}_")
921
+ parts.append("") # blank line before table
922
+ parts.append(_render_md_table(snap))
923
+
924
+ if snap.totals:
925
+ parts.append("")
926
+ for t in snap.totals:
927
+ parts.append(f"- **{_md_escape(t.label)}:** {_md_escape(t.value)}")
928
+
929
+ if snap.notes:
930
+ parts.append("")
931
+ for n in snap.notes:
932
+ parts.append(f"> {_md_escape(n)}")
933
+
934
+ if branding:
935
+ parts.append("")
936
+ parts.append(
937
+ f"_Generated by [cctally](https://github.com/omrikais/cctally) · "
938
+ f"{_version_label(snap.version)} · "
939
+ f"{_format_generated_at_iso(snap.generated_at)}_"
940
+ )
941
+
942
+ return "\n".join(parts) + "\n"
943
+
944
+
945
+ # --- SVG composition ---
946
+
947
+ _SVG_WIDTH = 600
948
+ _SVG_HEADER_H = 60
949
+ _SVG_CHART_H = 220
950
+ _SVG_FOOTER_H = 30
951
+ _SVG_PADDING = 20
952
+ # Composition-level offset from the footer band's top edge to the text baseline.
953
+ # (Inside _render_svg_header, the raw `y + 18` / `y + 36` literals are font-metric
954
+ # baseline offsets for the 18pt title and 12pt subtitle — they live at the chrome
955
+ # helper site, not at the composition site.)
956
+ _SVG_FOOTER_BASELINE = 18
957
+ # Vertical padding between stacked sections in `_stitch_svg`.
958
+ _SVG_SECTION_GAP = 20.0
959
+
960
+
961
+ def _render_svg(snap: ShareSnapshot, *, palette: dict,
962
+ branding: bool, include_chrome: bool = True) -> str:
963
+ """Render snapshot to SVG.
964
+
965
+ include_chrome=True → standalone SVG with title/subtitle/timestamp/footer.
966
+ include_chrome=False → chart-only (HTML wrapper consumes this).
967
+ """
968
+ if include_chrome:
969
+ height = _SVG_HEADER_H + _SVG_CHART_H + _SVG_FOOTER_H + (_SVG_PADDING * 2)
970
+ else:
971
+ height = _SVG_CHART_H + (_SVG_PADDING * 2)
972
+
973
+ pieces = []
974
+
975
+ if include_chrome:
976
+ pieces.append(_render_svg_header(
977
+ snap, palette=palette,
978
+ x=_SVG_PADDING, y=_SVG_PADDING, width=_SVG_WIDTH,
979
+ ))
980
+
981
+ # Chart.
982
+ chart_y = _SVG_PADDING + (_SVG_HEADER_H if include_chrome else 0)
983
+ if snap.chart is not None:
984
+ if isinstance(snap.chart, LineChart):
985
+ pieces.append(_render_line_chart_svg(
986
+ snap.chart, palette=palette,
987
+ x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
988
+ ))
989
+ elif isinstance(snap.chart, BarChart):
990
+ pieces.append(_render_bar_chart_svg(
991
+ snap.chart, palette=palette,
992
+ x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
993
+ ))
994
+ elif isinstance(snap.chart, HorizontalBarChart):
995
+ pieces.append(_render_hbar_chart_svg(
996
+ snap.chart, palette=palette,
997
+ x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
998
+ ))
999
+
1000
+ if include_chrome:
1001
+ pieces.append(_render_svg_footer(
1002
+ snap, palette=palette,
1003
+ x=_SVG_PADDING,
1004
+ y=_SVG_PADDING + _SVG_HEADER_H + _SVG_CHART_H + _SVG_FOOTER_BASELINE,
1005
+ width=_SVG_WIDTH,
1006
+ branding=branding,
1007
+ ))
1008
+
1009
+ total_w = _SVG_WIDTH + (_SVG_PADDING * 2)
1010
+ bg_rect = svg_rect(0, 0, total_w, height, fill=palette["bg"])
1011
+ inner = bg_rect + "".join(pieces)
1012
+ return (
1013
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
1014
+ f'viewBox="0 0 {_fmt_num(total_w)} {_fmt_num(height)}" '
1015
+ f'width="{_fmt_num(total_w)}" height="{_fmt_num(height)}">'
1016
+ f'{inner}'
1017
+ f'</svg>'
1018
+ )
1019
+
1020
+
1021
+ # --- Cell renderers (used by HTML and markdown) ---
1022
+
1023
+ def _render_cell_text(cell: Cell) -> str:
1024
+ """Plain-text rendering of a cell — pre-escape. Used as base for md/html."""
1025
+ if isinstance(cell, TextCell):
1026
+ return cell.text
1027
+ if isinstance(cell, MoneyCell):
1028
+ # Sign goes outside the currency symbol: "-$12.34", not "$-12.34".
1029
+ sign = "-" if cell.usd < 0 else ""
1030
+ return f"{sign}${abs(cell.usd):,.2f}"
1031
+ if isinstance(cell, PercentCell):
1032
+ return f"{cell.pct:.1f}%"
1033
+ if isinstance(cell, DateCell):
1034
+ return cell.when.strftime("%Y-%m-%d")
1035
+ if isinstance(cell, DeltaCell):
1036
+ # Zero is conventionally treated as non-negative for deltas (renders "+0.0%").
1037
+ if cell.value > 0:
1038
+ sign = "+"
1039
+ elif cell.value < 0:
1040
+ sign = "-"
1041
+ else:
1042
+ sign = "+"
1043
+ if cell.unit == "%":
1044
+ return f"{sign}{abs(cell.value):.1f}%"
1045
+ # Sign goes outside the currency symbol for $-deltas too: "-$1.50".
1046
+ return f"{sign}${abs(cell.value):,.2f}"
1047
+ if isinstance(cell, ProjectCell):
1048
+ return cell.label
1049
+ raise TypeError(f"unknown cell type: {type(cell).__name__}")
1050
+
1051
+
1052
+ def _render_cell_html(cell: Cell) -> str:
1053
+ return _xml_escape(_render_cell_text(cell))
1054
+
1055
+
1056
+ def _render_cell_md(cell: Cell) -> str:
1057
+ return _md_escape(_render_cell_text(cell))
1058
+
1059
+
1060
+ # --- HTML chrome and table ---
1061
+
1062
+ def _render_html_table(snap: ShareSnapshot, palette: dict) -> str:
1063
+ th_cells = "".join(
1064
+ f'<th style="text-align:{c.align};padding:6px 10px;background:{palette["table_header_bg"]};color:{palette["fg"]}">{_xml_escape(c.label)}</th>'
1065
+ for c in snap.columns
1066
+ )
1067
+ body_rows = []
1068
+ for i, row in enumerate(snap.rows):
1069
+ bg = palette["table_row_alt"] if i % 2 == 1 else palette["bg"]
1070
+ td_cells = "".join(
1071
+ f'<td style="text-align:{c.align};padding:6px 10px;background:{bg};color:{palette["fg"]}">{_render_cell_html(row.cells.get(c.key, TextCell("")))}</td>'
1072
+ for c in snap.columns
1073
+ )
1074
+ body_rows.append(f"<tr>{td_cells}</tr>")
1075
+ return (
1076
+ f'<table style="border-collapse:collapse;font-family:system-ui,-apple-system,sans-serif;font-size:13px;margin-top:12px">'
1077
+ f'<thead><tr>{th_cells}</tr></thead>'
1078
+ f'<tbody>{"".join(body_rows)}</tbody>'
1079
+ f'</table>'
1080
+ )
1081
+
1082
+
1083
+ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool) -> str:
1084
+ """Render the HTML body fragment — header + chart + table + (branded footer).
1085
+
1086
+ Document chrome (<!DOCTYPE>/<html>/<head>/<body>) is layered on at the wrap
1087
+ step via `_wrap_document`, keeping body-only content composable for v2's
1088
+ multi-section stitcher.
1089
+ """
1090
+ # `_share_apply_content_toggles` sets `snap.chart=None` for show_chart=False
1091
+ # and `snap.columns=()`/`snap.rows=()` for show_table=False. Gate the chart
1092
+ # wrapper div + the table chrome on those, so disabled sections drop entirely
1093
+ # rather than rendering empty chrome (an empty `<svg>` chart area or an
1094
+ # `<table>` with no `<th>`/`<td>`).
1095
+ chart_html = (
1096
+ f'<div style="margin-top:12px">{_render_svg(snap, palette=palette, branding=False, include_chrome=False)}</div>'
1097
+ if snap.chart is not None else ""
1098
+ )
1099
+ title_html = f'<h1 style="font-size:20px;color:{palette["fg"]};margin:0">{_xml_escape(snap.title)}</h1>'
1100
+ subtitle_html = (
1101
+ f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">{_xml_escape(snap.subtitle)}</div>'
1102
+ if snap.subtitle else ""
1103
+ )
1104
+ timestamp_html = (
1105
+ f'<div style="font-size:11px;color:{palette["muted"]};margin-top:4px">'
1106
+ f'{_format_generated_at_iso(snap.generated_at)}</div>'
1107
+ )
1108
+ table_html = _render_html_table(snap, palette) if snap.columns else ""
1109
+ if branding:
1110
+ # "Generated by cctally" stays as a single plain-text substring so HTML
1111
+ # consumers can grep for the branding marker uniformly with the SVG
1112
+ # footer; the project URL is the linkable element.
1113
+ footer_html = (
1114
+ f'<footer style="margin-top:16px;font-size:11px;color:{palette["muted"]}">'
1115
+ f'Generated by cctally · '
1116
+ f'<a href="https://github.com/omrikais/cctally" style="color:{palette["footer_link"]}">github.com/omrikais/cctally</a>'
1117
+ f' · {_version_label(snap.version)}'
1118
+ f'</footer>'
1119
+ )
1120
+ else:
1121
+ footer_html = ""
1122
+ return (
1123
+ f'<header>{title_html}{subtitle_html}{timestamp_html}</header>'
1124
+ f'{chart_html}'
1125
+ f'{table_html}'
1126
+ f'{footer_html}'
1127
+ )
1128
+
1129
+
1130
+ # --- Markdown chrome ---
1131
+
1132
+ def _render_md_table(snap: ShareSnapshot) -> str:
1133
+ """Markdown table per ColumnSpec + Row contract."""
1134
+ if not snap.columns:
1135
+ return ""
1136
+ head = "| " + " | ".join(_md_escape(c.label) for c in snap.columns) + " |"
1137
+ sep = "|" + "|".join(
1138
+ ":---:" if c.align == "center" else (
1139
+ "---:" if c.align == "right" else ":---"
1140
+ )
1141
+ for c in snap.columns
1142
+ ) + "|"
1143
+ lines = [head, sep]
1144
+ for row in snap.rows:
1145
+ cells_md = [
1146
+ _render_cell_md(row.cells.get(c.key, TextCell("")))
1147
+ for c in snap.columns
1148
+ ]
1149
+ lines.append("| " + " | ".join(cells_md) + " |")
1150
+ return "\n".join(lines)
1151
+
1152
+
1153
+ # --- SVG fragment ---
1154
+
1155
+
1156
+ def _strip_outer_svg_tag(full_svg: str) -> tuple[str, float, float]:
1157
+ """Extract inner XML + width/height from a standalone `<svg w h>...</svg>`.
1158
+
1159
+ Contract drift between renderer and stripper would raise here. Used by the
1160
+ SVG fragment path so compose can position multiple sections vertically
1161
+ inside one outer `<svg viewBox>` without nested-document weirdness.
1162
+ """
1163
+ m = re.match(
1164
+ r'<svg[^>]*\bwidth="(?P<w>[\d.]+)"[^>]*\bheight="(?P<h>[\d.]+)"[^>]*>'
1165
+ r'(?P<body>.*)</svg>\s*$',
1166
+ full_svg,
1167
+ flags=re.DOTALL,
1168
+ )
1169
+ if not m:
1170
+ raise ValueError("unexpected SVG shape (renderer contract drift)")
1171
+ return m.group("body"), float(m.group("w")), float(m.group("h"))
1172
+
1173
+
1174
+ def _render_svg_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool) -> tuple[str, float, float]:
1175
+ """Return (inner_xml, width, height) for a chart-and-chrome section.
1176
+
1177
+ Calls into the existing `_render_svg(include_chrome=True)` producer, then
1178
+ strips the outer `<svg ...>` so the wrap step can rewrap byte-identically
1179
+ today and compose can stitch sections under one viewBox later.
1180
+ """
1181
+ full = _render_svg(snap, palette=palette, branding=branding, include_chrome=True)
1182
+ return _strip_outer_svg_tag(full)
1183
+
1184
+
1185
+ # --- Print stylesheet + MD frontmatter (placeholders for M2.x layering) ---
1186
+
1187
+ def _print_stylesheet() -> str:
1188
+ """Print-only CSS injected into HTML <head> for PDF export polish (spec §11.2).
1189
+
1190
+ M1.2 stub returned this string but it was NOT wired into `_wrap_document`
1191
+ yet to keep v1 HTML goldens byte-stable through M1-M3. M4.2 wires it in
1192
+ so Print → PDF on a dark-theme export renders as black-on-white instead
1193
+ of a solid-black page, and forces page-break-inside avoidance on
1194
+ semantic blocks. v1 + v2 HTML goldens re-baseline once on first run
1195
+ after this change and are byte-stable thereafter; MD + SVG goldens are
1196
+ unaffected (the stylesheet only lives in the HTML document head).
1197
+ """
1198
+ return (
1199
+ '<style>@media print {'
1200
+ ' body { color-scheme: light; background: #fff !important; color: #000 !important; }'
1201
+ ' header, footer, section { page-break-inside: avoid; }'
1202
+ '}</style>'
1203
+ )
1204
+
1205
+
1206
+ def _build_md_frontmatter(snap: ShareSnapshot) -> str:
1207
+ """YAML frontmatter prepended to MD exports (spec §11.5).
1208
+
1209
+ Byte-stable: key order is fixed (title -> generated_at -> period ->
1210
+ panel -> optional template_id -> anonymized -> cctally_version);
1211
+ single-line values; no eolian formatting. `_wrap_document` strips this when
1212
+ `branding=False` so `--no-branding` behaves consistently with the
1213
+ HTML/SVG footer-link stripping.
1214
+
1215
+ `template_id` is present for dashboard share-v2 snapshots and omitted
1216
+ for legacy CLI snapshots that have no template recipe.
1217
+
1218
+ `anonymized` reflects whether `_scrub` has rewritten this snapshot --
1219
+ detected via `_snapshot_is_anonymized` (label-prefix heuristic; see
1220
+ that function for the contract).
1221
+ """
1222
+ period = snap.period
1223
+ period_iso = (
1224
+ f"{period.start.isoformat()}.."
1225
+ f"{period.end.isoformat()}"
1226
+ )
1227
+ anonymized = "true" if _snapshot_is_anonymized(snap) else "false"
1228
+ lines = [
1229
+ "---",
1230
+ f"title: {_yaml_scalar(snap.title)}",
1231
+ f"generated_at: {snap.generated_at.isoformat()}",
1232
+ f"period: {period_iso}",
1233
+ f"panel: {snap.cmd}",
1234
+ ]
1235
+ if snap.template_id:
1236
+ lines.append(f"template_id: {_yaml_scalar(snap.template_id)}")
1237
+ lines.extend([
1238
+ f"anonymized: {anonymized}",
1239
+ f"cctally_version: {snap.version}",
1240
+ "---",
1241
+ "",
1242
+ ])
1243
+ return "\n".join(lines)
1244
+
1245
+
1246
+ def _yaml_scalar(s: str) -> str:
1247
+ """Quote a YAML scalar value when it would otherwise be ambiguous.
1248
+
1249
+ YAML 1.2 reserves leading `:`, `#`, `&`, `*`, `!`, `|`, `>`, `'`,
1250
+ `"`, `%`, `@`, `` ` `` and embedded `:` in plain scalars. We quote
1251
+ aggressively (when the value contains any of these or leading/trailing
1252
+ whitespace) to keep frontmatter parsers happy. Single quotes use
1253
+ YAML's `''` escape for the rare title containing a quote.
1254
+ """
1255
+ if not s:
1256
+ return '""'
1257
+ if any(c in s for c in ":#&*!|>'\"%@`") or s.strip() != s:
1258
+ return "'" + s.replace("'", "''") + "'"
1259
+ return s
1260
+
1261
+
1262
+ def _snapshot_is_anonymized(snap: ShareSnapshot) -> bool:
1263
+ """Return True if every ProjectCell label is either an anon token or a sentinel.
1264
+
1265
+ `_scrub` rewrites labels to `project-<N>` (1-indexed, cost-descending).
1266
+ A snapshot with no `ProjectCell` rows returns False (nothing was
1267
+ anonymized because there was nothing to anonymize). `(unknown)` is the
1268
+ project-share sentinel for missing project_path (see
1269
+ `cmd_project`'s `_proj_label_for`) — it is never a revealed real name,
1270
+ so it is counted as also-anonymized. Mixed snapshots (some scrubbed,
1271
+ some revealed) are reported False to keep the frontmatter semantic
1272
+ ("are projects revealed in this MD?").
1273
+ """
1274
+ cells = [
1275
+ cell
1276
+ for row in snap.rows
1277
+ for cell in row.cells.values()
1278
+ if isinstance(cell, ProjectCell)
1279
+ ]
1280
+ if not cells:
1281
+ return False
1282
+ return all(
1283
+ re.fullmatch(r"project-\d+", c.label) or c.label == "(unknown)"
1284
+ for c in cells
1285
+ )
1286
+
1287
+
1288
+ # --- Fragment + wrap ---
1289
+
1290
+ def _render_fragment(snap: ShareSnapshot, *, format: str,
1291
+ palette: Mapping[str, str], branding: bool) -> "str | tuple[str, float, float]":
1292
+ """Body-only render — no document chrome.
1293
+
1294
+ Returns:
1295
+ - format="html": str — the body fragment (header + chart + table + footer).
1296
+ - format="md": str — the markdown body (frontmatter not prepended).
1297
+ - format="svg": tuple[str, float, float] — (inner_xml, width, height).
1298
+
1299
+ Callers compose this into either:
1300
+ - render(): wraps in full document chrome via `_wrap_document`.
1301
+ - compose(): stitches multiple fragments under one wrapper (M3.x).
1302
+ """
1303
+ if format == "html":
1304
+ return _render_html_fragment(snap, palette=palette, branding=branding)
1305
+ if format == "svg":
1306
+ return _render_svg_fragment(snap, palette=palette, branding=branding)
1307
+ if format == "md":
1308
+ return _render_md_fragment(snap, branding=branding)
1309
+ raise ValueError(f"unknown format: {format!r}")
1310
+
1311
+
1312
+ def _wrap_document(fragment, *, format: str, palette: Mapping[str, str] | None,
1313
+ snap: ShareSnapshot, branding: bool = True) -> str:
1314
+ """Wrap a fragment in document chrome.
1315
+
1316
+ Byte-stability invariant: for v1 single-section snapshots, the wrapped
1317
+ HTML/SVG output must equal the pre-refactor `_render_<fmt>` output
1318
+ character-for-character. The v1 share goldens (`bin/cctally-share-test`)
1319
+ are the gate.
1320
+
1321
+ MD: prepends `_build_md_frontmatter(snap)` when `branding=True` (spec
1322
+ §11.5). Suppressed when `branding=False` -- same surface as the
1323
+ HTML/SVG footer-link strip done inside the per-format renderers --
1324
+ so `--no-branding` behaves consistently across all three formats.
1325
+ """
1326
+ if format == "html":
1327
+ return (
1328
+ f'<!DOCTYPE html>'
1329
+ f'<html lang="en"><head><meta charset="utf-8">'
1330
+ f'<title>{_xml_escape(snap.title)}</title>'
1331
+ f'{_print_stylesheet()}'
1332
+ f'</head>'
1333
+ f'<body style="background:{palette["bg"]};font-family:system-ui,-apple-system,sans-serif;padding:20px;max-width:680px;margin:auto">'
1334
+ f'{fragment}'
1335
+ f'</body></html>'
1336
+ )
1337
+ if format == "svg":
1338
+ inner, w, h = fragment
1339
+ # Mirror `_render_svg`'s exact outer-tag shape (xmlns, viewBox+w+h via
1340
+ # `_fmt_num`) so single-section wraps are byte-identical to the v1
1341
+ # producer. The 0 0 origin matches `_render_svg`'s `viewBox` literal.
1342
+ return (
1343
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
1344
+ f'viewBox="0 0 {_fmt_num(w)} {_fmt_num(h)}" '
1345
+ f'width="{_fmt_num(w)}" height="{_fmt_num(h)}">'
1346
+ f'{inner}'
1347
+ f'</svg>'
1348
+ )
1349
+ if format == "md":
1350
+ front = _build_md_frontmatter(snap) if branding else ""
1351
+ # Frontmatter already ends with "---\n" (trailing "" in the join
1352
+ # adds the separator newline); concat directly so the byte shape
1353
+ # is `---\n...---\n<fragment>`. When branding=False, front is
1354
+ # "" and the fragment passes through untouched.
1355
+ return (front + fragment) if front else fragment
1356
+ raise ValueError(f"unknown format: {format!r}")
1357
+
1358
+
1359
+ # --- Compose: stitch many fragments under one chrome (M3.1) ---
1360
+
1361
+ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> str:
1362
+ """Stitch multiple section fragments into a single document.
1363
+
1364
+ Pure function. Each section's body comes from `_render_fragment(...)` —
1365
+ the same body-only renderer used by single-panel share. `compose`
1366
+ wraps them all in composite chrome (one title, one footer, one outer
1367
+ wrapper) per format-specific stitching rules in spec §4.3.
1368
+
1369
+ `opts.reveal_projects` does NOT scrub here — scrubbing must have
1370
+ happened upstream (in the API layer) before the snapshot reaches
1371
+ this function. The kernel is anon-agnostic at compose time; the
1372
+ composer endpoint is the chokepoint.
1373
+ """
1374
+ if not sections:
1375
+ raise ValueError("compose requires at least one section")
1376
+ fmt = opts.format
1377
+ if fmt == "html":
1378
+ return _stitch_html(sections, opts=opts)
1379
+ if fmt == "md":
1380
+ return _stitch_md(sections, opts=opts)
1381
+ if fmt == "svg":
1382
+ return _stitch_svg(sections, opts=opts)
1383
+ raise ValueError(f"unknown format: {fmt!r}")
1384
+
1385
+
1386
+ def _stitch_html(sections: tuple[ComposedSection, ...], *,
1387
+ opts: ComposeOptions) -> str:
1388
+ """HTML compose: single ``<html><body>`` wrapper, sections as ``<section>`` blocks."""
1389
+ palette = PALETTE_LIGHT if opts.theme == "light" else PALETTE_DARK
1390
+ body_open = (
1391
+ f'<body style="background:{palette["bg"]};'
1392
+ f'font-family:system-ui,-apple-system,sans-serif;'
1393
+ f'padding:20px;max-width:680px;margin:auto">'
1394
+ )
1395
+ header = f'<header><h1>{_xml_escape(opts.title)}</h1></header>'
1396
+ blocks = []
1397
+ for sec in sections:
1398
+ # branding here is for the *fragment* — composite footer is one
1399
+ # level up, so per-section branding is unconditional False to
1400
+ # keep the chrome single.
1401
+ frag = _render_fragment(sec.snap, format="html",
1402
+ palette=palette, branding=False)
1403
+ blocks.append(f'<section class="share-section">{frag}</section>')
1404
+ footer = (
1405
+ f'<footer style="font-size:11px;color:{palette["muted"]};margin-top:24px">'
1406
+ f'cctally · composed</footer>' if not opts.no_branding else ""
1407
+ )
1408
+ return (
1409
+ f'<!DOCTYPE html>'
1410
+ f'<html lang="en"><head><meta charset="utf-8">'
1411
+ f'<title>{_xml_escape(opts.title)}</title>'
1412
+ f'{_print_stylesheet()}'
1413
+ f'</head>{body_open}'
1414
+ f'{header}{"".join(blocks)}{footer}'
1415
+ f'</body></html>'
1416
+ )
1417
+
1418
+
1419
+ def _stitch_md(sections: tuple[ComposedSection, ...], *,
1420
+ opts: ComposeOptions) -> str:
1421
+ """MD compose: one composite frontmatter + ``## `` headers + bodies."""
1422
+ parts: list[str] = []
1423
+ if not opts.no_branding:
1424
+ # Composite frontmatter: same key set as the single-section
1425
+ # `_build_md_frontmatter` but `panel` becomes `composed` and
1426
+ # `template_id` is omitted because one composed document can contain
1427
+ # multiple section templates.
1428
+ # `generated_at` and `cctally_version` are taken from the first
1429
+ # section since the composite document has no independent
1430
+ # provenance — every section was rendered in the same request.
1431
+ first_snap = sections[0].snap
1432
+ # `period` for the composite document = earliest start ..
1433
+ # latest end across all sections (per spec §11.5 implied
1434
+ # convention; reference test uses identical periods so the
1435
+ # union collapses).
1436
+ earliest = min(sec.snap.period.start for sec in sections)
1437
+ latest = max(sec.snap.period.end for sec in sections)
1438
+ anon_field = (
1439
+ "true"
1440
+ if all(_snapshot_is_anonymized(s.snap) for s in sections)
1441
+ else "false"
1442
+ )
1443
+ parts.append(
1444
+ "---\n"
1445
+ f"title: {_yaml_scalar(opts.title)}\n"
1446
+ f"generated_at: {first_snap.generated_at.isoformat()}\n"
1447
+ f"period: {earliest.isoformat()}..{latest.isoformat()}\n"
1448
+ f"panel: composed\n"
1449
+ f"anonymized: {anon_field}\n"
1450
+ f"cctally_version: {first_snap.version}\n"
1451
+ "---\n\n"
1452
+ )
1453
+ # Title as H1 (when frontmatter is present, this duplicates the
1454
+ # title key visually — accept the duplication; markdown readers
1455
+ # vary in how they render frontmatter and the H1 is the universal
1456
+ # fallback). Title and per-section heading go through `_md_escape`
1457
+ # to match the single-section path (`_render_md_body` at line 915);
1458
+ # otherwise inline HTML or MD specials in a user-entered title
1459
+ # would survive into the export unescaped.
1460
+ parts.append(f"# {_md_escape(opts.title)}\n\n")
1461
+ last_idx = len(sections) - 1
1462
+ for idx, sec in enumerate(sections):
1463
+ frag = _render_fragment(sec.snap, format="md", palette=PALETTE_LIGHT,
1464
+ branding=False)
1465
+ parts.append(f"## {_md_escape(sec.snap.title)}\n\n")
1466
+ parts.append(frag.rstrip("\n"))
1467
+ parts.append("\n\n" if idx < last_idx else "\n")
1468
+ return "".join(parts)
1469
+
1470
+
1471
+ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
1472
+ opts: ComposeOptions) -> str:
1473
+ """SVG compose: single outer ``<svg>``, sections positioned vertically.
1474
+
1475
+ `opts.no_branding` is intentionally unused: the SVG composite has no
1476
+ chrome footer band, so there is nothing to strip. HTML stitcher uses
1477
+ it to gate the `<footer>cctally · composed</footer>` line.
1478
+ """
1479
+ palette = PALETTE_LIGHT if opts.theme == "light" else PALETTE_DARK
1480
+ inners: list[tuple[str, float, float]] = []
1481
+ for sec in sections:
1482
+ inner, w, h = _render_fragment(sec.snap, format="svg",
1483
+ palette=palette, branding=False)
1484
+ inners.append((inner, w, h))
1485
+ total_w = max(w for _, w, _ in inners)
1486
+ total_h = sum(h for _, _, h in inners) + _SVG_SECTION_GAP * (len(inners) - 1)
1487
+ body_blocks: list[str] = []
1488
+ y = 0.0
1489
+ for inner, _w, h in inners:
1490
+ body_blocks.append(
1491
+ f'<g transform="translate(0,{_fmt_num(y)})">{inner}</g>'
1492
+ )
1493
+ y += h + _SVG_SECTION_GAP
1494
+ return (
1495
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
1496
+ f'viewBox="0 0 {_fmt_num(total_w)} {_fmt_num(total_h)}" '
1497
+ f'width="{_fmt_num(total_w)}" height="{_fmt_num(total_h)}">'
1498
+ f'{"".join(body_blocks)}'
1499
+ f'</svg>'
1500
+ )
1501
+
1502
+
1503
+ # --- Public dispatch ---
1504
+
1505
+ def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool) -> str:
1506
+ """Render a snapshot to the requested format.
1507
+
1508
+ Pure function: no I/O, no DB, no filesystem, no locks. Caller is
1509
+ responsible for emitting the result (stdout/file/clipboard/open).
1510
+
1511
+ Thin delegator over `_render_fragment` + `_wrap_document`: separates
1512
+ body-only rendering from document chrome so compose can stitch multiple
1513
+ sections under a single wrapper (M3.x).
1514
+ """
1515
+ if format == "md":
1516
+ frag = _render_fragment(snap, format="md", palette=PALETTE_LIGHT, branding=branding)
1517
+ return _wrap_document(frag, format="md", palette=PALETTE_LIGHT, snap=snap,
1518
+ branding=branding)
1519
+
1520
+ if theme == "light":
1521
+ palette = PALETTE_LIGHT
1522
+ elif theme == "dark":
1523
+ palette = PALETTE_DARK
1524
+ else:
1525
+ raise ValueError(f"unknown theme: {theme!r}")
1526
+
1527
+ if format not in ("svg", "html"):
1528
+ raise ValueError(f"unknown format: {format!r}")
1529
+ frag = _render_fragment(snap, format=format, palette=palette, branding=branding)
1530
+ return _wrap_document(frag, format=format, palette=palette, snap=snap,
1531
+ branding=branding)
1532
+
1533
+
1534
+ # --- Backward-compat shims (Layer-A unit tests target these private helpers) ---
1535
+ #
1536
+ # The `_render_md` / `_render_html` names predate the fragment+wrap split.
1537
+ # v1 share goldens (`bin/cctally-share-test`) go through `render()` — these
1538
+ # shims exist solely to keep the Layer-A unit suite in `tests/test_lib_share.py`
1539
+ # pointed at byte-identical output without rewriting every call site. New code
1540
+ # should use `_render_fragment` + `_wrap_document` directly.
1541
+
1542
+ def _render_md(snap: ShareSnapshot, *, branding: bool) -> str:
1543
+ frag = _render_fragment(snap, format="md", palette=PALETTE_LIGHT, branding=branding)
1544
+ return _wrap_document(frag, format="md", palette=PALETTE_LIGHT, snap=snap,
1545
+ branding=branding)
1546
+
1547
+
1548
+ def _render_html(snap: ShareSnapshot, *, palette: dict, branding: bool) -> str:
1549
+ frag = _render_fragment(snap, format="html", palette=palette, branding=branding)
1550
+ return _wrap_document(frag, format="html", palette=palette, snap=snap,
1551
+ branding=branding)