Skip to content

viz smart: bridge declared pipelines whose stages do not nest (#4222) - #4282

Merged
jqnatividad merged 7 commits into
masterfrom
viz-cpdb-review-fixes
Jul 26, 2026
Merged

viz smart: bridge declared pipelines whose stages do not nest (#4222)#4282
jqnatividad merged 7 commits into
masterfrom
viz-cpdb-review-fixes

Conversation

@jqnatividad

Copy link
Copy Markdown
Collaborator

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 totalplannedcommit is 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_totals draws a funnel only while the declared totals never grow.
  • Otherwise the panel bridges the signed differences between consecutive totals — same declared order, same numbers, but the form asserts arithmetic rather than containment.

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, stepping 192G → −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 Waterfall trace added in dathere/plotly#432; Cargo.lock moves to that rev (the branch = 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

  • The completeness clause asserted the opposite of its purpose. {:.0} rendered 12,566-of-12,587 rows as 100% of rows, while $119,962,000 quietly went missing from the Planned bar. It now floors to one decimal, so only genuine completeness prints 100.
  • One chart, two suffixes for 10⁹. Value axes carried plotly's untouched exponentformat: "B" default while qsv's own labels use d3 SI prefixes (%{y:.3s} on bars, .3~s on KPI indicators) — so a gridline read 35B and the bar against it 33.4G. The axes now declare SI, matching the convention qsv had already chosen rather than overturning it.
  • A 416px margin that was masking a bug. The funnel panel borrowed the lollipop's budget for 56-char column-pair labels to hold ticks reading 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 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.

Keeping the docs and the gallery honest

  • USAGE and the generated docs/help/viz.md said 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.
  • Routing CPDB to a bridge left PanelKind::Funnel with 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.py now 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 smart reuses an existing stats cache rather than recomputing, so a cache left by unrelated --smarter work was silently inherited by a figure whose command never asked for it — which is how a committed dashboard ended up captioned signups (right-skewed, 3.3% outliers) beside a command that produces no such note.

Verification

259 viz unit + 354 test_viz integration 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

jqnatividad and others added 7 commits July 26, 2026 22:43
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>
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@jqnatividad
jqnatividad merged commit 49c3dd6 into master Jul 26, 2026
18 checks passed
@jqnatividad
jqnatividad deleted the viz-cpdb-review-fixes branch July 26, 2026 22:52
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant