viz smart: bridge declared pipelines whose stages do not nest (#4222) - #4282
Merged
Conversation
Three defects found reviewing the NYC Capital Projects gallery dashboard.
The funnel's completeness clause exists to warn that its totals will NOT
reconcile with `stats.sum`, but `{:.0}` rendered 12,566-of-12,587 rows as
"100% of rows" -- asserting the opposite while $120M went missing from the
Planned bar. `complete_pct_str` now FLOORS to one decimal, so only genuine
completeness prints "100"; the panel reads "99.8% of rows".
Value axes carried plotly's untouched `exponentformat` default, "B", while
qsv's own labels use d3 SI prefixes (`%{y:.3s}` on bars, `.3~s` on the KPI
indicators). One chart therefore labelled its gridline "35B" and the bar
against it "33.4G" -- two suffixes for the same magnitude. The axes now
declare SI, matching the labels qsv already chose rather than overturning
them.
The funnel panel reserved a 416px left margin -- the lollipop's budget for
56-char truncated column-PAIR labels -- for ticks reading "Planned" and
"Committed". Sizing it to the actual stage labels (87px) then exposed why
that number had been survivable: plotly centres funnel bands on the value
ZERO, so a `0..=ceil` range puts every band's centre on the left edge of
the plot area and draws half of it outside. The oversized margin had been
absorbing the overflow. `funnel_value_axis` is now symmetric about zero,
so the widest band still occupies 1/1.35 of the plot area but centred
rather than half off-screen; its tick labels are hidden, since a symmetric
range would read as negative amounts for values that are never negative
and the standalone `viz funnel` draws no value axis either.
Verified in-browser: bands complete and unclipped at margin 87 (they were
clipped at x=-291 with the margin fix alone), axis and labels both reading
G. 3 new unit tests; gallery regenerated -- funnel-free dashboards change
by `exponentformat` alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A funnel is a containment form: its band widths ARE the claim that each stage is a subset of the one above. The NYC Capital Projects dashboard drew one for three columns that grow -- Spent is 2.9x Committed -- so the third band rendered WIDER than the second and plotly printed "285%" on it. An hourglass asserting the opposite of the numbers, which the subtitle could not retract, because geometry is read before caption. The dictionary declaration was never the problem. It says these columns are one ordered pipeline, which no heuristic can infer and which the tool should keep believing. What it cannot say is that the stages NEST -- that is a measurable property -- and until now the declaration decided both whether to draw and what to draw. Those are separated: the declaration still selects the panel and fixes the order, while the measured totals select the FORM. `PipelineForm::for_totals` funnels only while the declared totals are monotonically non-increasing; otherwise the panel bridges the signed differences between consecutive totals. Deliberately not the per-row containment share, which measures something else and stays in the subtitle: rows can violate while the totals still shrink (a funnel remains truthful), or nest perfectly while the totals grow (it does not). The bridge is a waterfall, drawn VERTICALLY. A waterfall is an ordinary bar-like trace, so category index 0 lands at the axis bottom rather than the top, and the arrays cannot be reversed to compensate because it accumulates in array order and index 0 must seed the running total. Vertical sidesteps that and is the conventional orientation anyway. Each step is labelled as the arithmetic difference it is -- "Committed - Planned" -- never as a flow, since no flow between independent aggregates is being claimed. Per-bar text templates, because a step bar's number is its delta and a stage bar's is the running total, and neither variable is right for both. Two integration fixtures changed form incidentally rather than by intent -- they grew while testing the row encoding and case-insensitive stage matching -- so their values now nest and they keep testing what they were written for. The two that are genuinely about non-nesting assert the bridge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The feature list and the --dictionary help both said a declared pipeline becomes a funnel, and that "a funnel is drawn ONLY from an explicit declaration". Half of that is still true -- the declaration is the only source of the panel -- but the form is no longer implied by it. Both now say what the split actually is: the declaration fixes WHICH columns and in WHAT order, and the measured totals pick the form, with a funnel only while they never grow. Also separates the two containment statements that now mean different things, since row-wise containment is disclosed in the subtitle while the totals decide the form -- rows can overrun while the totals shrink, or nest while the totals grow. docs/help/viz.md regenerated with `qsv --generate-help-md`, not edited. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routing CPDB to a bridge left `PanelKind::Funnel` with no gallery figure at all -- reopening open item #1 from the funnel work. The standalone `viz funnel` figure does not cover it: that goes through `build_funnel_chart`, which supplies no axes and never touches the smart panel's zero-anchored value axis or its margin. That gap matters more than the usual "one less example". Both funnel rendering bugs found so far -- bands centred on the value zero and clipped, and index 0 drawn at the top rather than the bottom -- were invisible in the serialized JSON and only turned up when something was looked at in a browser. Without a rendered figure there is nothing standing to catch the next one. `onboarding_funnel.csv` is 60 channel-weeks of visits -> signups -> activated -> subscribed, generated with a fixed seed so the gallery stays byte-stable, and asserted to nest in every row at generation time. Stage rates are high enough that the taper stays legible: a real ad funnel's first drop is ~30x, which collapses every later stage into a sliver and demonstrates nothing. It renders through the TYPED SUBPLOT GRID (a single `qsv-viz-smart-grid` plot), which is the funnel path the inline renderer does not exercise. Placed immediately before the CPDB figure so the two read as one argument: the same kind of declaration, drawn as a funnel when the totals nest and as a bridge when they do not. README: adds both pipeline datasets and both dictionaries to their tables -- CPDB was missing too -- and corrects "fourteen smart dashboards" to eighteen, counted by listing `smart_*.html`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard count I "corrected" last commit was wrong in a new direction: the sentence is about dashboards EMBEDDED as iframes, and SMART_IFRAME has 16 -- the two extra smart_*.html files are screenshot link-outs, described by the very next paragraph. Counting files on disk answered a different question than the prose was asking. That is the third time this prose has drifted, so the fix is a check rather than another number. `check_readme_claims` fails closed on what this script can actually prove: the embedded-dashboard count, the screenshot link-out count, and whether every dataset, dictionary and geojson a figure feeds qsv is mentioned in README.md. It runs AFTER the gallery is written and exits 1, so adding a figure still regenerates -- the exit code is what tells you the docs need a follow-up rather than blocking the artifacts. Command text is deliberately NOT compared. README.md is a tutorial whose invocations differ from the gallery's argv on purpose (it shows `-o dashboard.html`, the gallery pins `--max-charts 8`), so requiring a verbatim mirror would force the README into a command dump. The comment claiming the commands ARE mirrored is what made that look like drift; it now says what is actually guaranteed. The check immediately found signup_funnel.csv fed to the standalone funnel figure and documented nowhere, so the Datasets table gains it. Also replaces smart_onboarding_funnel.html: the committed copy carried a "3.3% outliers" annotation that only appears under --smarter, picked up from a moarstats cache left behind by unrelated runs. The command the gallery documents does not produce it, so the artifact did not match its own caption. Regenerated clean-room. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orev 3859) Two gaps in the drift check added last commit. `check_readme_claims` collected inputs from FIGURES only, but the NYC 311 screenshot link-out runs qsv through a `SCREENSHOTS` entry with its own `args` -- so `nyc_311.csv`, `nyc311_dict.schema.json` and `nyc_neighborhoods.geojson` could vanish from the README with the check still passing. The input set now includes every screenshot entry carrying `args`. The two `cmd`-only entries stay excluded on purpose: those are display strings for datasets too large to commit, and are never executed. `cleanup_sidecars()` ran only after generation. That is a reproducibility bug, not untidiness: `viz smart` REUSES an existing stats cache rather than recomputing one, so a cache left behind by unrelated manual work is silently inherited by a figure whose own command never asked for it. A `--smarter` cache in particular carries moarstats enrichment that adds outlier annotations to panel titles -- which is exactly how a committed dashboard ended up captioned "signups (right-skewed, 3.3% outliers)" beside a command that produces no such note. It now runs at both ends, and reports what it swept, since a dirty tree at startup is itself worth knowing about. Verified against that failure rather than in the abstract: planting a `--smarter` cache for onboarding_funnel.csv and regenerating sweeps 4 sidecars and yields the clean title. Removing a screenshot-only input from the README now exits 1 where it previously passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oborev 3856) The bridge arm was half shape-aware, which is worse than not at all: its ROW-COUNT line matched on `FunnelShape` while the value line above it hardcoded "Amount:". So a row-encoded measure pipeline silently dropped the value-column label the dictionary declared -- showing "Amount: 360" where the funnel says "revenue: 360" -- and a count-only pipeline labelled a row count as an Amount and then restated the same number as "Rows in stage: 100". Looking deliberate, it gave the next reader no signal it was wrong. Both hardcoded `format!`s are replaced by one `stage_hover` closure carrying the funnel arm's three-way match. `RowsCount` folds the value and the count into a single "Rows: n of m" line, as the funnel does, because in that encoding they ARE the same number. Reachable, not theoretical: the row-measure bridge test added two commits ago declares `value_column: revenue`, so it was rendering the wrong label already -- it just never asserted on hover text. It now does, and a count-only bridge test joins it; that shape had no bridge coverage at all. Verified by rendering all three encodings, including that the Columns case (CPDB) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to standards ✅🟢 Issues
|
jqnatividad
added a commit
that referenced
this pull request
Jul 27, 2026
qsv --update-mcp-skills picks up the new flag in qsv-viz.json, plus previously-unregenerated accurate drift from the funnel/pipeline work (#4281/#4282): the funnel subcommand in the viz enum and the pipeline relationship docs in the viz + describegpt skills. Skills CHANGELOG Unreleased note added; skill count unchanged at 55. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jqnatividad
added a commit
that referenced
this pull request
Jul 27, 2026
…4283) (#4284) * viz smart: DataTables data viewer drawer with Explore/Preview link (#4283) Add an interactive data viewer to `viz smart` HTML dashboards: next to the rowcount in the metadata frontmatter, an "(Explore)" link (all rows embedded) or "(Preview)" link (first N rows) opens the underlying table in a bottom drawer, mirroring the Data Dictionary drawer mechanics. - New `--preview-threshold <n>` flag (default 50,000; 0 disables): at or under the threshold every row embeds and the link reads "(Explore)"; above it only the first <n> rows embed and the link reads "(Preview)". - Vendored DataTables 3.0.0 + DateTime 2.0.0 + SearchBuilder 2.0.0 (vanilla, no jQuery; MIT) in src/cmd/assets/, embedded gzip+base64 by default and inflated lazily on first drawer open (zero page-load cost); plain inline under QSV_VIZ_NO_COMPRESS; version-pinned cdn.datatables.net tags with Subresource Integrity under QSV_VIZ_CDN (unit test recomputes sha384 of the vendored files against the pinned hashes, like plotly's). - DataTable has global search, a per-column filter input header row, and a SearchBuilder whose per-column conditions derive from the stats cache: Integer/Float -> num, Date/DateTime -> date, else string - so numeric columns get numeric conditions, dates get Before/After/Between, and low-cardinality categoricals get auto-populated value dropdowns without client-side type sniffing on a partial preview. - Rows embed as a JSON array-of-arrays payload (gzip+base64 when >= 4 KiB and compression is on, else plain with &/</> \u-escaped so no cell can smuggle a </script>); cells render via DataTable.render.text() so cell values are data, not markup. - Bottom drawer coexists with the dictionary side drawer (different axes, z-index 1090 vs 1100), pushes the fixed logo/theme widgets up, closes on Esc, and dispatches a resize so plotly re-fits. DataTables' native dark palette follows the qsv theme toggle by mirroring body.qsv-dark onto html.dark. - HTML-only: image exports never pay the extra data pass. - gen_gallery.py caps gallery smart runs at --preview-threshold 500 (its payloads are plain under QSV_VIZ_NO_COMPRESS; the 50k default would have added ~6MB to smart_nyc311.html alone - now +317KB, ~757KB across the 13 regenerated pages). - 2 unit + 9 integration tests; 3 existing panel-scoped tests now pin --preview-threshold 0 (the viewer legitimately embeds all raw columns, including panel-excluded ones), and the metadata test tracks the new Rows cell markup. Full viz suite: 363 passed. Closes #4283 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz: regenerate MCP skill JSONs for --preview-threshold (#4283) qsv --update-mcp-skills picks up the new flag in qsv-viz.json, plus previously-unregenerated accurate drift from the funnel/pipeline work (#4281/#4282): the funnel subcommand in the viz enum and the pipeline relationship docs in the viz + describegpt skills. Skills CHANGELOG Unreleased note added; skill count unchanged at 55. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: data viewer drawer UX refinements (#4283) Six UX tweaks to the DataTables data viewer drawer: - Stacking: the data drawer now paints ABOVE the dictionary drawer when both are open (z-index 1120 vs 1100). - SearchBuilder rides in a Buttons popover: a "Filter (n)" button on the same controls row as the page-length selector and the global search box (language.searchBuilder.button + layout.topStart), replacing the permanent top pane and its whitespace. Esc with the popover open closes only the popover; a bare Esc closes the drawer. - Focus management: opening the drawer moves focus into it (tabindex="-1"); closing returns focus to the Explore/Preview link in the metadata table at the top of the dashboard. - Responsive extension replaces scrollX: columns that do not fit collapse into an expandable child-row control per row (nyc311's 41 columns show 7 + expander); the per-column filter input row tracks Responsive's column visibility via the responsive-resize event. - Resizable drawer: a grip bar on the top edge drags the height between 20vh and 90vh via the --qsv-data-h CSS var, so the body margin and the fixed logo/theme widgets follow; releasing re-fits plotly and re-balances the table columns. - Fixed header/footer: only the table's own layout row scrolls (flex column + overflow:auto) with a sticky two-row thead (title row at top:0, filter row offset by the measured title-row height), keeping the controls row above and the info/paging bar below always visible. The FixedHeader extension was deliberately NOT used - it tracks window scroll only and is inert inside the fixed-position drawer; native sticky delivers the same UX and stays correct under drawer resize. Vendored bundle regenerated with Buttons 4.0.0 + Responsive 4.0.0 added (combo dt-3.0.0/b-4.0.0/date-2.0.0/r-4.0.0/sb-2.0.0; new SRI hashes; unit-test code map extended). Gallery regenerated. Full viz suite: 363 passed. Browser-verified: popover + condition count, responsive collapse + filter-row sync, sticky scrolling, grip resize, focus round-trip, both drawers stacked, light/dark, console clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: re-clamp the persisted data drawer height against the viewport (#4283) roborev #3864 (Low): the grip persists --qsv-data-h as absolute px, so a later window resize or device rotation could leave the drawer outside the intended 20vh-90vh bounds. All consumers (drawer height, body margin, fixed logo/theme offsets) now read one body-scoped `--qsv-data-h-eff: clamp(20vh, var(--qsv-data-h, min(55vh, 560px)), 90vh)` so the effective height tracks the CURRENT viewport and the offsets can never disagree. Browser-verified both bounds; gallery regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * typos: add *.js and *.ts to whitelist * viz smart: reveal the data drawer in embedded (gallery iframe) pages (#4283) In the gallery, smart dashboards are embedded in auto-sized iframes that never scroll themselves (the parent page does), so position:fixed pins the data drawer to the bottom of the FULL iframe height - often far below the visible viewport. Clicking Explore/Preview appeared to do nothing, and scrollIntoView cannot fix it: it is a no-op on fixed-position elements. New revealBottom()/revealTop() helpers scroll the PARENT window directly via window.frameElement geometry: opening aligns the iframe's bottom edge (where the drawer lives) with the parent viewport (re-aligned once more after the open transition + the gallery's iframe auto-grow settle); closing scrolls back to this dashboard's top where focus lands on the Explore/Preview link. Same-origin only - frameElement access throws in cross-origin/file:// embeddings and we quietly skip, which is the prior behavior. Standalone pages never scroll (self === top guard). Browser-verified in the served gallery: open scrolls the drawer fully into view, close returns to the dashboard top; standalone pages unchanged. Gallery regenerated; full viz suite 363 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: data drawer reveal rides the gallery's postMessage channel (#4283) The frameElement-based reveal only worked for same-origin embeddings; a file://-opened gallery has opaque frames where every direct parent-window access throws, so clicking Explore/Preview still appeared to do nothing — the same constraint that made the gallery's iframe auto-sizing use postMessage in the first place. The reveal now posts {qsvVizReveal:"bottom"|"top"} to the parent (works cross-origin), with the direct frameElement scroll kept as a fallback for same-origin embedders without the listener. Gallery side: the resize listener honors the reveal messages (matching the sender iframe by e.source, like height reports); the jump stabilizer treats a reveal as reader takeover so it can no longer snap the view back and undo the reveal after a TOC jump; and pointerdown joins the takeover-forwarding events, since a click inside a dashboard is as much reader engagement as a scroll. Browser-verified end-to-end in the served gallery, including the adversarial flow (hash-jump arms the stabilizer, then a real click on Explore): open reveals the drawer, close returns to the dashboard top. Message path exercised in isolation (posted from the iframe context). Gallery regenerated; full viz suite 363 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: skip the drawer's delayed re-align after a quick close (#4283) roborev #3868 (Medium): the 450ms settle re-align in show() fired even if the drawer was closed within the window — over the postMessage path this scrolled the parent back to the iframe bottom right after qsvCloseData() had revealed the dashboard top. The delayed callback now re-checks that the drawer still carries the `open` class first. Gallery regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz gallery: iframes shrink back when the data drawer closes (#4283) The gallery's height reporter posted documentElement.scrollHeight, which is max(content, viewport) — once the drawer's reserved body margin had grown an iframe, scrollHeight could never report smaller than the iframe's own height, so closing the drawer left the reserved space as permanent whitespace (a one-way ratchet). The reporter now posts a content-derived height (body border-box + body vertical margins, which is exactly what the drawer's reserved space rides on; fixed-position chrome deliberately excluded), so the iframe grows on open AND shrinks back on close, and the report can never echo the viewport back into itself. Also: the drawer's post-open re-align is now a short series (450ms / 1.2s / 2.2s, still gated on the drawer being open) — on a still-settling gallery, other iframes growing above the target cancel an in-flight smooth scroll (the same churn the jump stabilizer fights), and a single re-align could land before the churn ended, leaving the drawer opened but out of view. Once the drawer region is visible the parent's reveal handler is a no-op, so the series converges and never fights the reader. Browser-verified on a fresh gallery load: click Explore -> iframe grows 2931->3663 and the drawer lands exactly at the viewport bottom; close -> iframe shrinks back to 2931 with the dashboard top in view. Full viz suite 363 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: reader input cancels drawer re-aligns; reveals scroll instant (#4283) roborev #3870 (Medium): the 450ms/1.2s/2.2s reveal re-align series only checked that the drawer was still open, so a reader who scrolled back up to inspect the dashboard (drawer left open) was yanked back down by a pending re-align. A revealSeq counter now invalidates the series on any reader input (wheel/touchstart/keydown/pointerdown, capture+passive); each delayed callback requires the drawer open AND the sequence unchanged. The arming click's own pointerdown fires before show() captures its sequence number, so it never cancels the series it starts. Verifying this surfaced the root cause of the reveal flakiness seen across sessions: the gallery page sets scroll-behavior:smooth, and on an iframe-heavy load a smooth scrollTo animation can be canceled at its first frame by concurrent layout work and never arrive — programmatic scrolls appeared to no-op while user scrolling worked. The jump stabilizer's snap() documents the identical lesson ("must be instant"). All reveal scrolls (viz.rs same-origin fallbacks + the gallery reveal listener) now use behavior:"instant"; "auto" would inherit the page's smooth CSS. Browser-verified: fresh-load click reveals deterministically in <800ms; wheel-up during the reveal window is respected (no yank-back for 3s+); close still shrinks the iframe and returns to the dashboard top. Full viz suite 363 passed; gallery regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-on to #4281, from a design review of the NYC Capital Projects dashboard in the gallery.
The finding
That dashboard's headline panel drew a funnel that widens at the bottom. Spent totals 2.9× Committed, so plotly printed
285%on the last band and drew it wider than the one above it.A funnel is a containment form — its band widths are the claim that each stage is a subset of the one before. Drawing one over stages that grow asserts the opposite of the data, and a subtitle cannot retract it, because the geometry is read before the caption is.
The dashboard already contained the refutation: its own Spearman panel reports ρ(Planned, Spent) = −0.11 and ρ(Planned, Committed) = 0.22. At the project level these three columns are near-unrelated — exactly what the source semantics say, since
totalplannedcommitis allocated in the Capital Commitment Plan while the other two are sums within the City's budget. The hover row counts are non-monotonic too (73% → 40% → 55%). Neither dollars nor rows nest.The fix: the declaration picks the panel, the numbers pick the form
The dictionary declaration was never the problem. It says these columns are one ordered pipeline — which no heuristic can infer, and which the tool should keep believing. What it cannot say is that the stages nest; that is measurable. Until now the declaration decided both whether to draw and what to draw. Those are separated:
PipelineForm::for_totalsdraws a funnel only while the declared totals never grow.Deliberately not the per-row containment share, which measures something else and stays a subtitle disclosure: rows can overrun while the totals still shrink (a funnel remains truthful), or nest perfectly while the totals grow (it does not).
CPDB now reads
Pipeline bridge: … · stages do not nest — bridged, not funnelled, stepping192G → −163G → 28.4G → +52.6G → 81.0G. Each step is labelled as the arithmetic difference it is (Committed − Planned), never as a flow, since no flow between independent aggregates is claimed.The bridge is a Waterfall trace, drawn vertically: a waterfall is an ordinary bar-like trace, so category index 0 lands at the axis bottom rather than the top, and the arrays cannot be reversed to compensate because it accumulates in array order and index 0 must seed the running total. Requires the
Waterfalltrace added in dathere/plotly#432;Cargo.lockmoves to that rev (thebranch =pin is unchanged).The rule found a second real case beyond CPDB: a row-encoded marketing pipeline summing revenue grows down its stages even though the row counts shrink — revenue at conversion is not a subset of revenue at impression — so that bridges too.
Three labelling defects from the same review
{:.0}rendered 12,566-of-12,587 rows as100% of rows, while $119,962,000 quietly went missing from the Planned bar. It now floors to one decimal, so only genuine completeness prints100.exponentformat: "B"default while qsv's own labels use d3 SI prefixes (%{y:.3s}on bars,.3~son KPI indicators) — so a gridline read35Band the bar against it33.4G. The axes now declare SI, matching the convention qsv had already chosen rather than overturning it.Planned. Sizing it to the real labels (87px) then exposed why that number had been survivable: plotly centres funnel bands on the value zero, so a0..=ceilrange puts every band's centre on the left edge of the plot area and draws half of it outside. The oversized margin had been absorbing the overflow.funnel_value_axisis now symmetric about zero.Keeping the docs and the gallery honest
docs/help/viz.mdsaid a declared pipeline becomes a funnel. Half of that is still true — the declaration is the only source of the panel — but the form no longer follows from it.PanelKind::Funnelwith no gallery figure at all. That matters more than one less example: both funnel rendering bugs found so far were invisible in the serialized JSON and only surfaced in a browser.onboarding_funnel.csv(60 channel-weeks, fixed seed, nesting asserted at generation) restores that coverage, and renders through the typed subplot grid, which the inline renderer never exercises.gen_gallery.pynow fails closed on README drift it can prove — the embedded-dashboard count, the screenshot link-out count, and whether every input fed to qsv is documented. It found an undocumented dataset immediately. Command text is deliberately not compared: the README is a tutorial whose invocations differ from the gallery's argv on purpose.cleanup_sidecars()now also runs before generation.viz smartreuses an existing stats cache rather than recomputing, so a cache left by unrelated--smarterwork was silently inherited by a figure whose command never asked for it — which is how a committed dashboard ended up captionedsignups (right-skewed, 3.3% outliers)beside a command that produces no such note.Verification
259 viz unit + 354
test_vizintegration tests pass (10 new/strengthened). Clippy clean apart from two pre-existing warnings. Verified in-browser at each step — the bridge, the restored funnel, and the axis/label changes — plus a static PNG export, which is what exercises the typed-grid arm, since the grid paths serve image export while HTML renders inline. Gallery regenerated with timestamp-only churn reverted, so the only artifacts that change are the ones whose figures actually did.Four roborev rounds, each finding something smaller than the last, ending clean.
🤖 Generated with Claude Code