coeftable 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {coeftable-0.5.0 → coeftable-0.7.0}/.github/workflows/ci.yml +0 -1
  2. {coeftable-0.5.0 → coeftable-0.7.0}/.gitignore +1 -0
  3. coeftable-0.7.0/AGENTS.md +8 -0
  4. {coeftable-0.5.0 → coeftable-0.7.0}/CHANGELOG.md +41 -0
  5. {coeftable-0.5.0 → coeftable-0.7.0}/PKG-INFO +291 -1
  6. {coeftable-0.5.0 → coeftable-0.7.0}/README.md +290 -0
  7. {coeftable-0.5.0 → coeftable-0.7.0}/pyproject.toml +14 -2
  8. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/_version.py +2 -2
  9. coeftable-0.7.0/src/coeftable/cards/__init__.py +69 -0
  10. coeftable-0.7.0/src/coeftable/cards/adornments.py +272 -0
  11. coeftable-0.7.0/src/coeftable/cards/card.py +127 -0
  12. coeftable-0.7.0/src/coeftable/cards/chrome.py +85 -0
  13. coeftable-0.7.0/src/coeftable/cards/fragments.py +248 -0
  14. coeftable-0.7.0/src/coeftable/cards/measure.py +378 -0
  15. coeftable-0.7.0/src/coeftable/cards/panel.py +404 -0
  16. coeftable-0.7.0/src/coeftable/cards/regions.py +600 -0
  17. coeftable-0.7.0/src/coeftable/cards/template.py +110 -0
  18. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/frame.py +1 -1
  19. coeftable-0.7.0/src/coeftable/graph/__init__.py +34 -0
  20. coeftable-0.7.0/src/coeftable/graph/breakout.py +539 -0
  21. coeftable-0.7.0/src/coeftable/graph/driver_tree.py +1161 -0
  22. coeftable-0.7.0/src/coeftable/graph/honesty.py +353 -0
  23. coeftable-0.7.0/src/coeftable/graph/metric_tree.py +250 -0
  24. coeftable-0.7.0/src/coeftable/graph/model.py +969 -0
  25. coeftable-0.7.0/src/coeftable/graph/render.py +144 -0
  26. coeftable-0.7.0/src/coeftable/graph/report.py +192 -0
  27. coeftable-0.7.0/src/coeftable/graph/state.py +215 -0
  28. coeftable-0.7.0/src/coeftable/graph/timeline.py +384 -0
  29. coeftable-0.7.0/src/coeftable/graph/topology.py +183 -0
  30. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/grid.py +1 -1
  31. coeftable-0.7.0/src/coeftable/plots.py +36 -0
  32. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/svg.py +37 -26
  33. coeftable-0.7.0/tests/test_breakout.py +1511 -0
  34. coeftable-0.7.0/tests/test_card_entry.py +279 -0
  35. coeftable-0.7.0/tests/test_cards.py +1415 -0
  36. coeftable-0.7.0/tests/test_driver_tree.py +1606 -0
  37. coeftable-0.7.0/tests/test_graph.py +2907 -0
  38. coeftable-0.7.0/tests/test_graph_report.py +90 -0
  39. coeftable-0.7.0/tests/test_honesty.py +451 -0
  40. coeftable-0.7.0/tests/test_panel.py +688 -0
  41. coeftable-0.7.0/tests/test_plots.py +148 -0
  42. coeftable-0.7.0/tests/test_regions.py +871 -0
  43. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_svg.py +158 -0
  44. coeftable-0.7.0/tests/test_timeline.py +468 -0
  45. coeftable-0.5.0/AGENTS.md +0 -7
  46. {coeftable-0.5.0 → coeftable-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  47. {coeftable-0.5.0 → coeftable-0.7.0}/.github/release.yml +0 -0
  48. {coeftable-0.5.0 → coeftable-0.7.0}/.github/workflows/post-release.yml +0 -0
  49. {coeftable-0.5.0 → coeftable-0.7.0}/.github/workflows/publish.yml +0 -0
  50. {coeftable-0.5.0 → coeftable-0.7.0}/.github/workflows/release.yml +0 -0
  51. {coeftable-0.5.0 → coeftable-0.7.0}/.pre-commit-config.yaml +0 -0
  52. {coeftable-0.5.0 → coeftable-0.7.0}/LICENSE +0 -0
  53. {coeftable-0.5.0 → coeftable-0.7.0}/Makefile +0 -0
  54. {coeftable-0.5.0 → coeftable-0.7.0}/docs/images/example.png +0 -0
  55. {coeftable-0.5.0 → coeftable-0.7.0}/docs/images/forest-robust-autoscale.png +0 -0
  56. {coeftable-0.5.0 → coeftable-0.7.0}/docs/images/trend-example.png +0 -0
  57. {coeftable-0.5.0 → coeftable-0.7.0}/noxfile.py +0 -0
  58. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/__init__.py +0 -0
  59. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/_axis.py +0 -0
  60. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/annotations.py +0 -0
  61. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/collapsible.py +0 -0
  62. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/errors.py +0 -0
  63. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/format.py +0 -0
  64. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/render.py +0 -0
  65. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/series.py +0 -0
  66. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/spec.py +0 -0
  67. {coeftable-0.5.0 → coeftable-0.7.0}/src/coeftable/theme.py +0 -0
  68. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_annotations.py +0 -0
  69. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_collapsible.py +0 -0
  70. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_format.py +0 -0
  71. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_frame.py +0 -0
  72. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_package.py +0 -0
  73. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_public_api.py +0 -0
  74. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_render.py +0 -0
  75. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_series.py +0 -0
  76. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_sparkline.py +0 -0
  77. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_spec.py +0 -0
  78. {coeftable-0.5.0 → coeftable-0.7.0}/tests/test_theme.py +0 -0
  79. {coeftable-0.5.0 → coeftable-0.7.0}/uv.lock +0 -0
@@ -4,7 +4,6 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  pull_request:
7
- branches: [main]
8
7
 
9
8
  jobs:
10
9
  prek:
@@ -20,3 +20,4 @@ src/coeftable/_version.py
20
20
  docs/superpowers/
21
21
  # local feature worktrees
22
22
  /.worktrees/
23
+ .superpowers/
@@ -0,0 +1,8 @@
1
+ # Agent guidelines
2
+
3
+ ## Changelog
4
+
5
+ Never edit `CHANGELOG.md`. It is generated by release automation:
6
+ `.github/workflows/post-release.yml` runs the `changelog-from-release` action
7
+ against it, from release notes built out of merged PR titles and categorized by
8
+ `.github/release.yml`. Describe changes in the PR description instead.
@@ -1,3 +1,42 @@
1
+ <a id="v0.6.0"></a>
2
+ # [v0.6.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.6.0) - 2026-08-27
3
+
4
+ <!-- Release notes generated using configuration in .github/release.yml at v0.6.0 -->
5
+
6
+ ## What's Changed
7
+ ### New Features
8
+ * Add coeftable.plots public facade for SVG plot primitives by [@kylejcaron](https://github.com/kylejcaron) in [#35](https://github.com/kylejcaron/coeftable/pull/35)
9
+ * Add typed card adornment vocabulary and HTML rendering by [@kylejcaron](https://github.com/kylejcaron) in [#36](https://github.com/kylejcaron/coeftable/pull/36)
10
+ * Add exact card measurement engine (chrome geometry) by [@kylejcaron](https://github.com/kylejcaron) in [#37](https://github.com/kylejcaron/coeftable/pull/37)
11
+ * Add trend, interval, metric, diagnostics and event card regions by [@kylejcaron](https://github.com/kylejcaron) in [#38](https://github.com/kylejcaron/coeftable/pull/38)
12
+ * Add public Card and CardGrid entry points by [@kylejcaron](https://github.com/kylejcaron) in [#39](https://github.com/kylejcaron/coeftable/pull/39)
13
+ * Add the panel composition layer by [@kylejcaron](https://github.com/kylejcaron) in [#40](https://github.com/kylejcaron/coeftable/pull/40)
14
+ * Add the metric tree / driver-tree graph builder by [@kylejcaron](https://github.com/kylejcaron) in [#41](https://github.com/kylejcaron/coeftable/pull/41)
15
+ ### Maintenance
16
+ * Update changelog for v0.5.0 by [@github-actions](https://github.com/github-actions)[bot] in [#34](https://github.com/kylejcaron/coeftable/pull/34)
17
+
18
+
19
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.5.0...v0.6.0
20
+
21
+ [Changes][v0.6.0]
22
+
23
+
24
+ <a id="v0.5.0"></a>
25
+ # [v0.5.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.5.0) - 2026-08-19
26
+
27
+ <!-- Release notes generated using configuration in .github/release.yml at v0.5.0 -->
28
+
29
+ ## What's Changed
30
+ ### Maintenance
31
+ * Update changelog for v0.4.0 by [@github-actions](https://github.com/github-actions)[bot] in [#32](https://github.com/kylejcaron/coeftable/pull/32)
32
+ * feat: forest plot outlier handling. robust autoscale + margin-fade clipping by [@kylejcaron](https://github.com/kylejcaron) in [#33](https://github.com/kylejcaron/coeftable/pull/33)
33
+
34
+
35
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.4.0...v0.5.0
36
+
37
+ [Changes][v0.5.0]
38
+
39
+
1
40
  <a id="v0.4.0"></a>
2
41
  # [v0.4.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.4.0) - 2026-08-14
3
42
 
@@ -108,6 +147,8 @@
108
147
  [Changes][v0.1.0]
109
148
 
110
149
 
150
+ [v0.6.0]: https://github.com/kylejcaron/coeftable/compare/v0.5.0...v0.6.0
151
+ [v0.5.0]: https://github.com/kylejcaron/coeftable/compare/v0.4.0...v0.5.0
111
152
  [v0.4.0]: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
112
153
  [v0.3.1]: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
113
154
  [v0.3.0]: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coeftable
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Publication-quality summary tables for estimates with uncertainty.
5
5
  Project-URL: Homepage, https://github.com/kylejcaron/coeftable
6
6
  Project-URL: Repository, https://github.com/kylejcaron/coeftable
@@ -470,6 +470,296 @@ absolute = pl.DataFrame(
470
470
  )
471
471
  ```
472
472
 
473
+ ## Standalone plots
474
+
475
+ The inline SVG plots that power `.forest(...)` and `.sparkline(...)` columns
476
+ are available directly from `coeftable.plots` — the same emitters, the same
477
+ themes, no table required. Each function returns a complete `<svg>` string
478
+ for embedding anywhere HTML goes:
479
+
480
+ ```python
481
+ from coeftable.plots import forest_bar, sparkline_bar
482
+ from coeftable.theme import DEFAULT
483
+ import coeftable as ct
484
+
485
+ bar = forest_bar(
486
+ 1.2, 0.4, 2.0, # estimate, lower, upper
487
+ domain=(-1.0, 3.0),
488
+ ref=0.0,
489
+ color=DEFAULT.favorable,
490
+ theme=DEFAULT,
491
+ )
492
+
493
+ line = sparkline_bar(
494
+ [0.0, 1.0, 2.0], # x
495
+ [1.0, 1.5, 2.0], # y
496
+ [0.5, 1.0, 1.6], # lower
497
+ [1.5, 2.0, 2.4], # upper
498
+ x_domain=(0.0, 2.0),
499
+ domain=(0.0, 3.0),
500
+ ref=0.0,
501
+ color=DEFAULT.favorable,
502
+ fmt=ct.Number(),
503
+ theme=DEFAULT,
504
+ )
505
+ ```
506
+
507
+ Also exported: `sparkline_multi` + `Trace` for overlaid series,
508
+ `forest_axis` / `sparkline_axis` for the shared axis footer under a column
509
+ of plots, and `ResolvedRule` / `ResolvedBand` for reference lines and
510
+ shaded intervals via each function's `annotations=` parameter. Because a
511
+ standalone plot and a table column share one `Theme`, a report that mixes
512
+ both stays visually consistent.
513
+
514
+ ## Metric cards (experimental)
515
+
516
+ `coeftable.cards` composes the standalone plots into measured, foldable
517
+ metric cards — the same formatters, themes, and SVG primitives as the
518
+ tables. The API is experimental and may change; it is deliberately not
519
+ exported from the top-level `coeftable` namespace yet.
520
+
521
+ ```python
522
+ import coeftable as ct
523
+ from coeftable.cards import Card, CardGrid
524
+ from coeftable.cards.regions import Diagnostics, Event, Events, Metric, Trend
525
+
526
+ events = Events([Event("launch", "#4C72B0", at=2.0)])
527
+ revenue = Card(
528
+ "Revenue",
529
+ subtitle="weekly lift vs control",
530
+ content=[
531
+ Metric(3.4, ct.Percent(signed=True), ci=(1.2, 5.7), ref=0.0),
532
+ Trend(
533
+ x=(0, 1, 2, 3), y=(0.3, 0.8, 1.1, 1.5),
534
+ lower=(-0.1, 0.3, 0.6, 0.9), upper=(0.7, 1.3, 1.6, 2.1),
535
+ x_domain=(0, 3), domain=(-0.5, 2.5), ref=0.0,
536
+ annotations=events.rules(),
537
+ ),
538
+ Diagnostics("diagnostics", [("n", 412), ("sigma", 0.8)]),
539
+ events,
540
+ ],
541
+ )
542
+ grid = CardGrid([revenue, revenue.with_theme(ct.theme.BLUE)])
543
+ ```
544
+
545
+ A `Card` (or `CardGrid`) left as the last notebook expression renders
546
+ itself; `as_raw_html()` is the string entry point. Cards are fixed-width
547
+ with exact measured heights and fold via a native `<details>` header (the
548
+ headline value stays visible as a chip when it fits beside the header,
549
+ and is omitted otherwise). A standalone card collapses to
550
+ its header; inside a `CardGrid` each card's expanded footprint stays
551
+ reserved, so folding never reflows neighbors.
552
+
553
+ ## Metric panels (experimental)
554
+
555
+ `coeftable.cards` panels put named side-by-side panes in one bordered shell
556
+ with a shared header and footer: the compositional step above cards. The API
557
+ is experimental and may change; it is deliberately not exported from the
558
+ top-level `coeftable` namespace yet.
559
+
560
+ ```python
561
+ import coeftable as ct
562
+ from coeftable.cards import Metric, Pane, Panel, Row, TextBlock
563
+
564
+ summary = Pane(
565
+ "Summary",
566
+ content=(
567
+ TextBlock("Weekly revenue", variant="subtitle"),
568
+ Metric(3.4, ct.Percent(signed=True), ci=(1.2, 5.7), ref=0.0),
569
+ ),
570
+ width=220,
571
+ )
572
+ segments = Pane(
573
+ "Segments",
574
+ content=(
575
+ Row(
576
+ (
577
+ (TextBlock("Enterprise"), 85),
578
+ (Metric(8.1, ct.Percent(signed=True)), 105),
579
+ )
580
+ ),
581
+ ),
582
+ width=200,
583
+ )
584
+ header = Row(
585
+ (
586
+ (TextBlock("Revenue", variant="title"), 320),
587
+ (Metric(3.4, ct.Percent(signed=True)), 120),
588
+ ),
589
+ gap=16,
590
+ )
591
+ panel = Panel((summary, segments), header=(header,))
592
+ html = panel.as_raw_html()
593
+ ```
594
+
595
+ Panes declare usable content widths; cell widths are author inputs; the panel
596
+ derives its own footprint with exact measurement, not responsive guessing.
597
+
598
+ A `Panel` left as the last notebook expression renders itself via
599
+ `_repr_html_`; `with_theme()` returns the same composition under another
600
+ theme.
601
+
602
+ ## Metric trees (experimental)
603
+
604
+ `coeftable.graph` turns metric cards wired into a driver DAG into a metric
605
+ tree: the graph layer adds slotted layout, vertical wires, and zero-JS
606
+ fold/downstream-hide behavior. The API is experimental and may change; it is
607
+ deliberately not exported from the top-level `coeftable` namespace yet.
608
+
609
+ ```python
610
+ import coeftable as ct
611
+ from coeftable.cards import Card
612
+ from coeftable.graph import MetricTree
613
+
614
+ tree = MetricTree(
615
+ nodes=(
616
+ ("revenue", Card("Revenue")),
617
+ ("price", Card("Price")),
618
+ ("volume", Card("Volume")),
619
+ ("retention", Card("Retention")),
620
+ ),
621
+ edges=(
622
+ ("revenue", "price", 1.2),
623
+ ("revenue", "volume", 2.1),
624
+ ("volume", "retention", -0.6),
625
+ ),
626
+ fmt=ct.Number(suffix=' pp', decimals=1, signed=True, thousands=False),
627
+ dom_prefix='drivers',
628
+ )
629
+ html = tree.as_raw_html()
630
+ ```
631
+
632
+ Use a distinct `dom_prefix` for each tree when rendering multiple graphs in one
633
+ document, so their generated control, card, and wire ids remain disjoint.
634
+
635
+ Each card retains its native `<details>` fold control. The nub under a parent
636
+ hides a node only when every root path to it is blocked: collapsing one parent
637
+ of a shared child leaves that child visible via the other path, all with pure
638
+ CSS and no JavaScript.
639
+
640
+ A `MetricTree` left as the last notebook expression renders itself via
641
+ `_repr_html_`; `with_theme()` follows the same conventions as cards.
642
+
643
+ ## Driver-tree reports (experimental)
644
+
645
+ `coeftable.graph.DriverTree` is the composition root over the metric-tree
646
+ layer: give it level series and a decomposition per parent, and it builds the
647
+ cards, wires, and layout itself, plus three opt-in honesty checks a hand-built
648
+ `MetricTree` graph would otherwise have to redo per node. The API is
649
+ experimental and may change; it is deliberately not exported from the
650
+ top-level `coeftable` namespace yet.
651
+
652
+ ```python
653
+ import coeftable as ct
654
+ from coeftable.graph import DriverTree
655
+ from coeftable.graph.breakout import Breakout
656
+ from coeftable.graph.timeline import TimelineEvent
657
+
658
+ x = (0.0, 1.0, 2.0, 3.0)
659
+ series = {
660
+ "revenue": (1000.0, 1071.0, 1144.0, 1219.0),
661
+ "users": (100.0, 105.0, 110.0, 115.0),
662
+ "aov": (10.0, 10.2, 10.4, 10.6),
663
+ "us": (600.0, 640.0, 680.0, 720.0),
664
+ "eu": (400.0, 431.0, 464.0, 499.0),
665
+ }
666
+ titles = {"revenue": "Revenue", "users": "Users", "aov": "AOV", "us": "US", "eu": "EU"}
667
+ breakouts = {
668
+ "revenue": (
669
+ Breakout(key="drivers", label="by drivers", op="x", children=("users", "aov")),
670
+ Breakout(key="region", label="by region", op="+", children=("us", "eu")),
671
+ )
672
+ }
673
+ events = (TimelineEvent(at=1.0, label="Launch", color="#4C72B0", affects=("revenue", "users")),)
674
+
675
+ report = DriverTree(
676
+ series,
677
+ titles,
678
+ breakouts,
679
+ ct.Percent(decimals=1),
680
+ x,
681
+ events=events,
682
+ level_fmt=ct.Number(decimals=1),
683
+ )
684
+ html = report.as_raw_html()
685
+ ```
686
+
687
+ Every node id in `series` and `titles` is derived from `breakouts`: each key
688
+ is a parent, and each `Breakout` names one decomposition of it (an `op="x"`
689
+ product or an `op="+"` sum) plus the child node ids it contributes. A parent
690
+ with two or more breakouts (like `revenue` above) renders a native
691
+ `<select>` that swaps its children's whole subtree in place — no JavaScript,
692
+ because the alternatives share one slotted position and pure CSS shows
693
+ exactly one of them at a time.
694
+
695
+ Each decomposition is checked against its own arithmetic: a parent that
696
+ should equal the sum or product of its children but falls short gets an
697
+ injected `"Unattributed"` residual card (additive shortfalls) or a reported
698
+ gap badge (multiplicative shortfalls, which have no subtraction fix), and a
699
+ decomposition explaining under 80% of its parent refuses to build rather than
700
+ render a misleading tree. Every wire's label role comes from its own child's
701
+ noise-aware confidence interval, so a wobbly delta renders muted with a
702
+ `· ns` marker instead of a confident color. Anti-correlated siblings (movers
703
+ that trade off against each other week to week) surface a callout hosted on
704
+ one of the participating children, so it stays visible only alongside that
705
+ alternative rather than the parent, which stays visible under every switch.
706
+ The root card renders no text of its own; pass `caption=` to add one. It
707
+ wraps to fit the card using an estimated character width, growing across
708
+ as many lines as that estimate needs. Runs of whitespace collapse to a
709
+ single space and line breaks are not preserved, and unusually wide text
710
+ can still be clipped on a line, since the wrap is an estimate rather than
711
+ a font measurement. A literal `{weeks}` placeholder is substituted for the
712
+ observed period count, and `caption` defaults to `None`, so nothing
713
+ renders unless you ask for it.
714
+ If you want a note that edge labels are accounting, not causal
715
+ claims, that wording is yours to choose — for example
716
+ `caption="Edge labels are an accounting of the realized {weeks}-week
717
+ change, not causal impact."` `events` fan out to every card named in their
718
+ `affects` tuple, both as sparkline markers and as captions, and the
719
+ report's header is a timeline strip indexing them across the whole canvas.
720
+ That strip carries no heading of its own — pass `strip_title=` if you want
721
+ one, since only you know whether your events are deploys, holidays, or
722
+ experiments. With neither events nor a `strip_title` there is nothing to
723
+ index and no heading to show, so the strip is omitted rather than reserving
724
+ space for a bare axis; supplying either one brings it back.
725
+
726
+ **Switchers can nest.** Every level of the tree may carry its own switcher at
727
+ the same time — revenue by drivers or region, and within drivers, active users
728
+ by funnel or country, and within that, sessions by platform or channel.
729
+ Switching an outer choice takes away the whole branch beneath it, including any
730
+ nested switcher and its alternatives; switching back restores it with the inner
731
+ selection intact, because a native `<select>` keeps its value while hidden.
732
+
733
+ **Limitations.** All alternatives of one switcher must have the same number of
734
+ children, since they share the same positions.
735
+
736
+ And every card must keep a visible parent under every combination of choices. A
737
+ card that disappears together with its own alternative is fine — that is the
738
+ point of a switcher. What is refused is a card whose visibility depends on
739
+ several switchers that do not subsume one another, so that some combination of
740
+ selections leaves it on the page with every route to it closed.
741
+
742
+ Nesting satisfies this on its own: an outer choice that removes a branch is
743
+ decided by the outer rule alone, and everything below goes with it. Being
744
+ reachable through more than one switcher is not by itself a problem either — if
745
+ every option of each switcher reaches the card, or if any route to it is
746
+ unconditional, it always keeps a parent and the tree is accepted.
747
+
748
+ One shape is refused: a card where *every* route can be independently closed and
749
+ none survives unconditionally. The clearest example is a card reachable through a
750
+ nested branch and also through a different alternative of the switcher above it.
751
+ Keep the outer branch that preserves the nested switcher, then pick an inner
752
+ option that does not reach the card, and both of its routes close at once. Since
753
+ each switcher's rules are emitted independently, no single one can see that. Such
754
+ a tree is rejected at construction with a `SpecError` naming the card and every
755
+ switcher implicated in it.
756
+
757
+ `DriverTree` returns a `GraphReport`: the underlying `Graph`, and above it a
758
+ measured, exact-width timeline strip whenever events or a `strip_title` call
759
+ for one. `report.measure()`,
760
+ `report.as_raw_html()`, and its `_repr_html_` notebook display all work the
761
+ same way they do for a plain `Graph`.
762
+
473
763
  ## Plot annotations
474
764
 
475
765
  `ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
@@ -416,6 +416,296 @@ absolute = pl.DataFrame(
416
416
  )
417
417
  ```
418
418
 
419
+ ## Standalone plots
420
+
421
+ The inline SVG plots that power `.forest(...)` and `.sparkline(...)` columns
422
+ are available directly from `coeftable.plots` — the same emitters, the same
423
+ themes, no table required. Each function returns a complete `<svg>` string
424
+ for embedding anywhere HTML goes:
425
+
426
+ ```python
427
+ from coeftable.plots import forest_bar, sparkline_bar
428
+ from coeftable.theme import DEFAULT
429
+ import coeftable as ct
430
+
431
+ bar = forest_bar(
432
+ 1.2, 0.4, 2.0, # estimate, lower, upper
433
+ domain=(-1.0, 3.0),
434
+ ref=0.0,
435
+ color=DEFAULT.favorable,
436
+ theme=DEFAULT,
437
+ )
438
+
439
+ line = sparkline_bar(
440
+ [0.0, 1.0, 2.0], # x
441
+ [1.0, 1.5, 2.0], # y
442
+ [0.5, 1.0, 1.6], # lower
443
+ [1.5, 2.0, 2.4], # upper
444
+ x_domain=(0.0, 2.0),
445
+ domain=(0.0, 3.0),
446
+ ref=0.0,
447
+ color=DEFAULT.favorable,
448
+ fmt=ct.Number(),
449
+ theme=DEFAULT,
450
+ )
451
+ ```
452
+
453
+ Also exported: `sparkline_multi` + `Trace` for overlaid series,
454
+ `forest_axis` / `sparkline_axis` for the shared axis footer under a column
455
+ of plots, and `ResolvedRule` / `ResolvedBand` for reference lines and
456
+ shaded intervals via each function's `annotations=` parameter. Because a
457
+ standalone plot and a table column share one `Theme`, a report that mixes
458
+ both stays visually consistent.
459
+
460
+ ## Metric cards (experimental)
461
+
462
+ `coeftable.cards` composes the standalone plots into measured, foldable
463
+ metric cards — the same formatters, themes, and SVG primitives as the
464
+ tables. The API is experimental and may change; it is deliberately not
465
+ exported from the top-level `coeftable` namespace yet.
466
+
467
+ ```python
468
+ import coeftable as ct
469
+ from coeftable.cards import Card, CardGrid
470
+ from coeftable.cards.regions import Diagnostics, Event, Events, Metric, Trend
471
+
472
+ events = Events([Event("launch", "#4C72B0", at=2.0)])
473
+ revenue = Card(
474
+ "Revenue",
475
+ subtitle="weekly lift vs control",
476
+ content=[
477
+ Metric(3.4, ct.Percent(signed=True), ci=(1.2, 5.7), ref=0.0),
478
+ Trend(
479
+ x=(0, 1, 2, 3), y=(0.3, 0.8, 1.1, 1.5),
480
+ lower=(-0.1, 0.3, 0.6, 0.9), upper=(0.7, 1.3, 1.6, 2.1),
481
+ x_domain=(0, 3), domain=(-0.5, 2.5), ref=0.0,
482
+ annotations=events.rules(),
483
+ ),
484
+ Diagnostics("diagnostics", [("n", 412), ("sigma", 0.8)]),
485
+ events,
486
+ ],
487
+ )
488
+ grid = CardGrid([revenue, revenue.with_theme(ct.theme.BLUE)])
489
+ ```
490
+
491
+ A `Card` (or `CardGrid`) left as the last notebook expression renders
492
+ itself; `as_raw_html()` is the string entry point. Cards are fixed-width
493
+ with exact measured heights and fold via a native `<details>` header (the
494
+ headline value stays visible as a chip when it fits beside the header,
495
+ and is omitted otherwise). A standalone card collapses to
496
+ its header; inside a `CardGrid` each card's expanded footprint stays
497
+ reserved, so folding never reflows neighbors.
498
+
499
+ ## Metric panels (experimental)
500
+
501
+ `coeftable.cards` panels put named side-by-side panes in one bordered shell
502
+ with a shared header and footer: the compositional step above cards. The API
503
+ is experimental and may change; it is deliberately not exported from the
504
+ top-level `coeftable` namespace yet.
505
+
506
+ ```python
507
+ import coeftable as ct
508
+ from coeftable.cards import Metric, Pane, Panel, Row, TextBlock
509
+
510
+ summary = Pane(
511
+ "Summary",
512
+ content=(
513
+ TextBlock("Weekly revenue", variant="subtitle"),
514
+ Metric(3.4, ct.Percent(signed=True), ci=(1.2, 5.7), ref=0.0),
515
+ ),
516
+ width=220,
517
+ )
518
+ segments = Pane(
519
+ "Segments",
520
+ content=(
521
+ Row(
522
+ (
523
+ (TextBlock("Enterprise"), 85),
524
+ (Metric(8.1, ct.Percent(signed=True)), 105),
525
+ )
526
+ ),
527
+ ),
528
+ width=200,
529
+ )
530
+ header = Row(
531
+ (
532
+ (TextBlock("Revenue", variant="title"), 320),
533
+ (Metric(3.4, ct.Percent(signed=True)), 120),
534
+ ),
535
+ gap=16,
536
+ )
537
+ panel = Panel((summary, segments), header=(header,))
538
+ html = panel.as_raw_html()
539
+ ```
540
+
541
+ Panes declare usable content widths; cell widths are author inputs; the panel
542
+ derives its own footprint with exact measurement, not responsive guessing.
543
+
544
+ A `Panel` left as the last notebook expression renders itself via
545
+ `_repr_html_`; `with_theme()` returns the same composition under another
546
+ theme.
547
+
548
+ ## Metric trees (experimental)
549
+
550
+ `coeftable.graph` turns metric cards wired into a driver DAG into a metric
551
+ tree: the graph layer adds slotted layout, vertical wires, and zero-JS
552
+ fold/downstream-hide behavior. The API is experimental and may change; it is
553
+ deliberately not exported from the top-level `coeftable` namespace yet.
554
+
555
+ ```python
556
+ import coeftable as ct
557
+ from coeftable.cards import Card
558
+ from coeftable.graph import MetricTree
559
+
560
+ tree = MetricTree(
561
+ nodes=(
562
+ ("revenue", Card("Revenue")),
563
+ ("price", Card("Price")),
564
+ ("volume", Card("Volume")),
565
+ ("retention", Card("Retention")),
566
+ ),
567
+ edges=(
568
+ ("revenue", "price", 1.2),
569
+ ("revenue", "volume", 2.1),
570
+ ("volume", "retention", -0.6),
571
+ ),
572
+ fmt=ct.Number(suffix=' pp', decimals=1, signed=True, thousands=False),
573
+ dom_prefix='drivers',
574
+ )
575
+ html = tree.as_raw_html()
576
+ ```
577
+
578
+ Use a distinct `dom_prefix` for each tree when rendering multiple graphs in one
579
+ document, so their generated control, card, and wire ids remain disjoint.
580
+
581
+ Each card retains its native `<details>` fold control. The nub under a parent
582
+ hides a node only when every root path to it is blocked: collapsing one parent
583
+ of a shared child leaves that child visible via the other path, all with pure
584
+ CSS and no JavaScript.
585
+
586
+ A `MetricTree` left as the last notebook expression renders itself via
587
+ `_repr_html_`; `with_theme()` follows the same conventions as cards.
588
+
589
+ ## Driver-tree reports (experimental)
590
+
591
+ `coeftable.graph.DriverTree` is the composition root over the metric-tree
592
+ layer: give it level series and a decomposition per parent, and it builds the
593
+ cards, wires, and layout itself, plus three opt-in honesty checks a hand-built
594
+ `MetricTree` graph would otherwise have to redo per node. The API is
595
+ experimental and may change; it is deliberately not exported from the
596
+ top-level `coeftable` namespace yet.
597
+
598
+ ```python
599
+ import coeftable as ct
600
+ from coeftable.graph import DriverTree
601
+ from coeftable.graph.breakout import Breakout
602
+ from coeftable.graph.timeline import TimelineEvent
603
+
604
+ x = (0.0, 1.0, 2.0, 3.0)
605
+ series = {
606
+ "revenue": (1000.0, 1071.0, 1144.0, 1219.0),
607
+ "users": (100.0, 105.0, 110.0, 115.0),
608
+ "aov": (10.0, 10.2, 10.4, 10.6),
609
+ "us": (600.0, 640.0, 680.0, 720.0),
610
+ "eu": (400.0, 431.0, 464.0, 499.0),
611
+ }
612
+ titles = {"revenue": "Revenue", "users": "Users", "aov": "AOV", "us": "US", "eu": "EU"}
613
+ breakouts = {
614
+ "revenue": (
615
+ Breakout(key="drivers", label="by drivers", op="x", children=("users", "aov")),
616
+ Breakout(key="region", label="by region", op="+", children=("us", "eu")),
617
+ )
618
+ }
619
+ events = (TimelineEvent(at=1.0, label="Launch", color="#4C72B0", affects=("revenue", "users")),)
620
+
621
+ report = DriverTree(
622
+ series,
623
+ titles,
624
+ breakouts,
625
+ ct.Percent(decimals=1),
626
+ x,
627
+ events=events,
628
+ level_fmt=ct.Number(decimals=1),
629
+ )
630
+ html = report.as_raw_html()
631
+ ```
632
+
633
+ Every node id in `series` and `titles` is derived from `breakouts`: each key
634
+ is a parent, and each `Breakout` names one decomposition of it (an `op="x"`
635
+ product or an `op="+"` sum) plus the child node ids it contributes. A parent
636
+ with two or more breakouts (like `revenue` above) renders a native
637
+ `<select>` that swaps its children's whole subtree in place — no JavaScript,
638
+ because the alternatives share one slotted position and pure CSS shows
639
+ exactly one of them at a time.
640
+
641
+ Each decomposition is checked against its own arithmetic: a parent that
642
+ should equal the sum or product of its children but falls short gets an
643
+ injected `"Unattributed"` residual card (additive shortfalls) or a reported
644
+ gap badge (multiplicative shortfalls, which have no subtraction fix), and a
645
+ decomposition explaining under 80% of its parent refuses to build rather than
646
+ render a misleading tree. Every wire's label role comes from its own child's
647
+ noise-aware confidence interval, so a wobbly delta renders muted with a
648
+ `· ns` marker instead of a confident color. Anti-correlated siblings (movers
649
+ that trade off against each other week to week) surface a callout hosted on
650
+ one of the participating children, so it stays visible only alongside that
651
+ alternative rather than the parent, which stays visible under every switch.
652
+ The root card renders no text of its own; pass `caption=` to add one. It
653
+ wraps to fit the card using an estimated character width, growing across
654
+ as many lines as that estimate needs. Runs of whitespace collapse to a
655
+ single space and line breaks are not preserved, and unusually wide text
656
+ can still be clipped on a line, since the wrap is an estimate rather than
657
+ a font measurement. A literal `{weeks}` placeholder is substituted for the
658
+ observed period count, and `caption` defaults to `None`, so nothing
659
+ renders unless you ask for it.
660
+ If you want a note that edge labels are accounting, not causal
661
+ claims, that wording is yours to choose — for example
662
+ `caption="Edge labels are an accounting of the realized {weeks}-week
663
+ change, not causal impact."` `events` fan out to every card named in their
664
+ `affects` tuple, both as sparkline markers and as captions, and the
665
+ report's header is a timeline strip indexing them across the whole canvas.
666
+ That strip carries no heading of its own — pass `strip_title=` if you want
667
+ one, since only you know whether your events are deploys, holidays, or
668
+ experiments. With neither events nor a `strip_title` there is nothing to
669
+ index and no heading to show, so the strip is omitted rather than reserving
670
+ space for a bare axis; supplying either one brings it back.
671
+
672
+ **Switchers can nest.** Every level of the tree may carry its own switcher at
673
+ the same time — revenue by drivers or region, and within drivers, active users
674
+ by funnel or country, and within that, sessions by platform or channel.
675
+ Switching an outer choice takes away the whole branch beneath it, including any
676
+ nested switcher and its alternatives; switching back restores it with the inner
677
+ selection intact, because a native `<select>` keeps its value while hidden.
678
+
679
+ **Limitations.** All alternatives of one switcher must have the same number of
680
+ children, since they share the same positions.
681
+
682
+ And every card must keep a visible parent under every combination of choices. A
683
+ card that disappears together with its own alternative is fine — that is the
684
+ point of a switcher. What is refused is a card whose visibility depends on
685
+ several switchers that do not subsume one another, so that some combination of
686
+ selections leaves it on the page with every route to it closed.
687
+
688
+ Nesting satisfies this on its own: an outer choice that removes a branch is
689
+ decided by the outer rule alone, and everything below goes with it. Being
690
+ reachable through more than one switcher is not by itself a problem either — if
691
+ every option of each switcher reaches the card, or if any route to it is
692
+ unconditional, it always keeps a parent and the tree is accepted.
693
+
694
+ One shape is refused: a card where *every* route can be independently closed and
695
+ none survives unconditionally. The clearest example is a card reachable through a
696
+ nested branch and also through a different alternative of the switcher above it.
697
+ Keep the outer branch that preserves the nested switcher, then pick an inner
698
+ option that does not reach the card, and both of its routes close at once. Since
699
+ each switcher's rules are emitted independently, no single one can see that. Such
700
+ a tree is rejected at construction with a `SpecError` naming the card and every
701
+ switcher implicated in it.
702
+
703
+ `DriverTree` returns a `GraphReport`: the underlying `Graph`, and above it a
704
+ measured, exact-width timeline strip whenever events or a `strip_title` call
705
+ for one. `report.measure()`,
706
+ `report.as_raw_html()`, and its `_repr_html_` notebook display all work the
707
+ same way they do for a plain `Graph`.
708
+
419
709
  ## Plot annotations
420
710
 
421
711
  `ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or