coeftable 0.4.0__tar.gz → 0.6.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 (68) hide show
  1. {coeftable-0.4.0 → coeftable-0.6.0}/.gitignore +1 -0
  2. coeftable-0.6.0/AGENTS.md +7 -0
  3. {coeftable-0.4.0 → coeftable-0.6.0}/CHANGELOG.md +32 -3
  4. {coeftable-0.4.0 → coeftable-0.6.0}/PKG-INFO +201 -1
  5. {coeftable-0.4.0 → coeftable-0.6.0}/README.md +200 -0
  6. coeftable-0.6.0/docs/images/forest-robust-autoscale.png +0 -0
  7. {coeftable-0.4.0 → coeftable-0.6.0}/pyproject.toml +14 -2
  8. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/_version.py +2 -2
  9. coeftable-0.6.0/src/coeftable/cards/__init__.py +67 -0
  10. coeftable-0.6.0/src/coeftable/cards/adornments.py +254 -0
  11. coeftable-0.6.0/src/coeftable/cards/card.py +127 -0
  12. coeftable-0.6.0/src/coeftable/cards/chrome.py +83 -0
  13. coeftable-0.6.0/src/coeftable/cards/fragments.py +240 -0
  14. coeftable-0.6.0/src/coeftable/cards/measure.py +361 -0
  15. coeftable-0.6.0/src/coeftable/cards/panel.py +402 -0
  16. coeftable-0.6.0/src/coeftable/cards/regions.py +598 -0
  17. coeftable-0.6.0/src/coeftable/cards/template.py +110 -0
  18. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/frame.py +1 -1
  19. coeftable-0.6.0/src/coeftable/graph/__init__.py +25 -0
  20. coeftable-0.6.0/src/coeftable/graph/metric_tree.py +250 -0
  21. coeftable-0.6.0/src/coeftable/graph/model.py +876 -0
  22. coeftable-0.6.0/src/coeftable/graph/render.py +144 -0
  23. coeftable-0.6.0/src/coeftable/graph/state.py +215 -0
  24. coeftable-0.6.0/src/coeftable/graph/topology.py +183 -0
  25. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/grid.py +1 -1
  26. coeftable-0.6.0/src/coeftable/plots.py +36 -0
  27. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/spec.py +48 -5
  28. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/svg.py +125 -42
  29. coeftable-0.6.0/tests/test_card_entry.py +279 -0
  30. coeftable-0.6.0/tests/test_cards.py +1342 -0
  31. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_frame.py +88 -0
  32. coeftable-0.6.0/tests/test_graph.py +2652 -0
  33. coeftable-0.6.0/tests/test_panel.py +688 -0
  34. coeftable-0.6.0/tests/test_plots.py +148 -0
  35. coeftable-0.6.0/tests/test_regions.py +871 -0
  36. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_svg.py +194 -0
  37. {coeftable-0.4.0 → coeftable-0.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  38. {coeftable-0.4.0 → coeftable-0.6.0}/.github/release.yml +0 -0
  39. {coeftable-0.4.0 → coeftable-0.6.0}/.github/workflows/ci.yml +0 -0
  40. {coeftable-0.4.0 → coeftable-0.6.0}/.github/workflows/post-release.yml +0 -0
  41. {coeftable-0.4.0 → coeftable-0.6.0}/.github/workflows/publish.yml +0 -0
  42. {coeftable-0.4.0 → coeftable-0.6.0}/.github/workflows/release.yml +0 -0
  43. {coeftable-0.4.0 → coeftable-0.6.0}/.pre-commit-config.yaml +0 -0
  44. {coeftable-0.4.0 → coeftable-0.6.0}/LICENSE +0 -0
  45. {coeftable-0.4.0 → coeftable-0.6.0}/Makefile +0 -0
  46. {coeftable-0.4.0 → coeftable-0.6.0}/docs/images/example.png +0 -0
  47. {coeftable-0.4.0 → coeftable-0.6.0}/docs/images/trend-example.png +0 -0
  48. {coeftable-0.4.0 → coeftable-0.6.0}/noxfile.py +0 -0
  49. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/__init__.py +0 -0
  50. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/_axis.py +0 -0
  51. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/annotations.py +0 -0
  52. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/collapsible.py +0 -0
  53. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/errors.py +0 -0
  54. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/format.py +0 -0
  55. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/render.py +0 -0
  56. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/series.py +0 -0
  57. {coeftable-0.4.0 → coeftable-0.6.0}/src/coeftable/theme.py +0 -0
  58. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_annotations.py +0 -0
  59. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_collapsible.py +0 -0
  60. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_format.py +0 -0
  61. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_package.py +0 -0
  62. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_public_api.py +0 -0
  63. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_render.py +0 -0
  64. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_series.py +0 -0
  65. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_sparkline.py +0 -0
  66. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_spec.py +0 -0
  67. {coeftable-0.4.0 → coeftable-0.6.0}/tests/test_theme.py +0 -0
  68. {coeftable-0.4.0 → coeftable-0.6.0}/uv.lock +0 -0
@@ -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,7 @@
1
+ # Agent guidelines
2
+
3
+ ## Changelog
4
+
5
+ Never edit `CHANGELOG.md`. It is generated by release automation
6
+ (`.github/release.yml`) from merged PR titles; Describe changes in the PR
7
+ description instead.
@@ -1,7 +1,34 @@
1
- ## Unreleased
1
+ <a id="v0.5.0"></a>
2
+ # [v0.5.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.5.0) - 2026-08-19
3
+
4
+ <!-- Release notes generated using configuration in .github/release.yml at v0.5.0 -->
5
+
6
+ ## What's Changed
7
+ ### Maintenance
8
+ * Update changelog for v0.4.0 by [@github-actions](https://github.com/github-actions)[bot] in [#32](https://github.com/kylejcaron/coeftable/pull/32)
9
+ * 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)
10
+
11
+
12
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.4.0...v0.5.0
13
+
14
+ [Changes][v0.5.0]
15
+
16
+
17
+ <a id="v0.4.0"></a>
18
+ # [v0.4.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.4.0) - 2026-08-14
19
+
20
+ <!-- Release notes generated using configuration in .github/release.yml at v0.4.0 -->
21
+
22
+ ## What's Changed
23
+ ### Maintenance
24
+ * build: align hatchling metadata floor by [@kylejcaron](https://github.com/kylejcaron) in [#30](https://github.com/kylejcaron/coeftable/pull/30)
25
+ * feature: support plot annotations by [@kylejcaron](https://github.com/kylejcaron) in [#31](https://github.com/kylejcaron/coeftable/pull/31)
26
+
27
+
28
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
29
+
30
+ [Changes][v0.4.0]
2
31
 
3
- ### Features
4
- - Add typed rule and band annotations to forest plots and sparklines, including row-specific field binding and domain-aware layering.
5
32
 
6
33
  <a id="v0.3.1"></a>
7
34
  # [v0.3.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.1) - 2026-08-14
@@ -97,6 +124,8 @@
97
124
  [Changes][v0.1.0]
98
125
 
99
126
 
127
+ [v0.5.0]: https://github.com/kylejcaron/coeftable/compare/v0.4.0...v0.5.0
128
+ [v0.4.0]: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
100
129
  [v0.3.1]: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
101
130
  [v0.3.0]: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
102
131
  [v0.2.1]: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coeftable
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -414,6 +414,36 @@ trend = pl.DataFrame(
414
414
  )
415
415
  ```
416
416
 
417
+ **Forest plots take `autoscale` too.** A single extreme interval (say a
418
+ +3000% lift among sub-1% metrics) otherwise stretches the shared domain
419
+ and flattens every other bar:
420
+
421
+ ```python
422
+ lifts = pl.DataFrame(
423
+ {
424
+ "metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
425
+ "rel": [0.03, -0.05, 0.08, 0.02, 30.0],
426
+ "rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
427
+ "rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
428
+ }
429
+ )
430
+
431
+ (
432
+ ct.CoefTable(lifts, rows="metric")
433
+ .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
434
+ # "robust" fits an IQR/Tukey fence over the pooled values instead of
435
+ # the raw min/max, so the outlier stops dictating the axis. Composes
436
+ # with symmetric= (fence first, then mirror around ref); ylim= still
437
+ # overrides everything.
438
+ .forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
439
+ )
440
+ ```
441
+
442
+ A discounted interval is never hidden: bars in a clipping domain plot
443
+ against a slightly inset region and the clipped interval continues into
444
+ the reserved margin as a gradient fade, reading as "extends beyond the
445
+ axis". Buckets with nothing clipped render exactly as before.
446
+
417
447
  **No reference, or a hidden one.** `ref` also drives the dashed line and
418
448
  colour resolution; two ways to opt out, for data with no meaningful
419
449
  zero (revenue, durations, absolute counts):
@@ -440,6 +470,176 @@ absolute = pl.DataFrame(
440
470
  )
441
471
  ```
442
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
+
443
643
  ## Plot annotations
444
644
 
445
645
  `ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
@@ -360,6 +360,36 @@ trend = pl.DataFrame(
360
360
  )
361
361
  ```
362
362
 
363
+ **Forest plots take `autoscale` too.** A single extreme interval (say a
364
+ +3000% lift among sub-1% metrics) otherwise stretches the shared domain
365
+ and flattens every other bar:
366
+
367
+ ```python
368
+ lifts = pl.DataFrame(
369
+ {
370
+ "metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
371
+ "rel": [0.03, -0.05, 0.08, 0.02, 30.0],
372
+ "rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
373
+ "rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
374
+ }
375
+ )
376
+
377
+ (
378
+ ct.CoefTable(lifts, rows="metric")
379
+ .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
380
+ # "robust" fits an IQR/Tukey fence over the pooled values instead of
381
+ # the raw min/max, so the outlier stops dictating the axis. Composes
382
+ # with symmetric= (fence first, then mirror around ref); ylim= still
383
+ # overrides everything.
384
+ .forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
385
+ )
386
+ ```
387
+
388
+ A discounted interval is never hidden: bars in a clipping domain plot
389
+ against a slightly inset region and the clipped interval continues into
390
+ the reserved margin as a gradient fade, reading as "extends beyond the
391
+ axis". Buckets with nothing clipped render exactly as before.
392
+
363
393
  **No reference, or a hidden one.** `ref` also drives the dashed line and
364
394
  colour resolution; two ways to opt out, for data with no meaningful
365
395
  zero (revenue, durations, absolute counts):
@@ -386,6 +416,176 @@ absolute = pl.DataFrame(
386
416
  )
387
417
  ```
388
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
+
389
589
  ## Plot annotations
390
590
 
391
591
  `ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
@@ -72,14 +72,26 @@ src = ["src", "tests"]
72
72
  docstring-code-format = true
73
73
 
74
74
  [tool.ruff.lint]
75
- select = ["B", "D", "E", "F", "I", "RUF", "S", "UP", "W"]
75
+ select = ["B", "C90", "D", "E", "F", "I", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "RUF", "S", "UP", "W"]
76
76
  ignore = ["RUF001", "RUF002", "RUF012"]
77
77
 
78
+ [tool.ruff.lint.mccabe]
79
+ # Anti-spaghetti gate: flag functions with cyclomatic complexity >= 25.
80
+ max-complexity = 24
81
+
82
+ [tool.ruff.lint.pylint]
83
+ max-branches = 24
84
+ max-statements = 100
85
+ max-returns = 12
86
+ # Wide keyword-only APIs (plot primitives, table builders) are deliberate;
87
+ # only flag genuine monsters.
88
+ max-args = 24
89
+
78
90
  [tool.ruff.lint.pydocstyle]
79
91
  convention = "numpy"
80
92
 
81
93
  [tool.ruff.lint.per-file-ignores]
82
- "tests/*" = ["D", "S101"]
94
+ "tests/*" = ["D", "S101", "PLR0915"]
83
95
 
84
96
  [tool.pytest.ini_options]
85
97
  testpaths = ["tests"]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.6.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,67 @@
1
+ """Experimental card layer. Not part of the public coeftable API yet.
2
+
3
+ The adornment vocabulary is the closed content contract card regions
4
+ resolve into; `render_adornment` is its only serializer.
5
+ """
6
+
7
+ from coeftable.cards.adornments import (
8
+ Adornment,
9
+ Badge,
10
+ CaptionRow,
11
+ InlineSvg,
12
+ KeyValuePopover,
13
+ Legend,
14
+ MetricValue,
15
+ RuleStrip,
16
+ SelectControl,
17
+ TextBlock,
18
+ )
19
+ from coeftable.cards.card import Card, CardGrid
20
+ from coeftable.cards.chrome import DEFAULT_CHROME, CardChrome
21
+ from coeftable.cards.fragments import render_adornment
22
+ from coeftable.cards.measure import Anchor, MeasuredCard
23
+ from coeftable.cards.panel import MeasuredPanel, Pane, Panel, Row
24
+ from coeftable.cards.regions import (
25
+ Diagnostics,
26
+ Event,
27
+ Events,
28
+ Interval,
29
+ Metric,
30
+ Region,
31
+ Trend,
32
+ resolve_content,
33
+ )
34
+ from coeftable.cards.template import CardTemplate
35
+
36
+ __all__ = [
37
+ "DEFAULT_CHROME",
38
+ "Adornment",
39
+ "Anchor",
40
+ "Badge",
41
+ "CaptionRow",
42
+ "Card",
43
+ "CardChrome",
44
+ "CardGrid",
45
+ "CardTemplate",
46
+ "Diagnostics",
47
+ "Event",
48
+ "Events",
49
+ "InlineSvg",
50
+ "Interval",
51
+ "KeyValuePopover",
52
+ "Legend",
53
+ "MeasuredCard",
54
+ "MeasuredPanel",
55
+ "Metric",
56
+ "MetricValue",
57
+ "Pane",
58
+ "Panel",
59
+ "Region",
60
+ "Row",
61
+ "RuleStrip",
62
+ "SelectControl",
63
+ "TextBlock",
64
+ "Trend",
65
+ "render_adornment",
66
+ "resolve_content",
67
+ ]