cctally 1.6.3 → 1.7.1

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,105 @@
1
+ """Pure SemVer primitives — parse, format, bump-compute, sort-key.
2
+
3
+ Eager-imported from bin/cctally to back release-flow internals and the
4
+ update-banner version-compare predicate. Zero I/O, zero module-level
5
+ side effects; safe to import from any context (script, SourceFileLoader,
6
+ compile+exec).
7
+
8
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ # Exported as a building block: bin/cctally's RELEASE_HEADER_RE and
15
+ # _cctally_release's _FORMULA_VERSION_RE both reuse this numeric-component
16
+ # pattern for SemVer matching.
17
+ _SEMVER_NUM = r'(?:0|[1-9]\d*)'
18
+
19
+ _SEMVER_RE = re.compile(
20
+ rf'^({_SEMVER_NUM})\.({_SEMVER_NUM})\.({_SEMVER_NUM})'
21
+ rf'(?:-([a-zA-Z][a-zA-Z0-9-]*)\.({_SEMVER_NUM}))?$'
22
+ )
23
+
24
+
25
+ def _release_parse_semver(s: str) -> tuple[int, int, int, str | None, int | None]:
26
+ """Parse SemVer; raises ValueError on malformed input."""
27
+ m = _SEMVER_RE.match(s)
28
+ if not m:
29
+ raise ValueError(f"invalid semver: {s!r}")
30
+ major, minor, patch, prerelease_id, prerelease_n = m.groups()
31
+ return (
32
+ int(major),
33
+ int(minor),
34
+ int(patch),
35
+ prerelease_id,
36
+ int(prerelease_n) if prerelease_n is not None else None,
37
+ )
38
+
39
+
40
+ def _release_format_semver(
41
+ major: int, minor: int, patch: int,
42
+ prerelease_id: str | None = None, prerelease_n: int | None = None,
43
+ ) -> str:
44
+ base = f"{major}.{minor}.{patch}"
45
+ if prerelease_id is None:
46
+ return base
47
+ return f"{base}-{prerelease_id}.{prerelease_n}"
48
+
49
+
50
+ def _release_compute_next_version(
51
+ current: str | None, kind: str, bump: str | None, prerelease_id: str,
52
+ ) -> str:
53
+ """Pure function. Implements bump rules from spec Section 4.4."""
54
+ if current is None:
55
+ # First-ever release. Treat as 0.0.0 base.
56
+ cur_maj, cur_min, cur_pat, cur_id, cur_n = 0, 0, 0, None, None
57
+ else:
58
+ cur_maj, cur_min, cur_pat, cur_id, cur_n = _release_parse_semver(current)
59
+ is_prerelease = cur_id is not None
60
+
61
+ if kind == "finalize":
62
+ if not is_prerelease:
63
+ raise ValueError("cannot finalize: current version is not a prerelease")
64
+ return _release_format_semver(cur_maj, cur_min, cur_pat)
65
+
66
+ if kind == "prerelease":
67
+ if is_prerelease:
68
+ if bump is not None:
69
+ raise ValueError("--bump invalid when current version is a prerelease; rc counter increments only")
70
+ return _release_format_semver(cur_maj, cur_min, cur_pat, cur_id, cur_n + 1)
71
+ if bump is None:
72
+ raise ValueError("--bump required for first prerelease from stable version")
73
+ # Apply bump kind to current stable, then attach -<id>.1
74
+ nxt = _release_compute_next_version(current or "0.0.0", bump, None, prerelease_id)
75
+ nxt_maj, nxt_min, nxt_pat, _, _ = _release_parse_semver(nxt)
76
+ return _release_format_semver(nxt_maj, nxt_min, nxt_pat, prerelease_id, 1)
77
+
78
+ if is_prerelease:
79
+ raise ValueError("current version is a prerelease; run 'cctally release finalize' first or use --bump in a prerelease bump")
80
+
81
+ if kind == "patch":
82
+ return _release_format_semver(cur_maj, cur_min, cur_pat + 1)
83
+ if kind == "minor":
84
+ return _release_format_semver(cur_maj, cur_min + 1, 0)
85
+ if kind == "major":
86
+ return _release_format_semver(cur_maj + 1, 0, 0)
87
+ raise ValueError(f"unknown bump kind: {kind!r}")
88
+
89
+
90
+ def _release_semver_sort_key(
91
+ parsed: tuple[int, int, int, str | None, int | None],
92
+ ) -> tuple:
93
+ """Total-order sort key for `_release_parse_semver` output.
94
+
95
+ SemVer §11.4: a stable release has higher precedence than a pre-release
96
+ of the same MAJOR.MINOR.PATCH. Naive tuple comparison breaks because
97
+ Python rejects ``None < str`` at runtime. The key returned here makes
98
+ stable releases sort *after* their pre-releases by inverting the
99
+ "has-prerelease" axis: stable → ``(maj, min, pat, 1, "", 0)``,
100
+ pre-release → ``(maj, min, pat, 0, id, n)``.
101
+ """
102
+ maj, min_, pat, pre_id, pre_n = parsed
103
+ if pre_id is None:
104
+ return (maj, min_, pat, 1, "", 0)
105
+ return (maj, min_, pat, 0, pre_id, pre_n)
package/bin/_lib_share.py CHANGED
@@ -80,6 +80,7 @@ class ColumnSpec:
80
80
  label: str
81
81
  align: str = "left" # "left" | "right" | "center"
82
82
  emphasis: bool = False
83
+ kind: str | None = None # "project" | "model" | None — privacy chokepoint signal
83
84
 
84
85
 
85
86
  @dataclass(frozen=True)
@@ -358,7 +359,8 @@ def svg_rect(x: float, y: float, w: float, h: float, *,
358
359
 
359
360
  def svg_text(x: float, y: float, text: str, *,
360
361
  font_size: float, fill: str,
361
- anchor: str = "start", weight: str = "normal") -> str:
362
+ anchor: str = "start", weight: str = "normal",
363
+ font_family: str | None = None) -> str:
362
364
  attrs = {
363
365
  "x": x,
364
366
  "y": y,
@@ -368,6 +370,8 @@ def svg_text(x: float, y: float, text: str, *,
368
370
  }
369
371
  if weight and weight != "normal":
370
372
  attrs["font-weight"] = weight
373
+ if font_family:
374
+ attrs["font-family"] = font_family
371
375
  return f'<text {_serialize_attrs(attrs)}>{_xml_escape(text)}</text>'
372
376
 
373
377
 
@@ -769,6 +773,22 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
769
773
  if p.project_label and p.project_label not in costs:
770
774
  costs[p.project_label] = p.y_value
771
775
 
776
+ # project-typed columns (cross-tab Detail templates, issue #33). Sum the
777
+ # MoneyCell values for each kind='project' column across all rows; the
778
+ # column.label is the original project path (anon happens AFTER _collect).
779
+ # No current panel mixes ProjectCell rows AND project-typed columns — if a
780
+ # future template does, the `+=` here will double-count that project's
781
+ # total. Refactor to a (path, source) keyed accumulator if/when that lands.
782
+ for col in snap.columns:
783
+ if col.kind != "project":
784
+ continue
785
+ col_total = 0.0
786
+ for row in snap.rows:
787
+ cell = row.cells.get(col.key)
788
+ if isinstance(cell, MoneyCell):
789
+ col_total += cell.usd
790
+ costs[col.label] = costs.get(col.label, 0.0) + col_total
791
+
772
792
  return costs
773
793
 
774
794
 
@@ -867,6 +887,21 @@ def _apply_anon_mapping(
867
887
  cap=snap.chart.cap,
868
888
  )
869
889
 
890
+ # Rewrite project-typed column headers (cross-tab Detail templates, issue
891
+ # #33). Fail-closed: any column.label not in `mapping` maps to "(unknown)",
892
+ # mirroring the ChartPoint arm above. Frozen-dataclass-compliant — we emit
893
+ # a new tuple of new ColumnSpec instances, never mutate snap.columns.
894
+ new_columns: list[ColumnSpec] = []
895
+ for col in snap.columns:
896
+ if col.kind == "project":
897
+ new_label = mapping.get(col.label, "(unknown)")
898
+ new_columns.append(ColumnSpec(
899
+ key=col.key, label=new_label,
900
+ align=col.align, emphasis=col.emphasis, kind=col.kind,
901
+ ))
902
+ else:
903
+ new_columns.append(col)
904
+
870
905
  # When ShareSnapshot grows a new field, add it to this constructor — the
871
906
  # scrubber must thread every field through to preserve frozen semantics.
872
907
  return ShareSnapshot(
@@ -874,7 +909,7 @@ def _apply_anon_mapping(
874
909
  title=snap.title,
875
910
  subtitle=snap.subtitle,
876
911
  period=snap.period,
877
- columns=snap.columns,
912
+ columns=tuple(new_columns),
878
913
  rows=tuple(new_rows),
879
914
  chart=new_chart,
880
915
  totals=snap.totals,
@@ -957,20 +992,292 @@ _SVG_FOOTER_BASELINE = 18
957
992
  # Vertical padding between stacked sections in `_stitch_svg`.
958
993
  _SVG_SECTION_GAP = 20.0
959
994
 
995
+ # --- SVG table geometry (issue #38) ---
996
+ _SVG_TABLE_FONT = 11
997
+ _SVG_TABLE_CELL_PAD_X = 8
998
+ _SVG_TABLE_CELL_PAD_Y = 6
999
+ _SVG_TABLE_LINE_H_MULT = 1.4
1000
+ _SVG_TABLE_GAP = 16
1001
+ _SVG_TABLE_MAX_WRAP_LINES = 3
1002
+ _SVG_TABLE_MIN_COL_W = 24
1003
+ _SVG_AVG_GLYPH_WIDTH_FRACTION = 0.6
1004
+ _SVG_WRAP_BREAK_CHARS = (" ", "/", "-", "_")
1005
+ _SVG_ELLIPSIS = "…"
1006
+
1007
+
1008
+ def _svg_text_width(text: str, font_size: float) -> float:
1009
+ """Estimate rendered width of `text` at `font_size` in a sans-serif font.
1010
+
1011
+ Heuristic-only: actual width depends on the UA-selected font. SVG
1012
+ goldens are diffed as source text, so this function's job is
1013
+ determinism, not pixel-perfect measurement. Wrap-then-ellipsize
1014
+ layout tolerates moderate over/under-allocation.
1015
+ """
1016
+ return len(text) * font_size * _SVG_AVG_GLYPH_WIDTH_FRACTION
1017
+
1018
+
1019
+ def _wrap_for_width(text: str, content_w: float, font_size: float) -> list[str]:
1020
+ """Wrap `text` into lines that each fit within `content_w` pixels.
1021
+
1022
+ Greedy left-to-right: binary-search the longest prefix that fits,
1023
+ then cut at the rightmost break-char inside the run. Cap output
1024
+ at `_SVG_TABLE_MAX_WRAP_LINES`. If the input still has tail after
1025
+ the cap, ellipsize the last emitted line until ellipsis fits.
1026
+ Empty text → [""]. Unbreakable token longer than `content_w` →
1027
+ hard-cut + ellipsis on the tail.
1028
+ """
1029
+ if not text:
1030
+ return [""]
1031
+ if _svg_text_width(text, font_size) <= content_w:
1032
+ return [text]
1033
+
1034
+ lines: list[str] = []
1035
+ remaining = text
1036
+ while remaining and len(lines) < _SVG_TABLE_MAX_WRAP_LINES:
1037
+ lo, hi = 0, len(remaining)
1038
+ while lo < hi:
1039
+ mid = (lo + hi + 1) // 2
1040
+ if _svg_text_width(remaining[:mid], font_size) <= content_w:
1041
+ lo = mid
1042
+ else:
1043
+ hi = mid - 1
1044
+ fit_end = lo
1045
+
1046
+ if fit_end == len(remaining):
1047
+ lines.append(remaining)
1048
+ remaining = ""
1049
+ break
1050
+
1051
+ if fit_end == 0:
1052
+ # Even one character overflows — abort wrap, fall through to ellipsis.
1053
+ break
1054
+
1055
+ break_at = -1
1056
+ for ch in _SVG_WRAP_BREAK_CHARS:
1057
+ idx = remaining.rfind(ch, 0, fit_end + 1)
1058
+ if idx > break_at:
1059
+ break_at = idx
1060
+
1061
+ if break_at <= 0:
1062
+ lines.append(remaining[:fit_end])
1063
+ remaining = remaining[fit_end:]
1064
+ else:
1065
+ lines.append(remaining[:break_at + 1].rstrip())
1066
+ remaining = remaining[break_at + 1:]
1067
+
1068
+ if remaining:
1069
+ last = lines[-1] if lines else ""
1070
+ while last and _svg_text_width(last + _SVG_ELLIPSIS, font_size) > content_w:
1071
+ last = last[:-1]
1072
+ if last:
1073
+ lines[-1] = last + _SVG_ELLIPSIS
1074
+ elif lines:
1075
+ lines[-1] = _SVG_ELLIPSIS
1076
+ else:
1077
+ lines.append(_SVG_ELLIPSIS)
1078
+
1079
+ return lines or [_SVG_ELLIPSIS]
1080
+
1081
+
1082
+ def _svg_table_anchor_and_x(align: str, col_x: float, col_w: float,
1083
+ pad_x: float) -> tuple[str, float]:
1084
+ """Map `ColumnSpec.align` to SVG text-anchor + x-coordinate."""
1085
+ if align == "right":
1086
+ return "end", col_x + col_w - pad_x
1087
+ if align == "center":
1088
+ return "middle", col_x + col_w / 2
1089
+ return "start", col_x + pad_x
1090
+
1091
+
1092
+ def _render_svg_table(
1093
+ snap: "ShareSnapshot", *, palette: dict,
1094
+ x: float, y: float, max_width: float,
1095
+ ) -> tuple[str, float, float]:
1096
+ """Render the cross-tab / project / sessions table body as SVG.
1097
+
1098
+ Returns (svg_fragment, total_height, used_width). Caller
1099
+ (`_render_svg`) uses the returned height to position the footer
1100
+ band, and `used_width` to size the outer canvas — at pathological
1101
+ `top_n` the min-width clamp can push `sum(widths)` past
1102
+ `max_width`, in which case the outer SVG expands so columns are
1103
+ never clipped (issue #38 follow-up: Codex P2 review on PR #40).
1104
+ Caller MUST short-circuit when `snap.columns` is empty — this
1105
+ helper is a precondition violation
1106
+ if called with `columns=()`.
1107
+
1108
+ Layout: greedy auto-size, shrink only oversized columns, clamp
1109
+ to `_SVG_TABLE_MIN_COL_W` minimum, wrap headers AND body cells
1110
+ with the same break-priority rule. Visual: Treatment A (HTML-
1111
+ mirror) — header band + alternating row stripes + body text;
1112
+ no borders or inter-column rules.
1113
+ """
1114
+ cols = snap.columns
1115
+ rows = snap.rows
1116
+ n = len(cols)
1117
+ assert n > 0, "_render_svg_table: precondition snap.columns non-empty"
1118
+
1119
+ font_size = _SVG_TABLE_FONT
1120
+ pad_x = _SVG_TABLE_CELL_PAD_X
1121
+ pad_y = _SVG_TABLE_CELL_PAD_Y
1122
+ line_h = font_size * _SVG_TABLE_LINE_H_MULT
1123
+
1124
+ # 1. Pre-format every cell to plain text.
1125
+ cell_strs = [
1126
+ [_render_cell_text(row.cells.get(c.key, TextCell("")))
1127
+ for c in cols]
1128
+ for row in rows
1129
+ ]
1130
+
1131
+ # 2. Natural per-column width = max(header, max body) + 2*pad_x.
1132
+ def _col_natural(i: int, c: "ColumnSpec") -> float:
1133
+ widths = [_svg_text_width(c.label, font_size)]
1134
+ for r in range(len(rows)):
1135
+ widths.append(_svg_text_width(cell_strs[r][i], font_size))
1136
+ return max(widths) + 2 * pad_x
1137
+
1138
+ nat_w = [_col_natural(i, c) for i, c in enumerate(cols)]
1139
+
1140
+ # 3+4. Fit or shrink.
1141
+ if sum(nat_w) <= max_width:
1142
+ widths = list(nat_w)
1143
+ else:
1144
+ fair = max_width / n
1145
+ oversize_idx = [i for i in range(n) if nat_w[i] > fair]
1146
+ oversize_set = set(oversize_idx)
1147
+ if not oversize_idx:
1148
+ scale = max_width / sum(nat_w)
1149
+ widths = [w * scale for w in nat_w]
1150
+ else:
1151
+ other_total = sum(nat_w[i] for i in range(n) if i not in oversize_set)
1152
+ total_oversize = sum(nat_w[i] for i in oversize_idx)
1153
+ budget = max_width - other_total
1154
+ scale = budget / total_oversize if total_oversize > 0 else 1.0
1155
+ widths = [
1156
+ (nat_w[i] * scale) if i in oversize_set else nat_w[i]
1157
+ for i in range(n)
1158
+ ]
1159
+ # 4b. Min-width clamp (pathological top_n).
1160
+ widths = [max(w, _SVG_TABLE_MIN_COL_W) for w in widths]
1161
+
1162
+ # 5a. Header wrap.
1163
+ header_lines: list[list[str]] = []
1164
+ for i, c in enumerate(cols):
1165
+ content_w = widths[i] - 2 * pad_x
1166
+ header_lines.append(_wrap_for_width(c.label, content_w, font_size))
1167
+ max_header_lines = max((len(ls) for ls in header_lines), default=1)
1168
+
1169
+ # 5b. Body wrap.
1170
+ wrapped_body: list[list[list[str]]] = []
1171
+ row_lines: list[int] = []
1172
+ for r in range(len(rows)):
1173
+ cells_wrapped: list[list[str]] = []
1174
+ mx = 1
1175
+ for i in range(n):
1176
+ content_w = widths[i] - 2 * pad_x
1177
+ ls = _wrap_for_width(cell_strs[r][i], content_w, font_size)
1178
+ cells_wrapped.append(ls)
1179
+ mx = max(mx, len(ls))
1180
+ wrapped_body.append(cells_wrapped)
1181
+ row_lines.append(mx)
1182
+
1183
+ # 6. Heights.
1184
+ header_h = max_header_lines * line_h + 2 * pad_y
1185
+ body_heights = [nl * line_h + 2 * pad_y for nl in row_lines]
1186
+ total_h = header_h + sum(body_heights)
1187
+
1188
+ # 7. Emit. Column x-offsets are cumulative.
1189
+ col_xs = [x]
1190
+ for w in widths[:-1]:
1191
+ col_xs.append(col_xs[-1] + w)
1192
+
1193
+ # Band rects span the full rendered width — when min-col-w clamp
1194
+ # pushes sum(widths) past max_width, both the header band and the
1195
+ # alternating row stripes extend with the columns so right-side
1196
+ # cells sit on the band color, not on the outer SVG background.
1197
+ used_width = sum(widths)
1198
+ band_width = max(max_width, used_width)
1199
+
1200
+ pieces: list[str] = []
1201
+
1202
+ # Header band.
1203
+ pieces.append(svg_rect(x, y, band_width, header_h,
1204
+ fill=palette["table_header_bg"]))
1205
+ # Header text.
1206
+ for i, c in enumerate(cols):
1207
+ cx = col_xs[i]
1208
+ cw = widths[i]
1209
+ anchor, tx = _svg_table_anchor_and_x(c.align, cx, cw, pad_x)
1210
+ for j, line in enumerate(header_lines[i]):
1211
+ baseline = y + pad_y + font_size + j * line_h
1212
+ pieces.append(svg_text(
1213
+ tx, baseline, line,
1214
+ font_size=font_size, fill=palette["fg"],
1215
+ anchor=anchor, weight="bold", font_family="sans-serif",
1216
+ ))
1217
+
1218
+ # Body rows.
1219
+ row_y = y + header_h
1220
+ for r, _ in enumerate(rows):
1221
+ rh = body_heights[r]
1222
+ row_bg = palette["table_row_alt"] if (r % 2 == 1) else palette["bg"]
1223
+ pieces.append(svg_rect(x, row_y, band_width, rh, fill=row_bg))
1224
+
1225
+ for i, c in enumerate(cols):
1226
+ cx = col_xs[i]
1227
+ cw = widths[i]
1228
+ anchor, tx = _svg_table_anchor_and_x(c.align, cx, cw, pad_x)
1229
+ for j, line in enumerate(wrapped_body[r][i]):
1230
+ baseline = row_y + pad_y + font_size + j * line_h
1231
+ pieces.append(svg_text(
1232
+ tx, baseline, line,
1233
+ font_size=font_size, fill=palette["fg"],
1234
+ anchor=anchor, font_family="sans-serif",
1235
+ ))
1236
+ row_y += rh
1237
+
1238
+ return "".join(pieces), total_h, used_width
1239
+
960
1240
 
961
1241
  def _render_svg(snap: ShareSnapshot, *, palette: dict,
962
- branding: bool, include_chrome: bool = True) -> str:
1242
+ branding: bool,
1243
+ include_chrome: bool = True,
1244
+ include_table: bool = True) -> str:
963
1245
  """Render snapshot to SVG.
964
1246
 
965
- include_chrome=True → standalone SVG with title/subtitle/timestamp/footer.
966
- include_chrome=False → chart-only (HTML wrapper consumes this).
1247
+ include_chrome=True → standalone SVG with title/subtitle/timestamp/footer.
1248
+ include_chrome=False → chart(+optional table)-only (HTML wrapper consumes this).
1249
+ include_table=True → emit `_render_svg_table` body when snap.columns
1250
+ is non-empty. HTML wrapper passes False so the
1251
+ chart-slot embed stays table-free (HTML <table>
1252
+ is rendered separately as a sibling element).
967
1253
  """
1254
+ has_table = include_table and bool(snap.columns)
1255
+ chart_h = _SVG_CHART_H if snap.chart is not None else 0
1256
+
1257
+ # Pre-layout the table (we need its height before declaring outer SVG height).
1258
+ if has_table:
1259
+ table_y = _SVG_PADDING + (_SVG_HEADER_H if include_chrome else 0) + chart_h + _SVG_TABLE_GAP
1260
+ table_svg, table_h, table_w = _render_svg_table(
1261
+ snap, palette=palette, x=_SVG_PADDING, y=table_y, max_width=_SVG_WIDTH,
1262
+ )
1263
+ else:
1264
+ table_svg, table_h, table_w = "", 0.0, 0.0
1265
+
1266
+ # Canvas grows when the table's used width exceeds _SVG_WIDTH — happens
1267
+ # only when the min-col-w clamp fired (pathological top_n). Header /
1268
+ # chart / footer keep their original positioning anchored at _SVG_WIDTH;
1269
+ # the canvas just extends to the right so wide tables aren't clipped at
1270
+ # the viewBox. Issue #38 follow-up (Codex PR #40 P2).
1271
+ content_w = max(_SVG_WIDTH, table_w)
1272
+
1273
+ table_block_h = (_SVG_TABLE_GAP + table_h) if has_table else 0
1274
+
968
1275
  if include_chrome:
969
- height = _SVG_HEADER_H + _SVG_CHART_H + _SVG_FOOTER_H + (_SVG_PADDING * 2)
1276
+ height = _SVG_HEADER_H + chart_h + table_block_h + _SVG_FOOTER_H + (_SVG_PADDING * 2)
970
1277
  else:
971
- height = _SVG_CHART_H + (_SVG_PADDING * 2)
1278
+ height = chart_h + table_block_h + (_SVG_PADDING * 2)
972
1279
 
973
- pieces = []
1280
+ pieces: list[str] = []
974
1281
 
975
1282
  if include_chrome:
976
1283
  pieces.append(_render_svg_header(
@@ -978,7 +1285,6 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
978
1285
  x=_SVG_PADDING, y=_SVG_PADDING, width=_SVG_WIDTH,
979
1286
  ))
980
1287
 
981
- # Chart.
982
1288
  chart_y = _SVG_PADDING + (_SVG_HEADER_H if include_chrome else 0)
983
1289
  if snap.chart is not None:
984
1290
  if isinstance(snap.chart, LineChart):
@@ -997,16 +1303,18 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
997
1303
  x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
998
1304
  ))
999
1305
 
1306
+ if has_table:
1307
+ pieces.append(table_svg)
1308
+
1000
1309
  if include_chrome:
1310
+ footer_y = _SVG_PADDING + _SVG_HEADER_H + chart_h + table_block_h + _SVG_FOOTER_BASELINE
1001
1311
  pieces.append(_render_svg_footer(
1002
1312
  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,
1313
+ x=_SVG_PADDING, y=footer_y,
1314
+ width=_SVG_WIDTH, branding=branding,
1007
1315
  ))
1008
1316
 
1009
- total_w = _SVG_WIDTH + (_SVG_PADDING * 2)
1317
+ total_w = content_w + (_SVG_PADDING * 2)
1010
1318
  bg_rect = svg_rect(0, 0, total_w, height, fill=palette["bg"])
1011
1319
  inner = bg_rect + "".join(pieces)
1012
1320
  return (
@@ -1093,7 +1401,7 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
1093
1401
  # rather than rendering empty chrome (an empty `<svg>` chart area or an
1094
1402
  # `<table>` with no `<th>`/`<td>`).
1095
1403
  chart_html = (
1096
- f'<div style="margin-top:12px">{_render_svg(snap, palette=palette, branding=False, include_chrome=False)}</div>'
1404
+ f'<div style="margin-top:12px">{_render_svg(snap, palette=palette, branding=False, include_chrome=False, include_table=False)}</div>'
1097
1405
  if snap.chart is not None else ""
1098
1406
  )
1099
1407
  title_html = f'<h1 style="font-size:20px;color:{palette["fg"]};margin:0">{_xml_escape(snap.title)}</h1>'
@@ -1221,14 +1529,14 @@ def _build_md_frontmatter(snap: ShareSnapshot) -> str:
1221
1529
  """
1222
1530
  period = snap.period
1223
1531
  period_iso = (
1224
- f"{period.start.isoformat()}.."
1225
- f"{period.end.isoformat()}"
1532
+ f"{_format_generated_at_iso(period.start)}.."
1533
+ f"{_format_generated_at_iso(period.end)}"
1226
1534
  )
1227
1535
  anonymized = "true" if _snapshot_is_anonymized(snap) else "false"
1228
1536
  lines = [
1229
1537
  "---",
1230
1538
  f"title: {_yaml_scalar(snap.title)}",
1231
- f"generated_at: {snap.generated_at.isoformat()}",
1539
+ f"generated_at: {_format_generated_at_iso(snap.generated_at)}",
1232
1540
  f"period: {period_iso}",
1233
1541
  f"panel: {snap.cmd}",
1234
1542
  ]
@@ -1260,16 +1568,20 @@ def _yaml_scalar(s: str) -> str:
1260
1568
 
1261
1569
 
1262
1570
  def _snapshot_is_anonymized(snap: ShareSnapshot) -> bool:
1263
- """Return True if every ProjectCell label is either an anon token or a sentinel.
1571
+ """Return True if every project label (cell or column) is anon or sentinel.
1264
1572
 
1265
1573
  `_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?").
1574
+ A snapshot with no `ProjectCell` rows AND no `kind='project'` columns
1575
+ returns False (nothing was anonymized because there was nothing to
1576
+ anonymize). `(unknown)` is the project-share sentinel for missing
1577
+ project_path (see `cmd_project`'s `_proj_label_for`) — it is never a
1578
+ revealed real name, so it is counted as also-anonymized. Mixed snapshots
1579
+ (some scrubbed, some revealed) are reported False to keep the
1580
+ frontmatter semantic ("are projects revealed in this MD?").
1581
+
1582
+ Cross-tab Detail templates (issue #33) carry project labels in
1583
+ `kind='project'` columns rather than `ProjectCell` rows; we walk both
1584
+ surfaces so MD frontmatter `anonymized:` stays correct for those panels.
1273
1585
  """
1274
1586
  cells = [
1275
1587
  cell
@@ -1277,11 +1589,16 @@ def _snapshot_is_anonymized(snap: ShareSnapshot) -> bool:
1277
1589
  for cell in row.cells.values()
1278
1590
  if isinstance(cell, ProjectCell)
1279
1591
  ]
1280
- if not cells:
1592
+ project_cols = [col for col in snap.columns if col.kind == "project"]
1593
+ if not cells and not project_cols:
1281
1594
  return False
1282
- return all(
1283
- re.fullmatch(r"project-\d+", c.label) or c.label == "(unknown)"
1284
- for c in cells
1595
+
1596
+ def _is_anon(label: str) -> bool:
1597
+ return bool(re.fullmatch(r"project-\d+", label)) or label == "(unknown)"
1598
+
1599
+ return (
1600
+ all(_is_anon(c.label) for c in cells)
1601
+ and all(_is_anon(col.label) for col in project_cols)
1285
1602
  )
1286
1603
 
1287
1604
 
@@ -1443,8 +1760,9 @@ def _stitch_md(sections: tuple[ComposedSection, ...], *,
1443
1760
  parts.append(
1444
1761
  "---\n"
1445
1762
  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"
1763
+ f"generated_at: {_format_generated_at_iso(first_snap.generated_at)}\n"
1764
+ f"period: {_format_generated_at_iso(earliest)}.."
1765
+ f"{_format_generated_at_iso(latest)}\n"
1448
1766
  f"panel: composed\n"
1449
1767
  f"anonymized: {anon_field}\n"
1450
1768
  f"cctally_version: {first_snap.version}\n"