@wafertools/wafermap 0.26.1 → 0.27.0
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.
- package/CHANGELOG.md +360 -0
- package/README.md +46 -3
- package/dist/packages/canvas-adapter/canvasTheme.d.ts +4 -0
- package/dist/packages/canvas-adapter/canvasTheme.js +1 -1
- package/dist/packages/canvas-adapter/charts/barPanel.d.ts +16 -0
- package/dist/packages/canvas-adapter/charts/barPanel.js +1 -1
- package/dist/packages/canvas-adapter/charts/binCluster.js +1 -1
- package/dist/packages/canvas-adapter/charts/boxplot.d.ts +13 -1
- package/dist/packages/canvas-adapter/charts/boxplot.js +1 -1
- package/dist/packages/canvas-adapter/charts/capability.d.ts +10 -10
- package/dist/packages/canvas-adapter/charts/capability.js +1 -1
- package/dist/packages/canvas-adapter/charts/chartShell.d.ts +337 -3
- package/dist/packages/canvas-adapter/charts/chartShell.js +1 -1
- package/dist/packages/canvas-adapter/charts/correlation.d.ts +8 -9
- package/dist/packages/canvas-adapter/charts/correlation.js +2 -1
- package/dist/packages/canvas-adapter/charts/groupedBarPlot.d.ts +50 -0
- package/dist/packages/canvas-adapter/charts/groupedBarPlot.js +1 -0
- package/dist/packages/canvas-adapter/charts/histogram.d.ts +12 -1
- package/dist/packages/canvas-adapter/charts/histogram.js +1 -1
- package/dist/packages/canvas-adapter/charts/regionYieldDiagram.js +1 -1
- package/dist/packages/canvas-adapter/charts/scatter.js +1 -1
- package/dist/packages/canvas-adapter/charts/testPassRate.d.ts +20 -0
- package/dist/packages/canvas-adapter/charts/testPassRate.js +1 -0
- package/dist/packages/canvas-adapter/charts/trend.d.ts +30 -0
- package/dist/packages/canvas-adapter/charts/trend.js +1 -0
- package/dist/packages/canvas-adapter/dieList.js +7 -7
- package/dist/packages/canvas-adapter/identityHeader.d.ts +46 -0
- package/dist/packages/canvas-adapter/identityHeader.js +1 -0
- package/dist/packages/canvas-adapter/index.d.ts +1 -1
- package/dist/packages/canvas-adapter/insightsTab.d.ts +27 -3
- package/dist/packages/canvas-adapter/insightsTab.js +1 -1
- package/dist/packages/canvas-adapter/maplessSummary.js +1 -1
- package/dist/packages/canvas-adapter/renderWaferGallery.d.ts +15 -2
- package/dist/packages/canvas-adapter/renderWaferGallery.js +1 -20
- package/dist/packages/canvas-adapter/renderWaferMap.d.ts +72 -17
- package/dist/packages/canvas-adapter/renderWaferMap.js +1 -1
- package/dist/packages/canvas-adapter/summaryPanel.d.ts +219 -33
- package/dist/packages/canvas-adapter/summaryPanel.js +3 -3
- package/dist/packages/canvas-adapter/toCanvas.d.ts +6 -0
- package/dist/packages/canvas-adapter/toCanvas.js +1 -1
- package/dist/packages/canvas-adapter/toolbar.d.ts +310 -6
- package/dist/packages/canvas-adapter/toolbar.js +2 -2
- package/dist/packages/canvas-adapter/userGuideHtml.d.ts +1 -1
- package/dist/packages/canvas-adapter/userGuideHtml.js +97 -34
- package/dist/packages/canvas-adapter/version.d.ts +2 -2
- package/dist/packages/canvas-adapter/version.js +1 -1
- package/dist/packages/canvas-adapter/warnings.d.ts +0 -4
- package/dist/packages/canvas-adapter/warnings.js +1 -1
- package/dist/packages/core/utils.d.ts +11 -0
- package/dist/packages/core/utils.js +1 -1
- package/dist/packages/renderer/buildView.d.ts +34 -1
- package/dist/packages/renderer/buildView.js +1 -1
- package/dist/packages/renderer/buildWaferMap.d.ts +104 -5
- package/dist/packages/renderer/buildWaferMap.js +1 -1
- package/dist/packages/renderer/colorSchemes.js +1 -1
- package/dist/packages/stats/analyzeWaferMap.js +1 -1
- package/dist/packages/stats/binPareto.d.ts +15 -0
- package/dist/packages/stats/binPareto.js +1 -1
- package/dist/packages/stats/clusterDetection.js +1 -1
- package/dist/packages/stats/connectedComponents.d.ts +14 -0
- package/dist/packages/stats/connectedComponents.js +1 -0
- package/dist/packages/stats/correlation.d.ts +26 -0
- package/dist/packages/stats/correlation.js +1 -1
- package/dist/packages/stats/facets.js +1 -1
- package/dist/packages/stats/filterFindings.d.ts +18 -0
- package/dist/packages/stats/filterFindings.js +1 -1
- package/dist/packages/stats/findingsNarrative.js +1 -1
- package/dist/packages/stats/histogram.d.ts +19 -1
- package/dist/packages/stats/histogram.js +1 -1
- package/dist/packages/stats/index.d.ts +6 -0
- package/dist/packages/stats/index.js +1 -1
- package/dist/packages/stats/mergeTestDefs.d.ts +44 -0
- package/dist/packages/stats/mergeTestDefs.js +1 -0
- package/dist/packages/stats/metadataColumns.d.ts +20 -4
- package/dist/packages/stats/metadataColumns.js +1 -1
- package/dist/packages/stats/patternClassification.js +1 -1
- package/dist/packages/stats/renderFindingsReport.js +14 -14
- package/dist/packages/stats/renderSummaryReport.js +32 -29
- package/dist/packages/stats/reportHtml.js +38 -13
- package/dist/packages/stats/testPassRate.d.ts +85 -0
- package/dist/packages/stats/testPassRate.js +1 -0
- package/dist/packages/stats/trend.d.ts +40 -0
- package/dist/packages/stats/trend.js +1 -0
- package/dist/packages/stats/types.d.ts +117 -6
- package/llms.txt +6 -0
- package/package.json +7 -5
- package/dist/packages/canvas-adapter/metadataBadge.d.ts +0 -30
- package/dist/packages/canvas-adapter/metadataBadge.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -22,6 +22,366 @@ under `### Breaking`.
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
## [0.27.0] — 2026-09-09
|
|
26
|
+
|
|
27
|
+
### Breaking
|
|
28
|
+
|
|
29
|
+
- **The `inferred-pitch` advisory is removed, and `non-standard-diameter` replaces it.** They cover the two halves of the same risk, and the old one was on the wrong half. A supplied diameter with an inferred pitch raised a warning on *every* build — but that pitch is derived as `diameter ÷ grid span`, which puts the outermost die at ~95% of the radius by construction, so the result is self-consistent and there is nothing to check it against. At full coverage the derived pitch is within ~1%, so the advisory fired mostly on inferences that were fine. Meanwhile the mirror case — a pitch supplied and the **diameter** inferred from the die extent — said nothing at all, despite producing a 210 mm wafer where the truth was 300 mm when the outer third went unprobed, which skews ring membership exactly as a wrong pitch does. Wafer diameters are standardised (SEMI M1: 100/150/200/300 mm and the smaller legacy sizes), so an inferred diameter *is* checkable: landing off that ladder is evidence the probed grid did not reach the wafer edge. Validated across 40 fixtures — 100/200/300 mm wafers, square and rectangular die, 50–94% coverage — the rule produced **no false alarms** (it never flagged an inference that was right) and caught every material error except those landing on another standard size, e.g. a 200 mm wafer at 65% coverage inferring exactly 150 mm; those stay silent, which is no worse than before, when nothing was reported at all. Hosts branching on `w.code === 'inferred-pitch'` will no longer match; the code is gone rather than renamed, because the condition it described is no longer reported.
|
|
30
|
+
|
|
31
|
+
- **`AnalyzeWaferMapOptions.significanceLevel`, `.minimumEffectSize` and `.minimumRelativeEffect` are removed.** They are internal constants now, as `minimumSampleSize` always was. These decide what *counts* as a finding, so a wrong value did not make the output look different — it made it wrong, and silently: `significanceLevel: -0.2` returned **zero findings across the entire lot**, with no error and nothing in the result to say why, which reads as "nothing wrong with this wafer". None of the three were validated; `minimumEffectSize: -1` and a `significanceLevel` of 1.5 (not a probability) were accepted without complaint. The library already treated this as dangerous for itself — `adaptOptions()` declines to adapt `significanceLevel` internally because it perturbs the multiple-comparison correction unpredictably — while handing the same knob to callers unguarded. The values and the gates they drive stay documented in `docs/api.md` §7.3.2 and the guide, which is what callers actually needed: to understand why a pattern did or did not produce a finding. Passing them from untyped JavaScript no longer takes effect — the value is validated, ignored, and reported (see below). tsmap, the reference integrator, set none of them.
|
|
32
|
+
- **`RenderOptions` no longer extends `ToCanvasOptions`, and five of its inherited options are gone:** `topClearance`, `minRightReserve`, `markFailingDies`, `activeBin`, `hoverBin`. All five were **accepted and silently ignored** — `renderWaferMap` overrides them on every draw (`topClearance` is hardcoded to 0, `minRightReserve` is derived from the legend, and the other three are read from `viewOptions` and internal hover state). Verified by instrumenting the draw transform and every text/rect/fill operation, in both value and bin plot modes: setting any of the five changed nothing observable. An option that is typed, documented and ignored is worse than one that does not exist — it costs a caller a debugging session to discover the API lied. The ten that genuinely work (`padding`, `background`, `showColorbar`, `colorbarWidth`, `showAxes`, `showTitle`, `legendPosition`, `legendOffset`, `diePitchMm`, `fallbackFormat`, plus `metadataFields`) are now listed explicitly and stay at the top level, so this breaks only callers of options that never did anything. Listing them also stops future `ToCanvasOptions` additions arriving here by accident; `toCanvas` remains the full low-level surface. They were deliberately *not* moved under a nested `draw` key: grouping would read better but would break every caller of the options that do work, in exchange for tidiness alone.
|
|
33
|
+
- **`RenderOptions.showMetadataBadge` → `showIdentity`, and `WaferMapController.setMetadataBadgeVisible` → `setIdentityVisible`.** The bottom-left canvas overlay became a real layout row, so the old names described something that no longer exists. The intermediate `showIdentityHeader`/`setIdentityHeaderVisible` were dropped before release for naming the *container* rather than the content: the chrome row now exists whenever there is a toolbar, so the flag says whether the wafer's identity is shown in it — a content switch, never a layout one. That distinction is what removed the special case where turning the header off would have cost the toolbar its row. Hard renames with no alias: passing an old option is a type error, and at runtime it is ignored.
|
|
34
|
+
- **`HoverTextOptions.waferMeta` removed.** The tooltip no longer merges wafer-level metadata over per-die metadata — wafer identity (lot, wafer ID, product, program) is shown once in the identity header, and repeating it on every die tooltip duplicated it. Only `die.metadata` now appears there.
|
|
35
|
+
- **Removed the `--wmap-bar-fill-muted` theming token.** It existed solely to grey the fill of below-median wafer-yield bars, an encoding that has been removed (see below). Nothing paints it any more, and a documented theming token a host can set to no visible effect is worse than an absent one. `--wmap-bar-fill` is unchanged.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`diameter-exceeds-die-extent` — the missing half of the geometry check.** `geometry-conflict` asks whether the dies *fit* the supplied wafer. Nothing asked whether they *fill* it, so a diameter that was too large passed in complete silence — and it is not harmless: ring bands are equal-radius, so an over-large wafer crushes dies into the inner rings and empties the outer ones. Measured on a 300 mm / 10 mm map whose dies reach 141 mm: at a supplied 400 mm the outermost ring is already empty, at 600 mm two rings are, and at 3000 mm — a plausible typo for 300 — **all 621 dies land in ring 1** with three of four rings empty and no advisory of any kind. Ring, quadrant and edge findings then describe the assumed wafer rather than the probed area. Now raised as a `warning` (the dies are drawn correctly; it is the analysis that is distorted) below 75% fill. The threshold is calibrated, not guessed: every dataset in this repo's own docs sits at 96–100%, a full wafer with edge exclusion reaches ~94% and a reticle-complete map ~83%, while the first ring empties at ~71%. A genuinely partial map is indistinguishable from an over-large diameter — `partial-coverage` will not separate them either, since it keys on an off-centre centroid and a centred small disc is geometrically identical to a tiny full wafer — so the message names both causes rather than guessing. **Not** raised when `waferConfig.center` was supplied, since anchoring the centre is the documented way to position deliberately partial data and is the very remedy `partial-coverage` recommends; nor below 20 dies, where the extent is not evidence about the wafer and ring analysis (`ringCount` × `minimumSampleSize`) cannot report anything anyway.
|
|
40
|
+
- **`standardDiameters` — the standard wafer-size table is overridable.** `STANDARD_WAFER_DIAMETERS_MM` (exported) defaults to `[100, 125, 150, 200, 300]`, and `WaferMapInput.standardDiameters` **replaces** it — spread it to extend (`[...STANDARD_WAFER_DIAMETERS_MM, 76.2]` for a line running 3-inch), or pass `[]` to disable the check for genuinely non-standard substrates. The list size is a real trade-off, measured across 63 fixtures: every entry is a value a wrong inference can hide behind, every omission wrongly accuses someone running that size. `[200, 300]` catches the most (37 of 39 errors) but produces 12 false alarms; the inclusive default trades 6 catches for 9 fewer. The default is deliberately the safer end because it reaches the hosts who never set the option — a 150 mm line would otherwise see a permanent warning on correct data with no visible way to clear it — while a fab that knows it runs only 200/300 can say so and get strictly better detection.
|
|
41
|
+
- **A dedicated Insights example, and `InsightsOptions.defaultOpen`.** The Insights tab is the largest feature surface in the library — thirteen panels across three views (yield by wafer, hard-bin pareto, parametric pass rate, ring and quadrant yield and two summary tables; capability, box plots, histogram and wafer-to-wafer trend; correlation matrix and scatter) — and it had **no example page and not one entry in the examples index**. It appeared on 2 of 20 pages, always as a secondary tab on a page about something else. `docs/examples/insights.html` is now that page, listed at the head of "Analysis and layout" and in the docs nav. Its lot is chosen so every panel has something true to show rather than a picture of noise: eight wafers across two process corners (a ~50 mV Vth split) so the trend chart separates and "Group by" has a real axis, an NE→SW gradient that couples Idsat and Vth so the correlation matrix is not a grid of near-zeros, spec limits on all three parametric tests so capability has Cpk to report, and a functional continuity test so the pass-rate panel ranks parametric and functional tests side by side. New `insights.defaultOpen` lands the reader on the charts instead of the map — symmetric with `summaryPanel.defaultOpen`, honoured by both `renderWaferMap` and `renderWaferGallery`, and off by default since a map-first page should stay map-first (it also pulls the lazily-imported chart chunk on load rather than on first click).
|
|
42
|
+
|
|
43
|
+
- **The examples now show the analysis features, not just the map.** They were written before findings, the summary panel and the Insights tab existed, and never caught up: of 20 live examples, 9 computed stats, 5 docked the summary panel and **2** enabled Insights. Someone evaluating the library saw a coloured circle. The focused pages are deliberately left lean — a page about retests or colour schemes is worse with a panel competing for attention — but the pages that do the convincing are fixed: `comparison.html`, `showcase.html`, and the five where a map is the main subject (`interaction`, `display-control`, `test-values`, `metadata-mode`, `quickstart-live`) now compute stats and dock the panel. Now 15 / 11 / 4.
|
|
44
|
+
- **`comparison.html` demonstrates the differentiator instead of tabulating it.** The page exists to argue for wafermap over Plotly, ran to 807 lines, and compared *render timings* — with "findings", "Insights" and "analysis" appearing six times in the whole file, as feature-table text. Speed was never the argument: Plotly draws a scatter perfectly well. A new section below the benchmark renders the same wafer with `analyzeWaferMap`, the summary panel and the Insights tab, and states the measured analysis cost against the build/render timings shown directly above it. It is deliberately **outside** the timed path — folding an analysis pass into the benchmarked side would be measuring two different jobs and calling it a race.
|
|
45
|
+
- **`showcase.html` enables the Insights tab**, which it had never shown despite the index describing it as "all features in one page" — the largest feature was the missing one. It is also promoted from an unlisted footer link ("good for a quick overview *once you know the basics*") to a **Start here** section at the top of the examples index and into the docs nav, reframed for someone deciding whether the library fits rather than someone already convinced.
|
|
46
|
+
- **The offline examples package ships the geometry sidecars.** `manifest.json` drives both the index and the downloadable archive; the six demo CSVs it lists now carry their `.meta.json` alongside, so the offline copy renders the same real 200–300 mm wafers as the hosted pages rather than falling back to inference.
|
|
47
|
+
|
|
48
|
+
- **Demo data now carries its own geometry, so no example renders a dimensionless wafer.** Every CSV under `docs/data/` and the four `showcase.html` scenarios ship a `<name>.meta.json` sidecar holding `waferConfig`/`dieConfig` plus the bin names, units and spec limits a flat die CSV has nowhere to put. Before this, a demo loading `dummy-fulldata.csv` got a wafer **29.83 across with 1×1 dies** and `diePitch.units: 'normalized'` at 0.4/0.5 inference confidence — the relative die positions were right, so the map looked fine, but the stated diameter was fiction and every millimetre-denominated feature (edge exclusion, die pitch, physical ring widths) was meaningless. The generators always knew the real numbers — `gen-showcase-csvs.mjs` builds each dataset from an explicit `radiusMm` and pitch and clips in millimetres — they simply never recorded them. All eight now resolve at 1.0/1.0 confidence with real diameters (150–300 mm) and real die sizes (2×1.5 mm to 10×10 mm). `showcase.html` loads the sidecar for its demo scenarios and falls back to inference for a user-uploaded file, where the geometry genuinely is unknown.
|
|
49
|
+
- **The showcase demo data had dies overhanging the wafer boundary.** Introduced when the scenarios were given real geometry: the generator clipped die *centres* to the radius, so the far corner of every outermost die crossed it. A probed die is by definition a real prober position and therefore fully on the wafer, so an overhanging one is a contradiction — and `buildWaferMap` reported it correctly as `geometry-conflict` on all four scenarios. It was invisible beforehand only because no geometry was declared and nothing could check it. The clip now allows for the die half-diagonal.
|
|
50
|
+
- **The showcase scenarios were 221 dies on a 17×17 grid; they are now ~1,250 on a real 200 mm wafer at 5 mm pitch.** At 221 dies each die was 0.45% of the wafer, so every yield figure moved in half-percent steps — far coarser than any real map, and coarse enough to make the region statistics the page exists to demonstrate look arbitrary. `generate-demos.js` derives the grid from `WAFER_DIAMETER_MM`/`DIE_PITCH_MM` rather than a hardcoded radius, and its header comment (which claimed 150 mm at 10 mm pitch) is now the value actually used and emitted.
|
|
51
|
+
- **Spec limits, tester verdicts and a functional test in the CSV demos.** Limits were the gap that mattered: no CSV dataset had any, so no CSV-backed demo could show spec-limit colouring, out-of-spec markers or the pass-rate pareto. Limits are set from the distributions each generator actually produces — near the tails, giving 0.6–17% of dies out of spec depending on the dataset — because a limit nothing violates demonstrates nothing and one everything violates is no better. A first pass invented plausible-looking limits in SI base units against data stored in the column's own unit (µA, MHz, mA) and put **100%** of three datasets out of spec, which is what prompted measuring first. `showcase-wide-die.csv` additionally gains a `CONTINUITY` column (a functional test — `testType: 'F'`, a verdict with no measured value, 97.4% functional yield) and a `VTH_FLAG` column carrying the tester's own recorded verdict, guard-banded tighter than the spec limit so the tester flag and the spec judgement disagree on a few dies — the case `passFailDisplay: 'test'` exists to expose.
|
|
52
|
+
- **`quickstart-live.html` now declares its geometry**, with a comment explaining that inference is a fallback rather than the norm: it is the page that teaches the API, and it was demonstrating the dimensionless path. `mixedwm38.html` and `real-data.html` deliberately still infer — they load public research benchmarks (MixedWM38, WM-811K) that carry grid indices and no physical wafer size, so inference is the honest answer and a declared diameter would be a fabrication.
|
|
53
|
+
|
|
54
|
+
- **Every numeric analysis option is now validated, and corrections are reported.** A value outside the range that can produce a meaningful analysis is clamped to the nearest usable one and surfaced as a new `'analysis-option-corrected'` `WaferWarning` in `summary.stats.warnings[]` — the channel the toolbar indicator and Summary-panel banner already read, so a misconfigured analysis announces itself instead of returning a confident wrong answer. `ringCount` must be a whole number ≥ 1 (`0`, `-3` and `2.7` were all previously accepted, the first two silently removing every ring finding); `sectorCount` must be 4, 8, 16 or 32; non-finite values fall back to the default. The now-internal statistical thresholds are validated on the same path, because a plain-JavaScript caller has no type checking to stop them passing the removed options. **There is deliberately no upper bound on `ringCount`:** a cap looked obviously right — "rings thinner than a die" — and measuring it said otherwise. At `ringCount: 40` on a 561-die wafer the ring findings still carry 16–148 dies each, because the minimum region size already rejects anything too small to test and adjacent regions merge; and a die-count-derived cap rejected `ringCount: 3` on this repo's own 28-die test wafers, which produce correct findings. The gate that matters was already there.
|
|
55
|
+
- **`StatsFinding`, `StatsSummary`, `LotStatsSummary`, `View`, `WaferMapResult` are now fully documented** — every field carries a doc comment, where `StatsFinding` previously had 9 of 11 undocumented. It is the type an integrator reads to build their own findings UI, and the one place a wrong reading means misreporting a wafer. `effect.relativeDelta` in particular is now explicit that it is a **ratio, not a percentage** — 1.0 is a doubling — since it is the field behind the gate whose documentation was wrong for three months (below). `stats.adjustedPValue` now says plainly that it, not `pValue`, is what the significance gate uses. `View`'s doc records that `dies` and `hoverPoints` are index-parallel, which is the invariant the highlight-offset fix earlier in this release depended on.
|
|
56
|
+
- **Integration guidance for `enableTestValueAnalysis` — the cost, and who decides.** The option's cost was documented per *wafer* (~130ms worst case, "still fast"), which is not the unit anyone decides at: a host runs analysis over a lot, where the same linear scan reaches 1.9s / 6.7s / 9.7s on 5–25 wafer lots at 30–100 tests, and did not finish inside two minutes at 25 × 500. `docs/performance.md` gains a lot-scale table and the single coefficient that spans both (~1–2µs per wafer × die × test, enough to predict "instant or not" before running it), plus the pattern the new `FindingsNotice` enables — run it when the estimate is small, offer it with its price when it is not. `docs/api.md` §7.3.1 is a new section framing the two options actually worth a decision, and states plainly that being off by default is not a recommendation to leave it off: an integrator who never enables it ships a Findings list that silently omits a category. Previously the docs left only two postures, both wrong for someone — leave it off and hide the findings, or turn it on for everybody and make large lots feel like a hang.
|
|
57
|
+
|
|
58
|
+
- **`FindingsNotice` — a host row at the top of the Summary panel's Findings section**, for stating that a category of finding is *absent* and offering to compute it. New optional `findingsNotice` render option on `renderWaferMap`/`renderWaferGallery`, a `setFindingsNotice(notice)` method on both controllers, and the `FindingsNotice` type exported from `/render`. The row carries a `message`, an optional `detail` (normally the size of the job and its expected cost) and an optional `actionLabel`/`onAction` pair; omit the pair for a message-only notice. It renders **above** the severity filter row, and — the load-bearing case — it makes the Findings section render at all when `findings` is empty, since a lot whose only findings would have come from the skipped analysis is exactly where returning `null` would have hidden the offer to run it. Motivated by a real gap in tsmap: wmap's expensive regional test-value pass (`enableTestValueAnalysis`) is off by default, and the only way to turn it on was a checkbox in a host menu — so a reader looking at a Findings list had no signal that a whole category had been skipped, and no reason to go looking for the control. Advertising a control cannot fix an invisible absence; the notice states the gap where the gap is. wmap deliberately never raises this itself: only the host knows what it chose not to compute and what recomputing would cost. The treatment is quiet by design — `buildWarningsBanner` owns the loud one, and a notice competing with it for alarm would misrepresent "some analysis is optional" as "something is wrong".
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **Withholding was undone by its own success: when every test was withheld, three call sites discovered the withheld numbers straight back from the dies.** `buildDataModeEntries`, the stacked-value builder and the die list each fall back to reading bare test numbers off `die.testValues` when they are given no test definitions — right when nobody described any tests, catastrophic when reconciliation deliberately kept none, because it re-offered exactly the numbers that had been ruled incomparable, labelled "Test 1001", and pooled one lot's nanoamps with another's millivolts under them. The cause was an ambiguous `undefined`: the gallery's reconciled list returned `undefined` for an empty result, conflating "nobody supplied defs" with "reconciled to nothing". Those are now distinct — `undefined` still means the former and still permits discovery, an empty array means the latter and no fallback may override it. `buildWaferMap` passes `testDefs` through without normalising, so a host that supplies none is unaffected; a host that explicitly passes `[]` is now taken at its word.
|
|
63
|
+
|
|
64
|
+
- **A finding's click wrote the gallery-wide active test with no reconciliation check at all.** Findings come from per-wafer analysis, which correctly reads each wafer's own definitions — so a `leakage` finding raised on one lot's wafers carries test number 1001, and clicking it switched *every* card in the lot to test 1001, including lots that call 1001 `vth_n_mV` in millivolts. Every other surface consulted the reconciled list; this one wrote the value directly and bypassed all of them, so the original bug remained reachable through the Findings panel. A finding now switches the whole gallery only when the population agrees about that test; otherwise it switches only the wafers the finding names — which come from files that do agree — and restores them when the finding is cleared. A withheld test on a finding that names no wafers leaves the plot mode untouched rather than defaulting to the lot-wide switch. The scoping is arguably right regardless of collisions: a finding about three wafers switching all forty-seven was always over-broad.
|
|
65
|
+
|
|
66
|
+
- **The plot-mode menu now names a test's wafer coverage when it is not lot-wide** (`idsat_p_uA — 13 of 47 wafers`). A gallery's active test is lot-wide but a test is not: across a mixed load a test number can exist in one lot and nowhere else, and picking it rendered every other card empty with no explanation. It is at its most confusing exactly when reconciliation has withheld the numbers the lots *share*, leaving the menu offering the ones unique to a single lot.
|
|
67
|
+
|
|
68
|
+
- **Withholding a colliding test number was evaluated over the whole load, which punished the wafers that agreed.** Reconciling test definitions across every loaded wafer at once means one disagreeing file can withhold a test from all the others: given six lots where four define test 1001 identically and two differ, 1001 was withheld from everyone — so the four perfectly comparable lots lost their shared tests, and the only tests left standing were those unique to a single lot, which is the one thing that cannot be compared with anything. Loading more data made Insights emptier. The Findings panel, which is per-wafer and correctly uses each wafer's own definitions, meanwhile carried on reporting findings for tests Insights would not chart — two surfaces silently disagreeing about what exists.
|
|
69
|
+
|
|
70
|
+
Distributions now reconciles over the population **in scope** rather than the whole load. With a group selected there is no collision to resolve — within one lot a test number does identify one test — so every test returns with its own file's name, unit and limits; "All groups" still withholds, because pooling one lot's `vth_n_mV` with another's `leakage` under one number genuinely is meaningless. When anything is withheld the section now says how many, which numbers, and that choosing a single group brings them back, instead of leaving a short list unexplained. Overview and Correlation still reconcile over the whole population — Correlation has its own per-panel restrict dropdown that would need the shared scope control to benefit.
|
|
71
|
+
|
|
72
|
+
- **The Insights tab now has ONE group scope, in the tab bar, applying to every view.** "Group by" says what the axis is; a new **Show:** control beside it says how much of that axis you are looking at, and every panel in Overview, Distributions and Correlation receives the population it names — including the reconciled test list, so narrowing to one lot brings back every test the whole-population merge had to withhold. Narrowing collapses grouping entirely: a scoped population arrives at each panel with `groups` undefined, so the compare-the-groups machinery (pooled overview rows, clustered bars, overlaid series) simply does not apply and each panel renders its ordinary ungrouped view. That removes a tier of conditional behaviour rather than adding one. The gestures that already meant "narrow to this group" — clicking a group's box in the boxplot to drill, the histogram's legend, the boxplot's Back button — now move the shared scope instead of a private copy.
|
|
73
|
+
|
|
74
|
+
This replaced the per-panel copies, including two that carried the same latent bug: `renderCorrelationPanel` silently restricted itself to `groups[0]` exactly as `renderCapabilityPanel` did, with no "all groups" option to return to and nothing on the card naming the group it had picked — so grouping a six-lot load reduced the correlation matrix to one lot without saying so. Both panels lose their `groups` option outright rather than keeping a typed one that no longer does anything. The withheld-tests note also moved to tab level, since all three views can now be withholding.
|
|
75
|
+
|
|
76
|
+
- **Grouped, the three Distributions panels showed three different populations at once, and nothing said so.** Capability, the boxplot and the histogram each held a private group state with a different default: capability silently restricted to `groups[0]`, the boxplot opened on a pooled overview of every group, and the histogram overlaid them all. So the moment "Group by" was set, a six-lot load produced a capability chart describing one lot next to a boxplot describing all six — and because the cross-panel link broadcast the selected *test* while nothing broadcast the *group*, clicking a test in capability handed the boxplot the right test against the wrong lot. Capability's default was the worst of it on its own: a chart captioned as the lot while drawing one sixth of it.
|
|
77
|
+
|
|
78
|
+
The three now share one group scope (`activeSectionGroup`/`selectGroupEverywhere` in `insightsTab.ts`), exactly as they already shared the selected test and the axis toggles, via a new `makeLinkedGroupSelect` built on the same asymmetry `makeLinkedTestSelect` relies on — `set` adopts a broadcast without firing `onUserChange`, so a scope change cannot bounce back on itself. **The default is now all groups, not the first one.** Each panel still renders the scope the way that suits it, which was never the problem: capability narrows to it, the boxplot drills into it (its click-a-group's-box and Back affordances are unchanged — they now move the shared scope instead of a private copy), and the histogram emphasises it against the others rather than filtering, because an overlaid comparison of every group is that panel's whole job. Wafer-to-wafer trend stays outside the scope, as it already stood outside `groups`: its x axis is the population's own slot order, and restricting it would remove the drift signal the chart exists for.
|
|
79
|
+
|
|
80
|
+
- **Every cross-wafer surface borrowed ONE wafer's `testDefs` and applied it to the whole population.** `TestDef.testNumber` identifies a test *within a test program*, but seven call sites — the Insights tab, the lot Summary panel, the exported lot report, the data-mode menu, the shared active-test resolution and the stacked-value cards — each took the defs from `items.find(it => it.testDefs?.length)`, one arbitrary wafer's list, and applied its names, units and spec limits to every other wafer's values. Across a load spanning more than one test program that is not a labelling slip: die values are keyed by test number, so a number meaning `vth_n_mV` (260–380 mV) in one lot and `leakage_nA` (0–5 nA) in another had the two pooled into a single distribution, normalised against whichever limits arrived first, and drawn under whichever name arrived first. Observed live: a box plot of nA readings titled `vth_n_mV` with a borrowed `USL 380` line across it, process capability reporting a **Ppk of −1489**, and drilled-in maps showing every die out of spec. Three further failures fell out of the same rule — mislabelled axes and colorbars, and tests present only in the non-first wafers missing from every selector, because the list was one item's array rather than a union.
|
|
81
|
+
|
|
82
|
+
Replaced by `mergeTestDefs` (new, exported from `/stats`), which unions every test number across the population and reconciles the defs describing it. **An absent field is "not stated", never a disagreement** — mixing a file that carries limits with one that does not is legitimate and merges silently, the stated value winning. Only two *stated and different* values conflict, in two tiers: distinct names, distinct units or a parametric/functional disagreement mean different measurements sharing a number by accident, so the test is **withheld** from every cross-wafer surface (code `test-def-collision`, severity `error`); the same measurement held to different limits keeps the test and its pooled distributions — the values are comparable — but drops both limits, so capability, spec yield and the limit lines are withheld rather than guessed (code `test-limit-conflict`, severity `warning`). Both reach the toolbar indicator and Summary banner through `collectWarnings`, so hosts get them with no code change. Limits compare on a relative tolerance rather than `===`, so a float32 STDF limit and a float64 CSV one cannot manufacture a false conflict; names compare trimmed and case-insensitively, so `TEST_TXT` drift cannot withhold data. `analyzeWaferMap` is untouched and stays per-wafer against that wafer's own defs, which is why single-wafer views never had this bug.
|
|
83
|
+
|
|
84
|
+
- **`docs/guide.md` carried the same stale thresholds as `api.md`, and had drifted further.** Its effect-size gate still quoted the pre-`955ebc9` `minimumRelativeEffect` of 0.5 ("50% above or below background") alongside a `minimumEffectSize` of 0.15, and its severity table repeated all four superseded numbers. Its worked example was wrong in the same direction as `api.md`'s: a 2-point elevation on a 2% background was offered as "clearly significant", when at the current 1.0 gate it only just clears. Corrected, with a 4-point example that clears comfortably and the marginal cases stated. `tests/docsThresholds.test.mjs` now checks both documents — fixing one while leaving the other only moves the problem.
|
|
85
|
+
- **`docs/api.md` documented statistical thresholds the code stopped using three months ago.** `955ebc9` (v0.12.8, 2026-06-02) retuned every gate and the docs kept the old values — *partially*, so the page mixed old and new with no way to tell which was which: `minimumEffectSize` was documented as 0.15 against an actual 0.20, the severity ladder's four numbers were all one step stale (`unusual` 0.25/2.0× vs an actual 0.30/2.5×, `notable` 0.15/1.0× vs 0.20/1.5×), and the prose described the relative gate as "a 50% elevation" when the default has been 1.0 — a doubling — since the same commit. The worked example taught the rule backwards: it offered "a 2 percentage-point increase on a 3% background is a 67% relative elevation — statistically and practically significant", but 0.67 clears neither gate, so the library emits nothing for that case. It now uses a 4pp example that does pass, and keeps the 2pp case as an explicit counter-example. This matters more than a typo: a reader consults this section to work out why a wafer did or did not produce a finding, and it was giving the wrong answer. New `tests/docsThresholds.test.mjs` reads the numbers out of both `analyzeWaferMap.ts` and the prose and fails when they diverge — confirmed to catch drift in either direction — and checks the worked example's own arithmetic and that it clears the gate it claims to illustrate.
|
|
86
|
+
- **The map canvas's type hierarchy was restored after being flattened onto one size.** Making canvas text follow `--wmap-font-size` (previous commit) routed four of the five map-canvas tokens through a bare `fontPx()`, collapsing three deliberate tiers into two: `MAP_SUBTITLE_FONT` 11px→12px, `SCALE_NOTE_FONT` 11px→12px, `COLORBAR_LABEL_FONT` 10px→12px, `AXIS_TICK_FONT` 10px→11px, leaving the map title and its own subtitle separated only by weight. `fontPx` already took a tier delta and the previous change used it for exactly one token. All five now carry the delta that reproduces their original size at the default 12px base — title `fontPx()`, subtitle and scale note `fontPx(-1)`, colorbar and axis ticks `fontPx(-2)` — so the sizes and their relationships are exactly what they were, and a host moving `--wmap-font-size` still moves the whole scale together. `BIN_ROW_H` (20→17), `BIN_LEGEND_W` (124→110) and `BIN_LEGEND_W_COMPACT` (72→64) revert with them: they had been enlarged only to house the bigger text, and the bin legend's reserve comes off the wafer, so this returns 14px of width to the map in bin mode. `fontPx`'s doc comment now records that `-2` is deliberately not a DOM tier — it is the map canvas's plot-coupled floor, text that annotates dense data rather than chrome, which is why it sits below the 11px DOM minimum. `UI_STANDARDS.md`'s canvas-text rule is amended to state the three tiers and to say plainly that collapsing them was tried and what it cost.
|
|
87
|
+
- **Colorbar tick labels were truncated at the right edge of the canvas.** The band to the right of the bar was two constants — `labelGap = 20` and `rightReserve = colorbarWidth + 28`, giving 31px of label room — set in April when `COLORBAR_LABEL_FONT` was a hardcoded `10px`, where the widest typical label measured ~20px. Making the font themeable via `fontPx()` (default 12px) in the previous commit widened every label by ~20% without widening the band: measured against the reported data (`correlated.csv`, `test_002`, range 48.3–77.0), the widest label ended **0.3px** short of the canvas edge at 12px and overran it at 13px; negative values, which carry a minus sign, overran by 3.5px at 12px and 0.7px at 11px. That change did grow `BIN_ROW_H` 17→20 to compensate for the taller font — the vertical compensation was made, the horizontal one missed. The band is now measured from the text that will actually be drawn: the tick formatter is derived before the reserves are computed and `measureText` sizes `colorbarLabelGap`, keeping every label `COLORBAR_EDGE_MARGIN` (6px) clear of the edge. The tick set itself cannot be used for this — it depends on the bar height, which depends on this reserve — so the endpoints bound the width instead, plus the negated larger magnitude when the range spans zero, which is the only case an intermediate tick can be wider than both endpoints. `COLORBAR_LABEL_GAP_MIN` floors the result at the old 20px, so layouts whose labels already fitted are unchanged to the pixel. `COLORBAR_TICK_LEN`/`COLORBAR_LABEL_PAD` replace the literals that were repeated across the three `fillText` call sites. **Consequence worth knowing:** with wide labels the value-mode reserve can now exceed `renderWaferMap`'s `colorbarReserve` floor (still `colorbarWidth + 28`), which exists to hold the wafer the same size across value/bin mode switches — so a wafer with very wide value labels will draw slightly smaller in value mode than in bin mode. That is the intended trade: a marginally smaller wafer over clipped numbers.
|
|
88
|
+
- **The finding/selection highlight was drawn offset from the dies it belonged to.** `renderWaferMap` cached the auto-fit viewport in `fittedViewport` the first time it was computed and only recomputed it at three explicitly enumerated points: a `ResizeObserver` callback, `resetZoom`, and a plot-mode change. But that cached value is the geometry `currentViewport()` hands to `drawSelectionOverlay`, to click hit-testing and to the hover tooltip, while the map itself is drawn with the viewport `toCanvas` computes for *that* draw — so the two silently diverged whenever anything else moved the fit. The fit origin and scale depend on the colorbar/bin-legend reserve, the legend position, the axis gutter and the legend row count, none of which resize the canvas, so the `ResizeObserver` never fires for them; and because that callback is delivered asynchronously, even a genuine resize left a window in which any render — including the one triggered by clicking a finding — drew the map at the new size against the old cached fit. Reported from a maximised window: clicking a ring finding highlighted an annulus sitting outside the wafer entirely. Measured drift in the regression fixture is 62px for merely hiding the legend and 427px across a maximise. `render()` now re-reads `result.viewport` on every fitted draw, which closes the whole class rather than adding a fourth invalidation point; the plot-mode special case has been removed, since it was one instance of the general bug and would additionally strand `fittedViewport` at `null` while zoomed. **This was a regression, not an original defect:** the condition read `if (!fittedViewport || !viewport)` until 0.9.0 (2026-05-03, `ef34a3c`) — the `!viewport` leg meant "this was a fitted draw", i.e. exactly the invariant restored here. That commit dropped the leg (likely because the no-op `if (!viewport) viewport = null;` beside it made the condition look redundant) and, in the same hunk, added the plot-mode invalidation to patch the one symptom it immediately caused. Plot mode being the commonest way to move the fit is why the remaining routes — legend toggle, legend reposition, and the async-`ResizeObserver` window on a resize — stayed latent for four months. The fix is not a plain revert: the old condition also assigned while zoomed when `fittedViewport` was `null`, writing the zoom into the fit baseline; the `viewport === null` guard closes that too. The assignment is guarded on `viewport === null` so a zoomed draw cannot overwrite the zoom clamp's fit baseline. The same staleness affected which die a click or hover resolved to, not just the highlight.
|
|
89
|
+
- **The floating toolbar lay across the docked Summary panel.** It is `position: absolute; right: 4px` inside `mapBox`, and `mapBox` contains the panel as well as the canvas — so the toolbar was pinned to the far edge of the whole row and covered **296px of the panel's 300px width** at the same height, putting the panel's scrollbar and top border under the buttons. The previous mitigation padded the panel's *content* down, which is why the heading cleared the toolbar while the scrollbar did not: a scrollbar is drawn on the element's full height, padding included. The panel is now pushed down by a `marginTop` — moving the whole box, border and scrollbar included — rather than the toolbar being inset by the panel's width. Insetting was tried first and cost map area: it narrows the space the toolbar has to lay out in, and in a 700px drilldown modal that tipped it onto a second row, taking 28px of height off the wafer. `marginTop` leaves the toolbar's width untouched, so it stays a single row at every width tested. `paddingTop` is still correct for a panel docked *above* the map, where the toolbar genuinely overlays it and moving the box down would only open a gap. The toolbar stays a child of `mapBox` rather than moving into `canvasWrap`, because it must paint above `insightsTab.el`, which covers `mapBox`.
|
|
90
|
+
- **Cards, the gallery grid and the Summary panel sat flush against the window edge.** A card is a bounded surface: with no gutter you see three of its borders and the screen edge standing in for the fourth, which reads as clipped rather than deliberate — and on the Summary panel, which carries `RADIUS.container` and its own shadow, the rounded corners were visibly flattened against the edge. `UI_STANDARDS.md` already required this gutter, but only for overlay `contentWrap`, so nothing in the main view was covered by it. Three different insets were in use and none of them was the edge gap: the Insights bands at 10px, the gallery bar at 4px, tsmap's own toolbar at 12px. There is now one `EDGE_GUTTER` (12px, `SPACE.xl`) shared by the Insights content, the gallery card grid and the docked Summary panel — on the scale, at the tight end of the 12-24px page-margin range the major design systems use, and matching the host toolbar directly above so cards line up with it. It is applied to the **container**, never as a margin on the cards: `gap` already spaces cards from each other, so a card margin would double up between neighbours while leaving a single gap at the outside. In the gallery the body row's own `gap` was removed for the same reason — the grid carries the gutter on both sides and the summary panel docks against one of them, so the two stacked into a 24px trench between the cards and the panel while every other edge used 12. Letting the grid's padding serve as both the window gutter (free side) and the panel separation (docked side) makes those equal without a rule that has to know which side the panel is on or whether it is open. The gallery's sticky header takes the gutter on its wrapper rather than on the toolbar and legend bars inside it: both are bordered, radiused surfaces in the same card language as the wafer cards, and both stayed flush while the cards moved in — padding the wrapper insets both at once and keeps its background full-bleed, which is what hides content scrolling under a sticky bar. The wafer map canvas is deliberately excluded and stays full-bleed — map area is the priority, and in a narrow drilldown modal the panel's gutter already costs ~12px of wafer diameter.
|
|
91
|
+
- **The floating toolbar and the docked Summary panel sat on different right edges.** The toolbar was `right: 4px` while the panel took `EDGE_GUTTER` (12) — two bordered, radiused surfaces stacked at the same edge and 8px apart, which reads as a mistake rather than as two things measured from different frames.
|
|
92
|
+
|
|
93
|
+
Both now use a new `MAP_CHROME_INSET` (4px), so they align on one column and the toolbar is symmetric on both axes. The first attempt aligned them the other way, moving the toolbar out to `EDGE_GUTTER`, and that was the wrong direction: `EDGE_GUTTER` is the gap between a bounded surface and the edge of the REGION it lives in, and a map's own chrome is already inside such a region — in a gallery card the card supplies that outer inset, so a second 12px within it stacks two gutters, which `UI_STANDARDS.md`'s own gutter rule forbids. On screen it was a toolbar standing 12px off the side of a card while sitting 4px off its top. The alignment was the right goal; the value was wrong, and the panel was the piece that should have moved.
|
|
94
|
+
|
|
95
|
+
`createSummaryPanelEl` therefore takes its inset as an explicit parameter with **no default**: `renderWaferMap` docks the panel inside `mapBox` and passes `MAP_CHROME_INSET`, `renderWaferGallery` docks it at the gallery's own edge and passes `EDGE_GUTTER`. A default here is a guess about context, and guessing wrong produced both this bug and the one after it — a blanket change to the small inset then put a 4px gutter on the gallery's panel, where 12 belonged.
|
|
96
|
+
|
|
97
|
+
`maxWidth` is derived from the inset (twice it) rather than the bare `calc(100% - 8px)` it used to be, which silently encoded the old 4px and would have let a wrapped second row overhang the moment that changed. `TOOLBAR_BAND_CSS` is unaffected — still 4 + 30 + 4.
|
|
98
|
+
|
|
99
|
+
- **The bin legend did not react to the pointer, though its rows are clickable.** Hovering a legend row set `cursor: pointer` and showed a tooltip but changed nothing on the map, so the row looked inert — the same "promises interactivity, does nothing" defect the new hover rule catches in the DOM, except the legend is drawn on the CANVAS, where no `:hover` can reach it and no static check can see it. `ToCanvasOptions` gains `hoverBin`, drawn as a row background fill from a new `hoverRow` colour on `CanvasTheme` (reading the existing `--wmap-bg-hover` token, so it follows the host's theme). Deliberately a different channel from `activeBin`, which marks the SELECTED row with an accent border and bold label: selected and pointed-at must stay tellable apart, and a row that is already active does not take the hover fill on top. `renderWaferMap` tracks the hovered row and redraws only when it CHANGES, never per pointer sample — the whole canvas is repainted, measured at 0.4-3.2ms on a 1050px map, which is affordable once per row crossing and would not be per mousemove. Cleared on pointer-leave, since the move handler stops firing at the canvas boundary.
|
|
100
|
+
|
|
101
|
+
Both legend layouts needed it. The grid legend and the floating/compact one are separate draw blocks that both push into `binLegendRows`, so both are hit-testable; fixing only the first left the feature looking completely dead, because the floating variant is what a single map usually renders.
|
|
102
|
+
|
|
103
|
+
- **`binCluster` and `testPassRate` were the same chart written twice; they now share `groupedBarPlot.ts`.** Identical `CLUSTER_GAP`/`SUBBAR_GAP`/`SUBBAR_HEIGHT` constants, an identical `plotMetrics()`, an identical `subBarAt()` differing only in whether it called the index `bin` or `row`, and an identical draw loop down to the order the hover highlight is painted in. The duplication was legible in the comments themselves — binCluster's read "same fix as charts/testPassRate.ts" and testPassRate's read "see binCluster.ts", each pointing at the other for the reasoning. What the two genuinely differ in is data, not drawing: bin counts normalise to the chart's largest count while pass rates use a fixed 0-100% axis (a 99% and a 98% test must not both draw a full-width bar), a pass rate can be "nothing measured" where a count cannot, and the trailing column is a count in one and a percentage in the other. All three are now inputs. Both callers also built their own 10px legend swatch, a further copy of the colour key the suite had just standardised; both now use the shared chip.
|
|
104
|
+
|
|
105
|
+
- **A canvas primitive.** Every chart opened its draw with the same eight lines — backing store at `cssW * dpr`, CSS size, `getContext`, `setTransform`, `clearRect`, default font and baseline, `resolveChartCanvasColors` — which is one place per chart to read the DPR from the wrong window (eight of ten did), to forget `setTransform` after a resize, or to clear in device pixels rather than CSS ones. `prepareCanvas(canvas, card, cssW, cssH)` returns a cleared, scaled context and the resolved theme; all eleven chart modules use it, and `setTransform` no longer appears in any of them.
|
|
106
|
+
|
|
107
|
+
- **Eight of the ten chart panels read `window.devicePixelRatio` — the opener's, not their own.** A chart card can be reparented into a detached popup, and that popup can sit on a display with a different pixel ratio, so the canvas backing store was sized for the wrong screen: a blurry or mis-scaled plot, visible only on a multi-monitor setup. `trend` and `testPassRate` had each independently worked out that they should resolve the card's own view, which is the usual sign the knowledge wants one home — it is now `chartDpr(el)` in `chartShell.ts`, used by all ten. A ninth site turned up in `renderWaferGallery`'s composite image export, which had the same bug for the same reason. `check-overlay-conventions.mjs` gains a rule for it, alongside the existing bare-`document.head.appendChild` one it belongs beside.
|
|
108
|
+
|
|
109
|
+
- **`chartFillHeight` measured its siblings with `offsetHeight`, so every margin was invisible to it.** The canvas was handed more room than actually remained and overlapped whatever sat above it. This was the THIRD appearance of that omission — `applyCanvasFlow`'s callers and the histogram's own `siblingH` were the first two, both fixed the same day — so it is fixed here, in the helper every chart shares, rather than a third time at a call site.
|
|
110
|
+
|
|
111
|
+
- **Every chart drew the "colour that stands for a series" differently, and the two clickable legends were the same control built twice.** The histogram tooltip used a 9px rounded square written as an HTML string, the scatter legend a 9px circle written as a style object, and process capability an 11px bordered square dimmed to 0.75 opacity — each defensible alone, and collectively a key that changed appearance as a reader moved between charts. There is now one `chartSwatchCss`: 10px, pill, full strength, with a single `outline` variant because capability needs "no spec limits" to read as absent rather than as another colour.
|
|
112
|
+
|
|
113
|
+
The clickable legends went further apart than that. The scatter's category filter and the histogram's group emphasis do the same thing — click a colour to narrow the plot — and differed in every particular: a pill with a border versus no border at all, the shared swatch versus a 10px square, `wireControlHover` versus a `filter: brightness(0.94)` that is invisible on a dark theme, and 0.35 versus 0.45 dimming. Both are now one `makeSeriesLegendItem`.
|
|
114
|
+
|
|
115
|
+
**Both had also carried "selected" on the `background`, which cannot work here.** `wireControlHover` owns `background` and `color`: it snapshots a resting pair on first hover and restores it on mouseleave, so hovering a chip *before* clicking wiped the selected look, and hovering a selected one left the look behind after it was switched off — chips displaying a state they were not in, which is exactly how it appeared on screen. Selection now lives on `borderColor` and `fontWeight`, neither of which hover touches, so the two cannot reset each other; weight also means the cue is not colour alone. An earlier attempt used a `box-shadow` ring, which failed differently: box-shadows paint outside the layout box, so `applyCanvasFlow` never counted it and the ring was clipped.
|
|
116
|
+
|
|
117
|
+
- **The plot overlapped the legend it sits under.** `applyCanvasFlow` was passed `legend.offsetHeight`, which excludes margins, so the canvas began at the legend's content edge and any margin on it became overlap — 8px of the scatter's chips were painted over, and giving the legend the breathing room it needed made it worse. It now takes the ELEMENT and accounts for margins; the histogram's own `siblingH` had the same bug and the same fix.
|
|
118
|
+
|
|
119
|
+
- **Picking a test in one Distributions chart left the others showing a different one, and the trend chart's own selector could get stuck.** Only the capability chart ever broadcast a selection; choosing a test in the boxplot, histogram or trend told nobody, so four panels on one page silently disagreed about what they were displaying. Worse, `setTest` in boxplot and histogram synced their control (`select.value = …`) while trend's did not **and could not** — it appended its selector without keeping a reference, so being driven from a sibling moved its data and left its control naming the previous test.
|
|
120
|
+
|
|
121
|
+
All four are now linked through `makeLinkedTestSelect`, which owns the active test, the guard against an unknown or unchanged one, and the control sync. Its `set()` deliberately does not fire `onUserChange` — that asymmetry is what lets panels drive each other without a broadcast bouncing back, and it previously existed only by accident, because nothing broadcast at all. Capability joins as the fourth: it used to broadcast a selection and never show one, and now marks the active column with an accent rule at the plot base — a different channel from hover's background fill, so "pointed at" and "chosen" stay tellable apart.
|
|
122
|
+
|
|
123
|
+
- **The axis toggles were the same linked state, written a third time.** `axisIncludesLimits`/`clipOutliers`, a `syncAxisToggles` building the same two toggles, and a byte-identical `setAxisPrefs` existed separately in the boxplot, histogram and trend. Now one `makeLinkedAxisPrefs`. This one was not misbehaving — but only because those three happen to rebuild their toggle row on every redraw, so an externally set value is re-rendered as a side effect. The test selector was built once and never re-synced, and that is the entire difference between the two. Neither arrangement was a decision.
|
|
124
|
+
|
|
125
|
+
- **Four cross-file clones removed, and a check added that can actually find them.** Two were correctness risks rather than untidiness: `clusterDetection` and `patternClassification` each had their own 8-connected flood fill (now `stats/connectedComponents.ts`), so the same wafer could report a cluster in one place and no pattern in the other; and the Summary panel and the exported report each pooled per-wafer functional yield (now `poolFunctionalYield`), which are the two places a reader would naturally compare. The other two: the Overlays menu rows built identically by the map and the gallery (`overlayMenuRows`/`anyOverlayActive`), and the WAI-ARIA roving-focus keyboard handler written twice (`wireListNavigation`) — thirty duplicated lines guarding one line of genuine divergence, that a searchable list must not steal focus from its own search box.
|
|
126
|
+
|
|
127
|
+
`check-clones.mjs` (both repos, wired into `check`) finds these. It compares SHAPE, not names: identifiers and literals are normalised away and equal-length token windows matched across files. That matters because the name-based alternative cannot see them — run an inventory of declared methods over `binCluster.ts` and `testPassRate.ts` as they were, ~200 lines of shared structure, and it reports one row, `onSaveImage`, because every other identifier differed. Validated by firing on that historical clone, on a deliberately renamed copy, and — within a minute of being wired in — on a regression of its own author's making, when a stray `git checkout` silently reverted one of the extractions above.
|
|
128
|
+
|
|
129
|
+
Its limit is worth stating: it finds code that was copied and renamed. It does **not** find the same idea written differently, and reports clean on the three chart swatches that were exactly that. Those were only caught by budgeting the visible output, which is the other half of the same job.
|
|
130
|
+
|
|
131
|
+
- **`UI_STANDARDS.md` is now partly enforced rather than entirely asserted.** The contract kept being written down and then *asserted* to be met — which is how a gutter rule added in this same release was broken within the hour by the element that had held the gutter before it moved. `check-style-scales.mjs` (both repos) gains two rules, inside the existing checker rather than as new scripts, since the repos have enough of those: a **budget on distinct off-scale spacing literals** (a ratchet at today's count — 4 here, 6 in tsmap — to be lowered as they are resolved, never raised), and a **focus-ring rule** requiring `outline: none` to carry a focus-specific reason in an adjacent comment. Both scan through a new quote-aware comment stripper, because a comment in `chartShell.ts` quotes `outline: none` while explaining its removal, and an unguarded scanner reads the explanation as the offence — the same shape as the `check-button-styles.mjs` regex that stopped at a `;` inside a CSS string and pronounced everything clean. Only literals at a style site count against the spacing budget, so naming a value once with its derivation (`EDGE_GUTTER`, `TOOLBAR_BAND_CSS`) is invisible to it while a bare `44px` at four call sites is not — the incentive pointing the way the standard already asks. `UI_STANDARDS.md` now states which rules are enforced and which are only prose, instead of implying full coverage.
|
|
132
|
+
|
|
133
|
+
**Every budget is now zero**, not headroom. The spacing and radius rules were introduced as ratchets sitting at whatever count already existed (4 off-scale spacing values here, 6 in tsmap, plus stray radius literals) because snapping them changes appearance. All are now snapped to the scale: `3px`→`4px` (die-list cells), `5px`→`4px` (histogram legend swatch margin), `14px`→`12px` and `20px`→`24px` (guide TOC bar and column gap), `14px`→`12px` (warnings panel), and both `3px` guide radii to `RADIUS.control`. One deliberate exception remains and is documented in the check itself: the `2px` rounding on the histogram's 9x9 legend swatch, which is not a control, a container or a pill, and to which `RADIUS.control` would read as very nearly a circle — the "aligns to something rather than carrying rhythm" case the standard allows.
|
|
134
|
+
|
|
135
|
+
Two further rules followed: a **radius budget** (a literal at a style site invents a fourth role beside `RADIUS.control`/`.container`/`.pill`) and a **hover rule** (`cursor: pointer` promises interactivity, so an element that never visibly reacts reads as dead). Values that already *were* a role were converted rather than admitted — `4px` → `RADIUS.control`, `50%` → `RADIUS.pill`, both rendering identically — so only genuinely off-role literals remain in the budget. The hover rule found **18 real sites in wmap and 10 in tsmap**, including the Summary panel's collapsible section headers and the Insights tab buttons, all of which are clickable and inert on hover; those are budgeted rather than fixed, since giving them hover states is a visible change and its own decision. **Every site the hover rule found has since been wired**, so that budget is now zero in both repos rather than left as headroom. In wmap: the Summary panel's collapsible section headers, finding rows, detail button and both chevrons; the severity filter chips (which also gained the `data-on` flag so an active chip keeps its own background instead of being flattened by hover); the Insights tab buttons; the segmented control's selected segment; `makeToggle`'s label; the gallery's metadata title and expand button; the map's clickable footer and expand button; and all four of the overlay's header buttons — two of which (`maximize`, `close`) the check found only after it learned to resolve a shared style object to its consumers.
|
|
136
|
+
|
|
137
|
+
A later edit to the hover rule spliced out the focus-ring and radius REPORT blocks entirely — both rules kept computing their results and then silently discarded them, while the summary line still printed their counts, so the checker looked fully armed while two of its five rules were dead. Found by re-running the acceptance test for every rule rather than only the one being edited, which is now the rule: a checker change is not done until all of its rules have each been shown to fail on a planted defect.
|
|
138
|
+
|
|
139
|
+
Both new rules passed a planted violation when first written, and were fixed only because the acceptance test exists: the focus rule could not fire at all — its justification window included the offending line, which necessarily contains the word "outline", so every violation excused itself — and before that, a bare mention of `UI_STANDARDS` anywhere nearby was enough to exempt a suppression.
|
|
140
|
+
|
|
141
|
+
- **Insights spaced every band identically, so nothing said which of them belonged together.** `rootEl` used one `gap: SPACE.lg` between the identity strip, the tab bar and the content — equal spacing that reads as three unrelated bands, with the tabs in particular floating between two identical gaps rather than looking attached to the content they switch. Reported as the controls having lost their relationship to the content, and separately as too much air between the strip and the tabs; both are the same cause. The uniform gap is gone: the strip and the tab bar are now tight to each other (`SPACE.xs`) as one header block, and the break from that block down to the content is several times larger. The contrast is what carries the grouping — no single value can, at any size. Written up in `UI_STANDARDS.md` alongside the gutter rule.
|
|
142
|
+
|
|
143
|
+
- **The gutter was horizontal only — surfaces still butted against the top of the view, and the Insights rule ran past it.** The gallery's sticky header and the Insights root are each the first thing in their view, so in a host that gives the map area no padding of its own (tsmap's `#map-container`) they sat directly against the host's own toolbar, two bordered surfaces with nothing between them. Both now take `EDGE_GUTTER` on top as well; on the sticky header that padding doubles as the band of its own background that keeps cards from touching the bar once they scroll under it. Separately, the Insights tab rule is that element's own `borderBottom`, so it spanned exactly as wide as the element — the tabs sat inside the gutter while the line ran straight past it to both edges. It takes the gutter as `margin` rather than `padding`, which shortens the rule itself so its ends land on the same column as the card borders above and below. Tab labels then sit at gutter + the buttons' own padding, the same place a card's text sits, so the view reads as two consistent columns: structural edges on the outer, text on the inner.
|
|
144
|
+
|
|
145
|
+
- **The Insights identity strip was indented relative to every card below it.** The Lot/Product/Program line carried a 10px `BAND_INSET` while the chart cards were flush at 0, so the two never shared a left edge. Both now take `EDGE_GUTTER`, as do the tab buttons — whose horizontal padding is what insets the first tab's label, and so has to match or the tab row lands on its own edge. The bands themselves stay full-bleed so the tab bar's `borderBottom` still spans edge to edge; only their content moves in. That is also why the gutter went on `bodyEl` rather than on the scroller or the Overview grid: `bodyEl` holds whichever sub-tab is active, so one value covers Overview, Distributions and Correlation, while padding an ancestor would have pulled that full-width divider in from both sides.
|
|
146
|
+
- **The gap under the toolbar was two and a half times the gap above it.** The clearance was 44px against a toolbar sitting at `top: 4px` and 30px tall — 10px below, 4px above — and the eye reads a control and the space beneath it as one shape, so the toolbar looked mis-set rather than bedded in a band. It is now `38px` = `4 + 30 + 4`, the same inset repeated. The value was also a bare literal at four call sites; it is now one constant, `TOOLBAR_BAND_CSS`, carrying its derivation. It deliberately sits off the `SPACE` scale, which `UI_STANDARDS.md` permits for a value aligned to another element rather than carrying rhythm — here the total is off-scale but every part of it is on-scale.
|
|
147
|
+
|
|
148
|
+
- **The Insights tab's pickers (test, wafer, "Group by", findings filters) ignored host theming entirely.** They were native `<select>` elements, and WebKitGTK — the Linux Tauri WebView a host like tsmap runs in — paints the closed box with native GTK chrome regardless of `CLR.*`, while the *open* option list is OS-drawn in **every** engine, so no CSS reaches it anywhere. An earlier pass applied `appearance: none` to win back the closed box; that could never touch the popup, which left these as the one part of an embedded map unable to follow its host's theme (tsmap has sixteen). All three builders (`makeTestSelect`, `makeWaferSelect`, `makeLabeledSelect`) now share one themed picker, `makeListSelect` — a trigger button plus a popup `listbox`, generalised from the long-list combobox that already backed `makeTestSelect` past `MENU_SEARCH_THRESHOLD`, so the searchable and short-list paths are one implementation rather than two. `styleNativeSelect` and its hand-drawn arrow SVG are gone. `makeWaferSelect` returns `HTMLElement & { value: string }` rather than `HTMLSelectElement`; every in-tree caller only ever set `.value` or `.style.display`, and none of the three is exported from the package, so no public API changes. **Hosts driving these in automation must click the trigger and the option row instead of setting `select.value` + dispatching `change`** — `data-wmap-select` still marks the same control, now on the trigger button.
|
|
149
|
+
- **Option rows suppressed their own focus ring.** The long-list test combo set `outline: 'none'` on each row and repainted the row background on `focus` instead, so keyboard focus and mouse hover were indistinguishable and a host's own `:focus-visible` styling could not reach the rows. Rows now keep the browser ring, and the background changes on hover only. This is the shared contract now written down in `UI_STANDARDS.md` ("Option lists and menus: one visual contract"), which both this library and tsmap follow: `listbox`/`option` roles for value pickers, roving `tabIndex = -1` so the engine draws the ring, and exactly three visual states — selected, hover, focus — with no hand-drawn focus indicator in any widget.
|
|
150
|
+
|
|
151
|
+
- **Two demos shipped data that did not fit the wafer they declared, and one shipped a wafer that was not round.** Both came from the same arithmetic mistake, made independently in a fixture generator and in a demo: clipping a die grid to a circle by testing each die's **centre** against the wafer radius. The part of a die that leaves the wafer first is its outer **corner** — √2/2 of a die further out at 45°, not half a die — so a centre-based clip lets corner dies protrude. `metadata-mode.html` put 7 of its 76 dies past a 90 mm edge (worst corner 50.9 mm against a 45 mm radius) and `docs/data/dummy-fulldata.csv` put 8 of 665 past 300 mm; wmap raised `geometry-conflict` on both, correctly, against the library's own shipped demos. `scripts/gen-dummy-fulldata.mjs` now clips at `Math.SQRT1_2`, and `metadata-mode.html` tests the die corner directly, in the same terms wmap checks, with the wafer and die size declared **once** and used for both the clip and the render config so the data and its geometry cannot disagree again.
|
|
152
|
+
- **`comparison.html` was rendering an elliptical wafer.** It had been given a hardcoded 5 × 5 mm die to silence an `inferred-pitch` advisory, but WM-811K's grid is **58 × 53, not square**, so a square pitch squashed the map to a 1.10 aspect ratio — and supplying a pitch suppressed the very advisory that would have said so. That fixture ships no die size, so any pitch written there is invented; it is back to declaring the diameter alone, where wmap derives the pitch per axis and shrinks it until every probed die fits. The map measures 285.0 × 284.6 mm (aspect 1.002) again. The `inferred-pitch` advisory it raises is honest and, since the severity change in this same release, only a warning — which is what made the hardcoded pitch unnecessary in the first place.
|
|
153
|
+
- **The Summary panel's bin breakdown ignored the map's plot mode.** Which bin type it showed was chosen by data presence (`hasHbin ? 'hard' : 'soft'`), so any wafer carrying both hard and soft bins showed a *hard*-bin breakdown no matter what the map displayed — the panel silently describing a different population from the one on screen. It now follows `plotMode` (`hardBin`/`stackedBins` → hard, `softBin`/`stackedSoftBins` → soft), falling back to whichever type has data, with a Hard/Soft selector in the section header to override. `maplessSummary.ts` already derived this correctly from the plot mode; all three call sites now agree.
|
|
154
|
+
- **Below-median wafer-yield bars were greyed with nothing on screen saying so.** The muted fill was a colour-only encoding (WCAG 1.4.1) whose only explanation — a median marker — was a 1px 50%-opacity hairline drawn *underneath* the bar fill, invisible on every above-median row. The median split also flags half of every lot by construction, including lots where every wafer is within a point of the others. Bars are now a single colour, the median marker is legible against both the fill and the empty track, the median is named in the section title, and genuine outliers (Tukey: below `Q1 − 1.5 × IQR`, only when there are ≥5 wafers) are labelled "low outlier" in the row text, which survives greyscale.
|
|
155
|
+
- **Two different yield percentages sat in the lot panel with no way to tell them apart.** "Mean wafer yield" is an unweighted mean of per-wafer yields; the bin breakdown's pass-bin row is a die-weighted share of the pooled population. They agree only when die counts are even across the lot, and both rendered as a bare percentage. The card now reads "unweighted, per wafer" and bin bars are titled "% of dies (N=…)".
|
|
156
|
+
- **The lot panel never stated its population.** It showed a wafer count and a percentage over an unnamed set of dies. It now reports dies analysed and excluded, from `StatsSummary.stats.analyzedDies`/`.excludedDies`.
|
|
157
|
+
- **A merged hard/soft bin finding printed the same bin term twice.** "hard bin 3 (Fail (multi)) and soft bin 3 (Fail (multi)) (same dies)" — three nested parentheses restating one fact. When the two bins share a number and a name the term is now factored: "hard bin and soft bin 3 (Fail (multi)) (same dies)". Differing names still print both labels in full.
|
|
158
|
+
- **`tests/summaryPanel.test.mjs` passed the precomputed bin counts into `buildBinSection`'s `colorScheme` slot**, so both sides of "precomputed counts produce the same text as the raw-scan fallback" fell through to the raw-die scan and the assertion compared the fallback with itself. The precomputed path it exists to cover was never exercised.
|
|
159
|
+
- **The user guide claimed yield, bin breakdown, region yield and per-test statistics had moved out of the panel into the Insights tab.** They had not; all four are rendered by `renderWaferSummaryContent`. Section 6 now documents the panel as it actually is.
|
|
160
|
+
|
|
161
|
+
- **The single map's Insights view hid a toolbar whose space it was still reserving.** Opening Insights hid the whole toolbar band on the grounds that it held "one duplicate button" — but `insightsTab.el` reserves `TOOLBAR_BAND_CSS` (38px) at its top precisely so a toolbar can sit over it, and that reservation stayed. The band was therefore still costing its full height with nothing drawn in it, so the space the hiding was meant to save was never actually saved. The toolbar now stays visible while Insights is open, at zero additional height, carrying the map-specific groups hidden exactly as before.
|
|
162
|
+
|
|
163
|
+
This fixes the real complaint, which was not vertical space but a control that moved: the way out of Insights was the toolbar's icon toggle at the top-right in map view and a "‹ Map" tab at the top-LEFT of the chart suite in Insights, so the one control a user needs when they are lost changed both position and appearance at the moment they needed it. It now holds one corner in both views.
|
|
164
|
+
|
|
165
|
+
**The identity header stays visible in Insights too, and that is load-bearing rather than cosmetic.** The header is an in-flow sibling *above* `mapBox` while the toolbar is absolutely positioned *inside* it, so hiding the header let `mapBox` rise by the header's height and carried the toolbar up with it — out of the map and into the reserved band, a whole row, on every single switch. Making the toolbar persist without this fixed the horizontal position and left the vertical jump, which is worse than either alone: the control now stayed on screen while visibly hopping between rows. Keeping the header also settles the other half of the problem, that this wafer's identity moved between a header row and the chart suite's own strip; it is now in one place in both views, and the tab's strip is switched off for this host (`showMetadataStrip: false`) so the two never both render — the arrangement `renderWaferGallery` already used, for the same reason.
|
|
166
|
+
|
|
167
|
+
The back tab and the tab row's Help are now passed only as **fallbacks**, when the toolbar cannot carry them (no toolbar, a `view-only` toolbar, or `showHelpButton: false`). Passing them unconditionally alongside a persistent toolbar put a second way back and a second Help in the tab row — the duplication that hiding the toolbar had been introduced to avoid. `renderWaferGallery` still passes both: its own toolbar continues to hide, and that view is being looked at separately.
|
|
168
|
+
|
|
169
|
+
- **Expand is back in the toolbar, and the metadata header carries identity only.** Expand had been moved onto the identity header on the reasoning that the header now existed and could be made to resemble a gallery card's. That was opportunistic rather than functional, and it cost more than it looked: the header had to mount for a wafer with **no metadata at all** just to give the button somewhere to live, and hiding the header in Insights (metadata is shown there in the tab's own strip) silently took the view-level Expand with it. Expand is a view control — "give this more room" — and now sits with the other view controls, restored to the exact position, separator and `makeBtn` call it had before the move. The header mounts only when there is metadata to show, and has lost the bottom rule that made it read as a card header: a gallery card's rule divides a header from a body inside a bordered tile, and a single map has no tile to divide.
|
|
170
|
+
|
|
171
|
+
- **Expand now works in the Insights view, on the whole chart suite.** It had been hidden there because the expand modal could not carry that view: it reparented the canvas (or the canvas+summary-panel wrapper) and the toolbar as two separate roots, and `insightsTab.el` is a *third* sibling inside `mapBox` — so moving the canvas out took the map and left the charts behind, while moving the charts instead left the modal blank the moment you switched back to the wafer view inside it. Neither root owned the view.
|
|
172
|
+
|
|
173
|
+
`mapBox` does, and it is now the single reparent target. It already contains the canvas, the summary-panel wrapper, the toolbar and the Insights overlay, so whichever view is showing travels into the modal and both keep working there — including toggling between them inside it. It also deletes the special-casing the two-root shape needed: `mapBox` is already `position: relative`, so the toolbar's absolute corner resolves against it exactly as it does in the page, with no `contentWrap` fix-up and no pairing of roots for the reparent helper's stale-reference guard to reason about. The `E` shortcut is live in both views for the same reason. `maxSize` is lifted while expanded and restored on close — the cap now travels with the element being moved, where before it was left behind on `mapBox`.
|
|
174
|
+
|
|
175
|
+
**The modal is sized to its content.** The default box is a 700px square, which is the right shape for a circular wafer and the wrong one for the chart suite: the suite lays out ~1330px wide in a normal page, so opening it in that square made Expand produce a view *smaller* than the one it expanded from, with the charts reflowing into a narrower column. Insights now opens at `min(96vw, 1600px)` × `min(92vh, 1000px)`; measured on the docs demo the charts go from 503px to 776px tall.
|
|
176
|
+
|
|
177
|
+
That size is a new optional `boxSize` on `OverlayOptions`/`ReparentModalOptions`, and adding it turned up a latent bug: `min(90vw, 700px)` was written as a literal in **three** places — the base box style, the un-maximize restore, and the un-minimize fallback — so any box opened at a non-default size would have snapped back to 700px square the first time it was maximized or minimized and restored. The size is now derived once and used by all three.
|
|
178
|
+
|
|
179
|
+
- **The gallery's Insights view now has the same chrome as the single map's.** The Insights suite is the same content in both — the same tabs, the same charts, differing only in how many wafers fed it — but the frame around it was arranged differently depending on which view opened it. The gallery hid its whole toolbar on entering Insights, so the toggle did not merely move: it became a different control, an icon button on the bar turning into a "‹ Gallery" text tab at the far left of the chart suite, while the identity strip jumped 58px up at the same moment, leaving nothing on screen still enough to anchor the change. The bar now stays, carrying the toggle and Help, and the back tab and the tab row's Help are gone — with the bar always present here (there is no option to suppress it, and the toggle exists whenever Insights does) a back tab could only ever be a second control doing the bar's job two inches to the left.
|
|
180
|
+
|
|
181
|
+
Unlike `renderWaferMap`, hiding this bar did reclaim real space — 58px, measured — because the gallery's bar is in flow, where the single map's Insights view reserves its toolbar band whether or not a toolbar is in it. That cost is accepted deliberately: it is the same 46px the grid view already pays, so neither view is the odd one out, and it buys a control that stays where the user left it.
|
|
182
|
+
|
|
183
|
+
- **A map's chrome inset is stated by the caller (`RenderOptions.chromeInset`), because it depends on context.** Giving every map `EDGE_GUTTER` put a second full gutter inside gallery cards, which already supply their own — a toolbar standing well off the card's side, and precisely the two-gutter bug `MAP_CHROME_INSET` had been introduced to prevent. That constant was deleted earlier in this release on the grounds that the Summary panel was its only remaining user; that was wrong, because the chrome row inherited the same context problem the moment it started carrying the toolbar. It is restored, and the choice is now explicit: the default `EDGE_GUTTER` is right for a standalone map, where the map area IS the region, and `renderWaferGallery` passes `MAP_CHROME_INSET` for its cards. The detached-window path deliberately keeps the default — there the map is the region again.
|
|
184
|
+
|
|
185
|
+
The same value is threaded to the docked Summary panel and to the Insights tab's own content (a new `contentInset` dep, defaulting to `EDGE_GUTTER` for the gallery), so the identity, the toolbar, the tab bar and the panel all sit on one column in whichever context the map is rendered.
|
|
186
|
+
|
|
187
|
+
- **The chrome row had no top inset**, so in a host that gives the map area no padding of its own (tsmap's `#map-container`) it butted straight against the host's own toolbar — two bordered surfaces touching. It now takes `chromeInset` on the top as well as the sides, matching the top gutter `renderWaferGallery`'s sticky header has always had for the same reason.
|
|
188
|
+
|
|
189
|
+
- **Geometry and analysis advisories in the Summary panel are collapsed by default.** They explain a geometry decision and its consequences, so the prose is necessarily long — `inferred-pitch` runs to about ten lines in a 300px panel. Rendered in full and undismissable, it pushed the panel's own content (the yield figures, the findings, the report links) below the fold on every open of every wafer in a lot with inferred geometry, which is the common case for the data this library exists to plot. It is also per-result and unchanging: once read it carries no new information, but it cost the same space every time.
|
|
190
|
+
|
|
191
|
+
Each advisory is now a one-line summary — severity glyph, short label, chevron — expanding to the full text on click. Short labels are keyed on `code` rather than derived from the message, the same rule hosts are given for branching on these, with an unknown code falling back to the message's first sentence so a new advisory still collapses sensibly. The header is a real `<button>`, so keyboard operation, focus order and the accessible name come from the element rather than from hand-rolled key handling. Measured on `examples/geometry.html`: 110px collapsed to 32px, returning 78px of a 346px panel.
|
|
192
|
+
|
|
193
|
+
Deliberately **not** dismissable. The condition is still true, and a dismissed advisory about dies that may be mis-positioned is a wrong map with nothing on screen saying so. Collapsing keeps it permanently visible and permanently one click from its reasoning, which is the part that was missing.
|
|
194
|
+
|
|
195
|
+
- **`examples/statistics.html` had a Summary panel "Overlay / Docked right" toggle that did nothing.** The library has no overlay mode: `SummaryPanelOptions.placement` takes a side and defaults to `'right'`, so both settings rendered an identically docked panel — the demo's own comment described behaviour that no longer exists. Replaced with the real distinction that option offers, `defaultOpen` (showing on load versus waiting for the toolbar's notebook button), which the single-wafer scope had no control for at all. The deep-link scenes that selected the dead mode now select the open/closed state instead.
|
|
196
|
+
|
|
197
|
+
- **The boxplot's "Clip outliers" toggle could draw a whisker past the plot edge, over the row label and axis ticks.** `clipOutliers` narrows the AXIS to a robust fence computed over every row's pooled min/q1/median/q3/max (see `resolveAxisRange`'s own doc comment — it never touches the statistics themselves). A row whose own min or max falls outside that narrowed axis previously still mapped to a real x-coordinate via `xFor`, which has no bound of its own — a value below the clipped low end produced an x position to the LEFT of the plot's left edge, and the whisker line, its cap, and the box outline all drew there, overlapping the row's own label text and the axis tick marks.
|
|
198
|
+
|
|
199
|
+
All five per-row coordinates (min/Q1/median/Q3/max) are now clamped to the plot rect. Q1/Q3/median are clamped defensively, not just min/max: the fence is built from every row's stats pooled together, so a row that sits far from the rest of the population can have its whole span, not only its extremes, fall outside a fence built from the pooled set.
|
|
200
|
+
|
|
201
|
+
A flat whisker cap asserts "this is the exact value", which is false once clamped — so a clamped end draws a small outward chevron instead (open, in the same stroke as the whisker line), reading as "truncated here, continues past this point". This is the same "state it, don't hide it" rule `drawOffAxisLimits` already applies to a spec limit that falls outside the axis, in the same visual family: a mark that points outward rather than a boundary that claims false precision. The un-clamped end keeps its plain flat cap.
|
|
202
|
+
|
|
203
|
+
- **Audited whether "Clip outliers" does anything in the wafer-to-wafer trend chart** (a question, not a bug report that turned out true). It does: `resolveAxisRange` is called identically to the boxplot and histogram, and its axis range changes correctly the moment there is something in the `mean ± σ` population for `robustFence` to exclude (it needs at least 8 finite values, i.e. at least 4 wafers, and a real spread — most short demo lots simply have nothing extreme enough to trigger it, which is why toggling it can look like nothing happened when nothing SHOULD happen). Confirmed with synthetic data carrying a genuine outlier wafer: the hint text reports the excluded count and the rendered axis visibly rescales. No code change was needed here — this is recorded so the same question doesn't get re-investigated from scratch next time it's raised.
|
|
204
|
+
|
|
205
|
+
- **The box plot/histogram/trend axis controls were entirely undocumented.** "Axis includes limits", "Clip outliers", and the box plot's own "Log scale" appear on screen but were never mentioned in `docs/user-guide.md` or `docs/api.md` — a reader had no way to know what they did short of reading the source. The Distributions bullet in §8 now explains the shared pair (kept in sync across all three charts, one row of controls not three), that clipping narrows the AXIS only and never drops a value from a reported statistic, and that Log scale is the box plot's own toggle, not shared with the other two.
|
|
206
|
+
|
|
207
|
+
### Added — checks that hold the layout rules
|
|
208
|
+
|
|
209
|
+
Six ratchets encoding the invariants the coherence pass settled. Every one
|
|
210
|
+
corresponds to a defect that actually shipped or regressed in this release, and
|
|
211
|
+
each was **negative-tested**: the rule was confirmed to FAIL when the invariant
|
|
212
|
+
is broken, not merely to pass today.
|
|
213
|
+
|
|
214
|
+
Five are wiring facts, in `check-overlay-conventions.mjs` beside the existing
|
|
215
|
+
anchor/`document.head`/stacking-context rules — they assert that a decision is
|
|
216
|
+
still made in a named place, because each is a value whose correctness depends
|
|
217
|
+
on context the site itself cannot see:
|
|
218
|
+
|
|
219
|
+
- both renderers' chrome rows paint `CLR.canvasBg` (the map area's background), not the surface they happen to sit on;
|
|
220
|
+
- the chrome row's gap below it is `paddingBottom`, never a margin — the row paints a background and a margin falls outside it;
|
|
221
|
+
- `renderWaferGallery` states `chromeInset: MAP_CHROME_INSET` for its cards, since a card already supplies the outer inset;
|
|
222
|
+
- `renderWaferMap` passes `contentInset: chromeInset` to the Insights tab, so the tab bar and the identity above it share one column.
|
|
223
|
+
|
|
224
|
+
The sixth is in `check-style-scales.mjs`: every `data-wmap-toolbar` element must
|
|
225
|
+
use `RADIUS.control`. A toolbar is a control cluster, not a container, and the
|
|
226
|
+
two build sites had drifted to different roles — which pulled their padding and
|
|
227
|
+
height apart with it, so the same bar rendered 36px in the gallery and 30px in
|
|
228
|
+
every card inside it.
|
|
229
|
+
|
|
230
|
+
That last rule failed its own negative test on the first attempt, in the way
|
|
231
|
+
worth recording: the scope window was too short (`stripComments` blanks comment
|
|
232
|
+
lines rather than removing them, so a well-commented style block pushes its own
|
|
233
|
+
`borderRadius` out of range), and "no `borderRadius` found in range" was treated
|
|
234
|
+
as "nothing to check" — so the rule passed on the exact drift it exists to
|
|
235
|
+
catch. Not finding the property is now a failure in its own right. A check that
|
|
236
|
+
cannot see its subject must say so rather than pass, which is the same defect
|
|
237
|
+
shape as the focus-ring rule whose justification window included the line it was
|
|
238
|
+
meant to judge.
|
|
239
|
+
|
|
240
|
+
### Changed — layout coherence pass
|
|
241
|
+
|
|
242
|
+
A measured audit across the gallery grid, gallery Insights, single map and
|
|
243
|
+
single-map Insights, comparing every band and surface by height, gap, font
|
|
244
|
+
tier, background, border, radius and inset. Seven divergences, each fixed at
|
|
245
|
+
the one place that decides it rather than at the view where it was noticed.
|
|
246
|
+
|
|
247
|
+
- **The toolbar was two components.** The gallery's page toolbar rendered 36px tall with `RADIUS.container`; `renderWaferMap`'s and each gallery card's rendered 30px with `RADIUS.control`. The same cluster of icon buttons had two heights and two corner depths depending on which renderer mounted it. All three are now 30px/`RADIUS.control` — a toolbar is a control cluster, not a container, and two of the three already agreed.
|
|
248
|
+
- **The chrome row's background came from two different rules** — transparent in the gallery, `canvasBg` in the single map. Identical in this repo's demos because the page is already slate, and divergent on a host whose page is white. Both now paint the map area's background.
|
|
249
|
+
- **The gap below the chrome row used two properties and two values** (a 6px `paddingBottom` in one, a 10px `marginBottom` in the other). Now `paddingBottom: SPACE.lg` in both. Padding specifically: the row paints a background, and a margin falls outside it — inside a gallery card a margin showed 10px of the card's white between the slate chrome row and the slate canvas, reintroducing the pale band the background exists to remove.
|
|
250
|
+
- **Single-map Insights was misaligned with its own chrome.** The chrome row sat flush at the map region's edge while the Insights tab bar and content were inset by `EDGE_GUTTER`, so identity and tab labels started on different columns — a visible step on every switch. The chrome row now takes the same gutter. The canvas stays full-bleed deliberately: map area is the priority, and a round wafer wastes the inset anyway.
|
|
251
|
+
- **The identity's vertical padding was asymmetric** (8px top against 6px bottom), a leftover from when it was a standalone header row rather than half of a row shared with the toolbar. The asymmetry pushed it off the toolbar's midline.
|
|
252
|
+
- **Wafer cards were the only bounded surface without elevation.** Toolbar, bin legend, Insights tab band and summary panel all carry `SHADOW.panel`; a grid of flat cards read as a different class of object from the bands directly above them.
|
|
253
|
+
- **The summary panel was the smallest text on screen** at `FONT.body` while holding the densest content, and became the sole outlier once the identity strip was raised. Its inherited baseline is now `FONT.sub`; descendants that set their own size are unaffected.
|
|
254
|
+
|
|
255
|
+
`MAP_CHROME_INSET` is deleted. It existed so a toolbar floating in the map's own corner and a docked summary panel would align on one small column; with the toolbar in a row above the map it aligned with nothing, and its sole remaining user — the panel — now takes `EDGE_GUTTER` like the chrome row it sits under. This is the fourth constant in this release whose justification did not survive the floating toolbar's removal.
|
|
256
|
+
|
|
257
|
+
Not changed, recorded as a judgement: the grid's legend band is 49px and the Insights tab band 30px, so content starts 18px higher in Insights. They hold genuinely different amounts (the legend carries a swatch row and a share bar), and forcing equal heights would pad one artificially.
|
|
258
|
+
|
|
259
|
+
- **Insights content sat 8px further from its band than the grid's cards do, and the Insights view had a different background.** The gap below the tab bar was 18px (a 2px tab margin plus a 16px body margin) against the gallery legend's 10px, so switching views reflowed the content by the difference; the 16px existed to make a naked tab row read as belonging to the content below, a job the band's own border now does. The gap is owned in one place and matches the legend's. Separately, the single map's Insights overlay painted `CLR.panelBg`, which resolves **white** against the canvas's light slate — so opening Insights changed the page's whole ground colour. It now uses `CLR.canvasBg`, the value the canvas itself paints, matching `renderWaferGallery` (whose Insights root is transparent over the same background).
|
|
260
|
+
|
|
261
|
+
- **The gallery's identity strip rendered a tier smaller than everything around it, and could not be raised.** It sat at `FONT.body` while the wafer labels on the cards beneath it were `FONT.sub` — a view's primary identity set smaller than the things it identifies. Raising it appeared to do nothing, because `buildFacetSummaryChips` pinned `fontSize: FONT.body` on its own container, two levels below the caller, silently overriding whatever the mounting surface asked for. That size is now inherited rather than decided there (it has exactly one caller, so it had no business being decided there), and the strip is set to `FONT.sub` where it is mounted — matching `renderWaferMap`'s identity label, so the same fact is the same size in both views.
|
|
262
|
+
|
|
263
|
+
- **The identity did not yield width to the toolbar, so the toolbar overflowed and lost controls.** The identity header kept a `flexShrink: 0` from when it was a full-width row inside a COLUMN, where that meant "keep your height". As an item in the chrome ROW it meant "never give up width", and with the toolbar also refusing to shrink the two simply overran their row: in a 700px expand modal a 261px identity and a 472px toolbar came to 733px, so the toolbar ran 39px past the modal's edge and was clipped — the User guide button vanished off-screen, and every control beside it sat 39px out of place, which is why the Insights toggle appeared to move between views inside the modal. The identity is now `flex: 1 1 auto` with `minWidth: 0`: it is the half that should give, since it already truncates (ellipsis on the label, inline fields collapsing back to the chevron), whereas a clipped toolbar silently loses controls with nothing to show that it has.
|
|
264
|
+
|
|
265
|
+
- **Expand was offered inside the expand modal.** Opening the modal hides the button, but `setInsightsOpen` restored it unconditionally, so toggling to Insights inside the modal brought back a control offering to expand a view that was already expanded — and, appearing in only one of the two views, it shifted the toggle beside it on every switch. It now stays hidden while a modal is open, and the modal's close handler restores it in both views rather than only in the map view.
|
|
266
|
+
|
|
267
|
+
- **`showHelpButton: false` produced a Help button anyway once Insights opened.** The tab row's Help was passed as a fallback "when the toolbar cannot carry it", but the condition was inverted in both renderers — it passed the guide through precisely when Help had been switched OFF. A host that had deliberately disabled Help (tsmap does) got one the moment Insights opened, which is the option silently reversing itself. `renderWaferGallery` now never passes it (its bar is always present and carries Help whenever asked), and `renderWaferMap` passes it only when Help is wanted *and* there is no toolbar to put it in.
|
|
268
|
+
|
|
269
|
+
- **The chrome row painted the host's surface, not the map's.** Inside a gallery card the row sits directly above the canvas, so inheriting the card's white left a pale band across the top of every card where the canvas below paints a light slate. It now uses a new `CLR.canvasBg`, which reads the same `canvas-bg` → `surface` chain `resolveChartCanvasColors` uses for the canvas fill, so DOM chrome beside the map matches what the canvas actually paints instead of whatever surface it happens to sit on. The row also gained a bottom padding: a right-docked summary panel began flush against the toolbar and read as joined to it, since the clearance that used to separate them was removed with the floating toolbar.
|
|
270
|
+
|
|
271
|
+
- **The Insights tab row is ruled top and bottom.** In the gallery it replaces a bordered legend strip when Insights opens, so with only a bottom rule the switch traded a defined surface for one that read as unfinished. It is now a bounded band, with padding keeping the labels off the new top rule.
|
|
272
|
+
|
|
273
|
+
- **The single map got the same chrome row, and the floating toolbar is gone.** The toolbar was `position: absolute` in the map's top-right corner. That cost no layout row, but it meant every full-bleed overlay had to reserve a band so it would not render underneath — and there were **four** such reservations, each a separate copy of the same fact: `insightsTab.el`'s `paddingTop`, the mapless empty state's `paddingTop`, `reserveToolbarClearance` for a top- or right-docked summary panel, and `TOOLBAR_CLEARANCE` passed to `toCanvas` in canvas space rather than CSS. Two constants existed (38px in CSS, 24px on the canvas) purely because the same toolbar was being measured against two frames.
|
|
274
|
+
|
|
275
|
+
The toolbar now sits in a row above the map beside the identity, exactly as the gallery's does, and all four reservations are deleted along with both constants and `reserveToolbarClearance` — whose own comment documented the panel-overlap bug in detail, a bug that can no longer occur. The map is not squeezed by this: the identity row already existed above it, so the toolbar joins a row rather than adding one, and the canvas gains back the 24px it was reserving at the top, so the wafer draws slightly larger than before.
|
|
276
|
+
|
|
277
|
+
**The hover fade is gone too.** The bar sat at `opacity: 0.35` and faded to full on pointer-enter, with a 600ms linger so a click on a fading bar still registered. All of that existed because the bar was painted *on* the wafer, where a permanently solid strip competes with the data beneath it. In its own row it covers nothing, so ghosting only made the controls hard to read and hid, until the pointer happened to cross the map, that they existed at all. Removed outright rather than pinned at full opacity, so no dormant timer or listener is left behind.
|
|
278
|
+
|
|
279
|
+
The toolbar keeps a width bound, now relative to the chrome row rather than the map box: with `flexShrink: 0` it takes its max-content width and would run out of a narrow container, so the cap is what turns overflow into its existing wrap, and the identity beside it is what yields the space. The expand modal reparents the chrome row as a single root instead of pairing the header with the map box, so the identity and the toolbar arrive together and in the right order.
|
|
280
|
+
|
|
281
|
+
- **The gallery's lot identity moved onto the toolbar's row, filling the space beside it.** It had been the first line inside the legend strip, on its own row below the toolbar — 14px of content in a ~46px bordered strip, while the toolbar above it stood 36px tall with the entire left half of its row empty. Two rows to say what fits in one, and the empty half was the most conspicuous thing in the header. The identity is now plain text in the same row as the toolbar — borderless, matching `renderWaferMap`'s identity header, so both views state the same thing the same way rather than one of them boxing it; the toolbar keeps its border because it is a control surface and the identity is not. It is laid out as: `flex: 1` so it takes whatever the toolbar does not need, `minWidth: 0` so it can shrink below its content rather than pushing the toolbar off the row, and the toolbar `flexShrink: 0` so it always wins the space — every control in it must stay hittable at any width, whereas the identity already degrades gracefully through the strip's own "+N more" collapsing.
|
|
282
|
+
|
|
283
|
+
Measured on the docs gallery: the identity content is ~297px and the toolbar ~330px, so they share a 700px viewport with room to spare, and the header shrinks from 143px to 116px in the grid view. **In Insights the whole strip row disappears** — the bin legend has nothing to key against once the cards are gone, and with identity no longer riding in that strip there is nothing else in it — taking the header to 58px. The toolbar also carries `marginLeft: auto`, so a lot with no metadata at all (no pill) still finds it on the trailing edge rather than snapping to the left.
|
|
284
|
+
|
|
285
|
+
The row stretches its items rather than centring them: side by side on one row they read as mismatched unless their boxes are the same height, and centred the identity took its content height (27px) against the toolbar's 36px. Stretch derives the pill's height from the toolbar instead of hardcoding it, so they stay matched if the toolbar's padding or icon size changes, or if it wraps to a second line; the pill centres its own text within that taller box.
|
|
286
|
+
|
|
287
|
+
The bins row lost the top border it used to draw when identity sat above it inside the same strip; there is no longer anything above it to separate from. Two gallery tests moved with the content, from `[data-wmap-gallery-legend]` to the new `[data-wmap-gallery-meta]` — the faceting behaviour they cover (every distinct value of a varying field, then "+N more") is unchanged, it is only queried where it now lives.
|
|
288
|
+
|
|
289
|
+
- **The gallery toolbar is pinned to the right edge**, matching `renderWaferMap`'s toolbar, which is absolutely positioned against its own map box. Keeping the bar visible was not on its own enough to stop the toggle moving: the bar is shrink-to-fit, so when the grid-specific controls hide it collapses from ~330px to ~80px, and while it was left-anchored that dragged every button at its right end ~250px leftward. Pinned, it shrinks away from its right edge rather than toward it, and the surviving controls do not move at all — measured at `right: 1332` in the grid, in Insights, and back again. The identity/legend strip keeps the default stretch and still spans the full width; only the toolbar moved. It also lands the bar on the same column as the card grid's right edge and each card's own top-right toolbar, so the whole view shares one right margin at every width tested (a constant 12px `EDGE_GUTTER`, single row, no overflow, down to 420px).
|
|
290
|
+
|
|
291
|
+
- **The identity header now shows short metadata inline instead of behind the chevron.** Collapsing by default earned its keep when the panel held a lot; it did not when it held one field. On the docs demo the row is ~1350px wide, the label ~100px, and the chevron hid exactly one fact the row had ample room to state (`Product`), costing a click to learn it. Fields whose value the label already states are filtered out — the label is `lot · waferId`, so without that the row would have read "LOT-DEMO · W01 Lot: LOT-DEMO · Wafer Id: W01", restating its own identity and spending the width that decides whether anything fits.
|
|
292
|
+
|
|
293
|
+
The rule is fit, with a count ceiling: at most four fields, and only when they do not clip. Fit alone is not sufficient — twenty short fields would "fit" a wide monitor and turn the identity row into a dense strip harder to read than the label it replaced. Inline and expandable are alternatives, never both: while the fields are inline there is no chevron, no `aria-expanded`, and the whole-row click handler stands down, so the panel cannot offer a second copy of what the row is already showing. A `ResizeObserver` re-tests on resize, so narrowing past the fit falls back to the chevron and widening gives the fields back. Measured on the demo: inline down to a 294px row, chevron at 234px.
|
|
294
|
+
|
|
295
|
+
Fit is decided by asking the layout, not by predicting it. The first attempt summed the label and field widths plus a gap and compared that against the row — and was wrong at the boundary, reporting a fit at exactly the available width while the label was visibly truncated, because the real spacing is the flex container's `gap` AND the inline element's `marginLeft` and only one was in the sum. It now tests `scrollWidth > clientWidth` on the rendered candidate, which is the browser reporting directly that content did not fit its box: no spacing arithmetic to keep in sync, and correct by construction if the gap, margin or font ever change.
|
|
296
|
+
|
|
297
|
+
- **Expanded metadata was unreachable in the expand modal.** The metadata panel is mounted inside `canvasWrap`, so it travelled into the modal with the map, while its toggle — the identity header — was deliberately left behind on the page under the backdrop. The control and the panel it opens ended up in different places, so metadata could be expanded in the map and Insights views but not in the expanded one. The header is now reparented too. The reason it had been left behind (that the toolbar's absolute top-right corner would collide with a header sharing its positioning context) belonged to the old two-root shape where the toolbar resolved against `contentWrap`; it now travels inside `mapBox` and resolves against that, so a header above it shares no positioning context with it. The modal's `title` is dropped in favour of the real header — it would otherwise print the same identity string a second time, one line above — and the dialog's accessible name is set from the same label, so nothing is lost there.
|
|
298
|
+
|
|
299
|
+
`contentWrap`'s flex direction had to be set explicitly as part of this. It is a flex **row**, which is invisible while it holds one child and wrong the moment it holds two: the header has `flexShrink: 0`, so as a row item it took its natural width and stretched to full height, rendering as a 134px full-height column of identity text down the left of the map instead of a row above it.
|
|
300
|
+
|
|
301
|
+
- **The gallery toolbar came back full-width after closing Insights.** `barEl` is created `inline-flex` — a shrink-to-fit pill — and the close path restored it as `flex`, stretching it to the full gallery width, so a compact toolbar was replaced by a full-width bordered box that persisted until reload. One word, and only reachable by toggling Insights and coming back, which is why it survived to be caught here rather than on the way in.
|
|
302
|
+
|
|
303
|
+
- **The boxplot's LSL/USL labels sat on the wrong side of their own lines.** Both were placed inward, so LSL — the *lower* limit — was labelled to its right and USL to its left, putting each label on the side that reads as the opposite bound. They now sit outward, LSL to the left of its line and USL to the right, matching what the names say. The side is chosen by a new `limitLabelSide` in `chartShell.ts` rather than a hardcoded offset, so a label that would run off the plot flips inward instead of being clipped at the edge; it has its own unit tests covering both limits and both edge cases.
|
|
304
|
+
|
|
305
|
+
### Added
|
|
306
|
+
|
|
307
|
+
- **The gallery's bin legend now states the population, not just the colours.** It listed a swatch and a label per bin and nothing else — no counts, no shares, no yield — while each card's own canvas legend has printed a count all along (`toCanvas.ts`'s `LegendSwatch`). That gap mattered because the strip is often the only surface there is: the Summary panel is optional (it only auto-mounts when the host passes `lotStatsSummary` or a wafer carries findings) and Insights is opt-in and off by default, so a gallery rendered without either had **nowhere** stating how the population divides. Each row now reads `1 · Pass 3,394 85.6%`, the caption carries `Yield 85.6% · 3,394 / 3,966 dies` in bin modes, and a single stacked share bar sits under the strip. Yield needs no separate computation and cannot drift from the panel: the legend population already *is* the yield-eligible one and `passBins` is the same array the panel gets, so yield is the pass bins' share of the very denominator the swatches divide up. Metadata mode gets counts and shares too, but no yield — a metadata field has no pass/fail notion, and a yield figure beside one would answer a different question from the swatches under it.
|
|
308
|
+
- **The legend was also counting dies the map doesn't paint.** Its scan skipped `partial` but not `edgeExcluded`, so a bin appearing only on edge-excluded dies got a swatch — advertising a colour that is not on screen, since those dies are drawn in `EDGE_EXCLUDED_FILL`. It now uses the rule `buildView` already applies to its own per-card legend tallies, which is the rule `analyzeWaferMap` (`isYieldEligibleDie`) and the Summary panel use as well. Counts are recomputed from the visible cards rather than read from `lotStatsSummary`, because the gallery can be showing a filtered subset and a pooled total would describe a population that isn't on screen.
|
|
309
|
+
|
|
310
|
+
- **The shared demo fixture (`docs/examples/data.js`) now exercises the pass-rate chart.** It previously had no groupable field at all — `makeWaferConfig` varied only `waferId`, which the facet table deliberately excludes — so no generated demo could reach "Group by", and no die carried a tester verdict for a *parametric* test, so the chart's "Tester flag" mode never appeared. Added: a `processSplit` metadata field alternating POR / Hi-dose across wafers (`PROCESS_SPLITS`, `splitForWafer`), spec limits on Idsat and Ioff so the spec pareto ranks three tests rather than one, and recorded `testPass` verdicts for Idsat and Vth alongside the existing functional Continuity test. Vth's tester verdict applies a guard band 8 mV tighter than the exported spec, and the split arm's +50 mV Vth bias is calibrated so it still passes ~98% of the datasheet limits while passing only ~81% of the tester's own criterion — a split that looks acceptable one way and marginal the other, which is precisely what the disagreement note exists to surface. `makeResults({ waferIndex })` applies the matching bias; the demo pages that build lots now pass it, since a wafer labelled with a split its measurements do not reflect would be worse than no split at all. `tests/demoData.test.mjs` pins the properties the features depend on, so a later tweak to a bias or a limit cannot silently flatten the demo.
|
|
311
|
+
- **The lot summary report can now express a split experiment.** Its "Splits" section was a roster — one row per wafer against `metadata.split` — which compared nothing and only recognised a metadata key literally named `split`, so any other naming (`processSplit`, `implant`, `anneal`) produced no section at all. It is now a **Split Comparison**: facets are discovered with `buildFacetTable`, the same function the Insights "Group by" control uses, so the report offers the comparisons the app offers. Each splittable facet gets the wafer roster, yield per arm, and per-test pass rates per arm for every judgement the data supports — spec limits, tester flag, functional — with the spec-vs-tester disagreement count stated once per facet. Capped at 3 facets, since a load can carry many incidental ones (operator, tester, test date) and past a few the report becomes a cross-product nobody reads. Until now a split experiment could be read on screen and never exported.
|
|
312
|
+
- **Export CSV on the correlation matrix**, emitted long-form (one row per unordered pair: test names, test numbers, r, n) rather than as a square grid that repeats every value twice. `n` is per row because pairwise coverage differs between tests and an `r` without its own `n` is not interpretable. Deliberately the ONLY new per-panel export: boxplot and trend would re-export the per-wafer mean/σ/quartiles the Overview's test-values CSV already carries, and a second button for the same numbers is chrome without information. Requires a host `onSaveText`; no hook, no button.
|
|
313
|
+
- **Per-test pass rate chart, split by group**, in Insights → Overview. The suite could already answer "which *bin* is failing" (bin pareto) and "which group yields worse" (yield chart), but not "which *test* is failing, and does it fail more in one split than another" — the question a split experiment is usually run to answer. One cluster per test, worst first, with a sub-bar per group when "Group by" is active. Three modes, not two, because a parametric test carries **two independent** pass/fail notions: `spec` (the value against its limits) and `testFlag` (the tester's own recorded verdict in `die.testPass` — STDF's PTR `TEST_FLG` bits, which exist whether or not `LO_LIMIT`/`HI_LIMIT` do), plus `functional` for pass/fail-only tests. The two parametric modes can legitimately disagree — guard bands, dynamic or per-site limits, a limits/data mismatch — so they are separate views rather than one collapsed "parametric pass rate", and `TestPassRateData.disagreementDies` counts the dies judged differently (`null`, distinct from `0`, when only one source exists). The card reports that count rather than resolving it: only the reader can tell an expected guard band from a real mismatch. Only the modes the data supports are offered — `hasJudgeableTests` takes the dies for `'testFlag'`, since every parametric test *could* carry a verdict and a definition-only check would offer a mode that renders empty. Backed by `buildTestPassRateData`/`hasJudgeableTests` (`@wafertools/wafermap/stats`), which plot **rates on a fixed 0–100% axis, never counts** — splits routinely have different wafer counts, and a count axis would show the larger split failing more while failing at the same rate. A parametric test with *neither* limits nor a recorded verdict is genuinely unjudgeable and is omitted rather than reported as 100%; a group that never ran a test renders "no data" rather than a 0% bar.
|
|
314
|
+
- **Wafer-to-wafer trend chart** in Insights → Distributions: one point per wafer at its mean for the selected test, ±1σ whiskers, the die-weighted lot mean as a dashed centre line, and spec limits where the test has them. Clicking a point opens that wafer's map on that test, and the capability panel's test selection now drives it alongside the boxplot and histogram. The suite had no trend view at all — the boxplot comes closest but answers a different question (distribution *within* each wafer, with its rows sorted and drilled), while drift across a lot only reads in the population's own order. It therefore has **no sort control by design**: slot order is the entire signal. Backed by `buildTestTrendData`/`trendCentre` (`@wafertools/wafermap/stats`), which follow the same precomputed-first pattern as `buildTestBoxplotData` and keep items with no data in place rather than closing the gap.
|
|
315
|
+
- **Insights Overview now states its population.** A lot's Overview opened straight into the yield chart, naming no wafer count, no die count and no exclusions — the population every chart below it is computed over went unnamed, the same gap the docked lot Summary panel had. It now leads with tiles: wafers, mean wafer yield (labelled *unweighted, per wafer*, matching the panel so the two can't read as contradicting each other) and dies analysed/excluded. A single wafer keeps its existing Yield/Total dies tiles. Suppressed while "Group by" is active, where a whole-population headline would silently disagree with the per-group charts beneath it.
|
|
316
|
+
- **A median reference line on the Insights yield chart** (`ChartPanel.reference`), so a bar can be judged without reading every printed percentage. The docked Summary panel's per-wafer yield bars already marked the lot median; the larger, more prominent chart of the same data had no reference at all. Recomputed after a drill so a group's bars are judged against that group's median, and median rather than mean so one catastrophic wafer cannot drag the reference below every other bar.
|
|
317
|
+
- **`r` and `n` on the scatter panel.** The correlation matrix quantifies every pair and clicking a cell drives the scatter — at which point the strength and sample size vanished, leaving a plot that invites reading a trend into noise. Both are now printed, recomputed over the points actually visible so filtering the legend to one group reports that group's own coefficient. Both are computed through one shared internal formula, so the matrix cell and the scatter card cannot disagree about the same pair. Deliberately **not** exported: the shared helper takes running sums as six positional numbers, which is an internal calling convention rather than something a consumer would reach for, and every stats library already has Pearson r.
|
|
318
|
+
- **Three new sizing tokens: `--wmap-font-size`, `--wmap-density` and `--wmap-font-family`.** Chrome had no host-settable size lever at all — an embedded map pinned wmap's own type scale and spacing regardless of the host's. `--wmap-font-size` (default 12px) drives every tier through derived deltas rather than a token per tier, so a host cannot produce an incoherent scale and there is one thing to document; `--wmap-density` (default 1) scales the spacing steps without touching type, because shrinking type to fit a column is what produced a segmented toggle smaller than the buttons beside it; `--wmap-font-family` (default `inherit`) lets an embedded map take its host's typeface, which it now does automatically. All three reach DOM chrome only — canvas text cannot read a CSS variable — so `fontPx` resolves the size token to a number at draw time and must be kept in step with it.
|
|
319
|
+
- **`CorrelationCell.n`** — the dies contributing to each pair, shown per-cell in the matrix tooltip with the median across pairs in the card's hint. An `r` without its `n` is not interpretable, and the card's "strong pair" count thresholds on |r| alone.
|
|
320
|
+
|
|
321
|
+
### Changed
|
|
322
|
+
|
|
323
|
+
- **`check:api` now pins the cross-repo claims too — the ones most likely to rot unnoticed.** The docs' answer to "how much of this must I learn?" leans on figures whose numerator lives in *another repo*: "tsmap imports **10** of its ~100 exports", "passes **6**" of `RenderOptions`' fields. Nothing on this side changes when tsmap adds an import, so those could drift with no local edit to notice. All four sites are now derived and checked — `docs/api.md`, `README.md`, and the two org surfaces (`wafertools.github.io/docs/index.md`, `.github/profile/README.md`), which spell the figure in words rather than digits and are pinned the same way, since a word ages exactly as badly as a number. Imports are counted as **runtime only**: an `import type` costs nothing at run time and is not what "uses N exports" means to someone weighing the API's size. The option count is brace-matched from the real `renderWaferMap(…)` call rather than regexed, because the object spans dozens of lines and a flat pattern counts nested keys — the same mistake that made the field counts wrong in the first place. Absent sibling repos are **skipped and announced**, never failed: CI clones one repo at a time, and the skip is printed so a green run cannot silently mean "checked nothing".
|
|
324
|
+
|
|
325
|
+
- **`check:drift` now covers all four wafertools repos, not just the pair.** `.github` and `wafertools.github.io` had no checks of any kind, which is backwards: they carry no code, so nothing ever validated them, and between them they are the first thing anyone sees — `github.com/wafertools` renders `.github/profile/README.md`, and the org site renders the site repo's `docs/index.md`. Both are now checked for dead relative links (the bug class that actually happens there — a doc renamed, or a section promised and never written) and for mentioning both projects, since with GitHub Pages there is no funnel: a visitor arrives at the org page, either project's site, or any README, so each surface has to route on its own. A missing sibling repo is a **note, not a failure** — CI clones one repo at a time, and a hard failure there would break every build that isn't a local four-repo checkout.
|
|
326
|
+
|
|
327
|
+
- **`check:styles` gained the font-size floor tsmap's copy already had.** The two repos share this script's mechanism and differ only in the constant, because the rules genuinely differ: `UI_STANDARDS.md` sets 11px here (with an 11px tier for uppercase micro-labels and ornaments), while tsmap sets 12px because smaller text renders poorly on the Windows WebView2 it ships in. The floor is a separate flat check rather than another distinct-value budget, since the question for font-size is not how many sizes exist but whether any is too small.
|
|
328
|
+
|
|
329
|
+
- **The API-size figures in `api.md` were wrong the day they were written, and are now derived.** The reference tells a reader how little of the API they need — "`RenderOptions` has N top-level fields", "tsmap passes 6 of them" — because at ~34,000 words its size otherwise reads as the size of the thing you must learn. Those numbers are the reassurance, so they have to be right, and they were not: **32 and 24 against a real 29 and 21**. Both were counted with a regex matching any indented `name:` line, which also matches the *parameters of a callback signature* declared inside the interface — a mistake invisible in prose and obvious in code, which is the whole argument for deriving a figure rather than reading it once. `scripts/check-api-claims.mjs` now counts fields at exactly one level of indent, checks every place the number is quoted (`npm run check`), and regenerates them at release (`npm version`, alongside the bundle figures).
|
|
330
|
+
|
|
331
|
+
- **Bundle figures are generated at release and checked in between; the README badge was wrong.** It advertised `~40 kB` against a real 44 KB, and — worse — the word "core" meant two different things across the docs: the badge meant the DOM-free root entry, `comparison.html` meant the renderer, so the same page set quoted 40 and 104 for it. All four figures (data layer, renderer, Insights chunk, guide chunk) now live in exactly one table, `docs/performance.md` § Download size, with everything else linking there rather than restating. `npm version` runs `check-bundle-size.mjs --write`, which measures the built `dist/` and rewrites that table plus the README badge and prose line, then stages them — so a release cannot ship a stale number, and nobody has to remember to update one. Between releases the same script still runs as a check, so drift is caught at the commit that causes it. Its tolerance was tightened from 10% to max(1 KB, 3%): 10% is provably too loose, since the 40-vs-44 error that started this is 9% and sailed through.
|
|
332
|
+
|
|
333
|
+
- **The README now names its author and explains the licence in a sentence.** The MIT terms already bind the copyright notice to every copy, including inside closed commercial products, but nothing in the README's visible text said who wrote the library or what MIT means for someone evaluating it — the information existed only in `LICENSE` and `package.json`, which is where a lawyer looks, not a reader.
|
|
334
|
+
|
|
335
|
+
- **The docs now say how little of this API you need, and point at the finished app first.** The reference is ~33,500 words across 118 headings, and nothing in it distinguished the four functions almost everyone uses from the ninety-odd exports almost nobody does — so its *size* read as the size of the thing you had to learn. Measured rather than asserted: §4–§7 (the four core functions) are 74.7% of the file, so the weight is not advanced material but option depth — `RenderOptions` has 32 top-level fields. **tsmap, a complete cross-platform desktop application built on this library, imports 10 of its ~100 exports and passes 6 of those 32 options.** That figure now appears at the top of `api.md`, in the README, and beside both render functions' signatures, because it is the single most useful calibration available and was documented nowhere. §3's overview table is tiered Everyday / Occasional / Rarely-needed instead of listing ten sections as peers. No reference material was removed.
|
|
336
|
+
- **tsmap is now offered as the alternative to integrating at all.** The docs site's landing page, the README, the Quick Start and `llms.txt` all led with "install the library" and mentioned the finished application either in passing or — on the landing page, the Quick Start, the guide and `llms.txt` — not at all. Someone arriving to *look at* wafer data was being routed into writing code. Each of those now opens with the honest question, names what tsmap does (desktop and browser, STDF/ATDF/CSV/JSON/Parquet, parsed locally and never uploaded), and states plainly when to build instead: wafer maps inside your own application, a data source it doesn't read, or behaviour it doesn't offer.
|
|
337
|
+
- **`pearsonFromSums`/`pearsonOfPairs` are internal again.** They were exported earlier in this cycle only because a draft changelog entry claimed they were public — reality was changed to match the prose rather than the other way round. `pearsonFromSums` takes running sums as six positional numbers, which is a calling convention between two call sites in one file, not an API; every stats library already has Pearson r. Unreleased, so nothing depended on them. The matrix and the scatter card still share one formula, which was the actual point.
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
- **The Insights chart suite is no longer in the initial `/render` chunk.** `renderWaferMap` and `renderWaferGallery` imported `createInsightsTab` statically, so chartShell, histogram, correlation, boxplot, scatter, capability, trend, testPassRate and insightsTab — **~25 KB gzipped** — were downloaded by every consumer to render a wafer map, even though `insights` is opt-in and off by default. It is now fetched on first open (`ensureInsightsTab`), exactly the treatment `userGuideHtml` already had, and the initial chunk drops from ~128 KB to **~104 KB gzip (−22%)** — back under the ~106 KB baseline set in August. `tests/bundle-size.test.mjs` gained a static-import guard mirroring the guide's, since a stray `import` would silently undo this; `scripts/check-bundle-size.mjs` now attributes the core, Insights and guide chunks separately instead of counting two lazy chunks as core (run it with `WMAP_CHUNKS=1` for a per-chunk breakdown). **`setInsightsOpen(true)` now returns before the tab's DOM exists** — the toolbar still responds synchronously, and toggling back while the chunk is in flight is honoured, but a caller asserting on the chart DOM immediately after the call must wait for it. See §5.9 in `docs/api.md`.
|
|
341
|
+
Not done, deliberately: `summaryPanel.ts` still statically imports `makeLabeledSelect`/`makeSegmented` from `charts/chartShell.ts`, which keeps a sliver of the chart shell in core. Measured at **1.4 KB gzip** — tree-shaking already discards the rest — so moving two widgets to a neutral module would cost more churn than it returns.
|
|
342
|
+
|
|
343
|
+
- **The `inferred-pitch` geometry advisory is now `severity: 'warning'`, not `'error'`.** Supplying `waferConfig.diameter` without `dieConfig.width`/`height` is a documented, supported input: the pitch is derived as diameter ÷ grid span, which is exact for a map whose grid reaches the wafer edge and only skewed when edge dies are absent. It reports an assumption made on the caller's behalf, unlike `partial-coverage` and `geometry-conflict`, which mean dies really may be mis-positioned — those stay errors. Flagging it in red left every host that legitimately knows only the diameter (tsmap's wafer-diameter setting, which has no die-pitch field) showing a permanent error banner with nothing available to clear it.
|
|
344
|
+
|
|
345
|
+
- **Spacing, radius and type moved onto named scales, enforced by a new `check:styles`.** The library had 2, 3, 4, 5, 6, 8, 10 and 12px radii in use with no rule for which belonged where, and paddings clustered at 2/4/6/8/10/12/16/24px with a long tail of one-off 3, 5, 7, 9, 11, 14, 15, 20, 23 and 32px values. The scales are *derived from what the library already did* rather than imposed — the clusters were the system, the tail was drift — and are now `SPACE`, `RADIUS` (three roles: 4px control, 6px card, full pill) and `FONT` (tiers derived from `--wmap-font-size`). `scripts/check-style-scales.mjs` (wired into `npm run check`, and available alone as `check:styles`) budgets the number of *distinct literal values* per property rather than checking any single site, because that is the shape this class of defect actually has: four definitions of one card frame are each defensible read alone and only wrong in comparison. Off-scale values are not all mistakes — an indent aligning to a 7px status dot is optical, not rhythm — so the rule is to snap deliberately per site and leave a comment where a value is optical.
|
|
346
|
+
- **The bin-cluster chart painted its hover highlight over the bin name.** The highlight spans the full row width and was drawn inside the per-group loop, after the label, so it covered the name whenever the hovered sub-bar overlapped the vertically centred text. It is now painted first. `charts/testPassRate.ts` inherited the ordering from here and was written correct.
|
|
347
|
+
- **`noUnusedLocals` is on, and the 38 unused imports and locals it flagged are gone.** This was not tidying: that noise is precisely what hid two real defects. `drawOffAxisLimits` was imported into the histogram panel and never called, so one of the three distribution charts silently lacked the off-axis limit markers the other two had — a fix reported as complete that had landed in two places out of three. And a complete `yieldSection` builder sat unreferenced in `renderSummaryReport.ts` after a slimmer summary replaced it, so the **wafer report showed only Total dies and Yield** while the lot report showed good/bad die counts and exclusions — the two reports disagreeing about what a summary is. Both are fixed (see Fixed). Also removed: `applyMenuRoles` (superseded by inline role assignment at six call sites — no accessibility gap), `FAMILY_PRIORITY` (superseded by `FAMILY_RES`), and a write-only `rowsPerCol`. `noUnusedParameters` is deliberately left off: deliberately ignored callback arguments are legitimate and would drown the signal again.
|
|
348
|
+
|
|
349
|
+
- **Uncurated metadata keys are labelled with `prettyKey` instead of their raw identifier.** A host field wmap does not know about — which is most of a real host's metadata — surfaced its camelCase name verbatim (`processSplit`, `frameId`) in the Insights "Group by" dropdown and the report's split comparison. Curated labels still win.
|
|
350
|
+
- **`csvField` moved from `canvas-adapter/summaryPanel.ts` to `core/utils.ts`.** It is a pure string function (CSV quoting plus the formula-injection guard) and now has three consumers — the summary panel, the die list and the correlation chart. Reaching it from a chart would have meant importing `summaryPanel` into `charts/`, inverting an existing dependency. Still re-exported from its old path, so no importer breaks.
|
|
351
|
+
|
|
352
|
+
- **Deleted four unreferenced demo CSVs** — `docs/data/dummy-bins.csv`, `dummy-test.csv`, `sample-long.csv`, `sample-stdf-export.csv`. Nothing in the docs, examples or tests loaded them, and as 15–39-die toy grids that span no wafer they would have raised a `partial-coverage` advisory the moment anyone wired one into a demo.
|
|
353
|
+
|
|
354
|
+
- **The distribution panels' axis behaviour is now shared, derived, and honest about what it hides.** Three related changes to histogram, boxplot and wafer-to-wafer trend:
|
|
355
|
+
- **"Axis includes limits" defaults from the data** instead of always starting off. Neither fixed answer was right: off hides how close a distribution runs to its limit, which is the main thing a spec'd test is read for; on squashes the data into a sliver whenever the limits are generous — and generous limits are exactly what a capable process looks like, so the *good* case rendered worst. `shouldIncludeLimitsByDefault` includes them when doing so leaves the data at least a third of the axis. The toggle still overrides, and once set it sticks.
|
|
356
|
+
- **A limit outside the plotted range now gets an edge marker** (`USL 13 mV ↑`) rather than a line drawn off-canvas. Previously it rendered as nothing at all, so a test whose limits sit beyond the axis was indistinguishable from a test with no limits.
|
|
357
|
+
- **A "Clip outliers" toggle** bounds the axis to a Tukey fence (`Q1 − 1.5·IQR … Q3 + 1.5·IQR`), so one wild reading cannot flatten every real value into a single pixel. Deliberately **not** mean ± 3σ: σ is computed from the data including the outlier, so the bound is dragged out by the very value it should fence off, and with more than one outlier it stops excluding anything at all. This clips the **axis only** — no statistic anywhere is computed from a clipped population, since an out-of-spec die is a distribution outlier by construction and excluding it would delete real spec failures from yield and misrepresent capability. Each panel states how many values fall outside the view.
|
|
358
|
+
- All three toggles are **shared across the panels**, the same way the selected test already is; they were per-panel, so one preference had to be set three times for one test.
|
|
359
|
+
|
|
360
|
+
- **Summary-panel sections are collapsible, and three carry a header selector.** Collapsed state is remembered per panel element across re-renders (both render functions begin with `panel.innerHTML = ''`, so DOM-only state was destroyed on every stats update). Selectors: bin breakdown Hard/Soft, region yield Ring/Quadrant, wafer yield Slot/Yield — each deriving its default rather than starting neutral.
|
|
361
|
+
- **Findings moved from the bottom of the panel to directly under the headline stats.** They are the only actionable section and the only one with a badge count, and were reachable only after scrolling past two full test tables.
|
|
362
|
+
- **Ring Yield and Quadrant Yield merged into one Region Yield section**, ring by default with quadrant behind the selector. The two were the same builder with a different `regionBuilder` and consumed eight rows of a 260px column between them; quadrant yield averages over half the wafer and lands within a point or two of the wafer mean on almost every lot, while a genuine asymmetry is already reported as a finding with a significance test behind it.
|
|
363
|
+
- **Bin bars are now in pareto order** — pass bins first (in bin order), then failing bins by descending count — instead of ascending bin number, which buried the dominant failure mode beneath whatever had the lowest code and disagreed with the Insights bin chart (`buildBinParetoData`, count-descending) for the same data.
|
|
364
|
+
- **The panel's test table carries Test / Mean / Ppk / Spec yield only.** It emitted 9 columns, 12 with limits, into a 260px column: four were visible and the rest sat behind a horizontal scrollbar nested inside a vertical one. The full descriptive statistics remain in the CSV export, the summary report, and the Insights boxplot/capability panels.
|
|
365
|
+
- **Added a Ppk column**, read from the same `buildCapabilityData` the Insights capability panel and the summary report use. Ppk rather than Cpk deliberately: Cp/Cpk use the pooled within-wafer stddev, so on a single-wafer panel there is exactly one subgroup and `cpk === ppk` identically — labelling it "Cpk" would name an index the data does not contain — while across a lot Cpk excludes the wafer-to-wafer shift that Ppk includes. The Cpk/Ppk pair is a drift diagnostic and stays in the summary report, which prints all four indices. Ppk is also added to the test-values CSV.
|
|
366
|
+
- **A test's `N` moves into the section title when every test shares it**, instead of repeating an identical value down a column; it stays a column when counts actually differ, which is the case worth seeing. The functional-test table's pass-rate cell no longer repeats the N its own column already shows.
|
|
367
|
+
- **The gallery panel's Lot/Findings tabs are gone — it is one panel.** The tab named "Findings" contained no findings: it listed wafers with a count badge, while the *Lot* tab held the actual lot-level findings list. Worse, both tabs carried a per-wafer list with the same row idiom and different click actions — the Lot tab's Wafer Yield rows highlighted a card in the grid, the Findings tab's rows opened a window. The per-wafer index is now a findings-count badge on each Wafer Yield row, which is one list instead of two and includes wafers with no findings — the case the subset list structurally could not show, and precisely the low-yielding-but-unflagged wafer a triage view exists to surface. The tab's "Findings report" button moves beside the other report buttons. The per-wafer index survives only as the panel's whole content when there is no `lotStatsSummary` at all, since there is then no per-wafer yield series to badge.
|
|
368
|
+
- **Wafer Yield rows now open the wafer, which their `aria-label` already claimed.** The label said "— view wafer" while the gallery wired the click to a card highlight. The row also names its findings count in that label.
|
|
369
|
+
- **The single-wafer panel no longer duplicates the identity header's metadata.** `renderWaferSummaryContent` takes `metadataShownElsewhere`, and `renderWaferMap` passes it whenever `showIdentityHeader` is on (the default) — the header's expandable panel is built from the same `metadataEntries`/`buildCompactMetadataRows` helpers, so the fields were printing twice in a 260px column, authoritative in neither place. The lot panel has always made this call for itself; the wafer panel now matches. With `showIdentityHeader: false` the section returns, since the panel is then the only place the metadata exists.
|
|
370
|
+
- **The compact test-values column set leaked out of the docked panel into Insights.** `buildTestSection` was changed unconditionally, so the Insights Overview's test table — a full-width sibling of the chart grid, with all the room it needs — also lost min/Q1/median/Q3/max/σ/LSL/USL. The column set is now an explicit `columns: 'compact' | 'full'` defaulting to **full**; only the 260px docked panel opts into compact. The CSV export was never affected.
|
|
371
|
+
- **One report instead of two, and every CSV button says what it exports.** The lot panel offered `Summary report` and `Findings report` side by side, but the summary report already contained a Findings section — the real difference was lot-level versus per-wafer findings, which neither name conveyed, so choosing wrong produced a plausible document missing what you wanted. Worse, the pair was asymmetric: `Summary report` was unconditional, `Findings report` appeared only when some wafer had findings, and on the no-`lotStatsSummary` fallback path it was the ONLY report available. The summary report now carries a **Findings by Wafer** section (stating how many of N wafers had any, so the absent ones read as clean rather than unanalysed), the separate button is gone, and the fallback path offers the same summary report — `renderLotSummaryReportHtml` computes `analyzeWaferLot` itself, so it needs no precomputed lot stats and the work stays lazy, on click. Separately, three buttons all labelled `Export CSV` — two of them on adjacent Insights cards — are now `Test values CSV`, `Functional CSV` and `Correlation CSV`. The die list's own export keeps the plain label: it sits alone in a modal headed "Die list", where nothing else could be meant.
|
|
372
|
+
- **The summary report printed every finding, including the ones already merged into another.** The Summary panel and the findings report both drop findings that another finding has claimed as an exact restatement (a soft-bin twin over identical dies; the single pass bin against the yield row), because the claimer's own label already names what it absorbed. The summary report did not — so a wafer with 8 merged twins listed 16 rows, each merged row followed immediately by the bare row it had just absorbed, contradicting the merge the label described. The rule now lives once, as `visibleFindings` (`@wafertools/wafermap/stats`), used by all three surfaces.
|
|
373
|
+
- **The die list omitted functional tests entirely.** `resolveTestColumns` filtered to parametric tests, copying the rule that keeps functional results out of parametric *statistics* — where a mean of a pass/fail outcome is meaningless. A raw per-die table is not a statistic: it is where you go to answer "why did this die fail?", and the tester's functional verdict is frequently the answer. The column builder already knew how to render a verdict; it was simply never handed a functional test. Verdicts now read through `getTestPassStatus`, so the legacy 0/1-in-`testValues` form renders as PASS/FAIL instead of a bare `1`.
|
|
374
|
+
- **The die list CSV embedded units in every cell**, so a test column read `300 mV` and was text to a spreadsheet — unsummable, unplottable, unfilterable, and with per-value SI scaling capable of putting `300 mV` and `1.2 V` in the same column. The unit moved to the column header (`vth (mV)`) and the CSV emits the bare stored number. The on-screen table keeps its SI-formatted cells.
|
|
375
|
+
- **The per-test CSV exports stamped the whole wafer-metadata blob as "identity" columns.** They used `waferKeys: 'auto'`, so a host mapping an STDF header into wafer metadata got its WCR geometry — `Center X`, `Center Y`, `Die Ht`, `Die Wid`, `Pos X`, `Pos Y`, `Wafr Siz`, `Wf Flat`, `Wf Units`, `Job Rev`, `Tester Type` — as constant leading columns on every row: fifteen columns before the first statistic, none of which identified anything. `MetadataKeySelection` gains `'identity'` (the curated `DEFAULT_FACET_CURATION` keys — lot, wafer, product, program, split, operator, date), and the per-test exports use it. The die list keeps `'auto'`, being a raw-dies dump where full context is the point.
|
|
376
|
+
- **`CsvExportContext.populationLabel` was documented as emitting a `Population` column and never did** — it only ever reached the on-screen section title. A pooled export therefore showed one large N with nothing saying it spanned several wafers, reading exactly like a single wafer's data. Now emitted, including for a mixed lot with no common metadata, where it is the only honest thing the file can say about its population.
|
|
377
|
+
- **`Spec Yield N` duplicated the `N` column** on every row whenever the two agreed, which is the usual case. Kept only when some test's spec population genuinely differs from its value count.
|
|
378
|
+
- **The gallery's bin legend never said whether it was showing hard or soft bins.** Hard and soft bins are independent number spaces, so a row of bare "Bin 3" swatches is genuinely ambiguous about which population it describes. A card's own on-canvas legend has always carried a "Hard Bin"/"Soft Bin" title from `buildMapTitle`, but that legend is suppressed below `BIN_LEGEND_MIN_CANVAS_W/H` — at gallery card sizes the shared lot-level strip is frequently the only legend on screen, and it was the one without a label. It now leads with the bin space, tracking the plot mode. Metadata mode gains the same treatment, naming the field it is keyed on (resolved exactly as `buildMapTitle` resolves it for a card title, so the two cannot disagree).
|
|
379
|
+
- **The Summary panel used native `title` tooltips instead of the library's own.** Six sites in `summaryPanel.ts` (findings rows, the supporting-findings chevron, the severity chips, and the new per-wafer findings badge) set a `title` attribute — the browser's OS tooltip: slow to appear on a hover delay, in an unthemed system font and colour, and unaware of the app's overlay stacking. Every hover surface built through `createToolbarHelpers` already used the shared instant dark tooltip, and `maplessSummary.ts` had converted away from `title` for exactly these reasons, with a comment recording why — but the helper it wrote stayed local to that file and the panel never got it. `wireHoverTooltip` is now `wireTooltip` in `toolbar.ts`, and the panel, `renderWaferMap`'s header expand button and `renderWaferGallery`'s card expand button all use it. It accepts a fixed string, a getter (for a hint that flips with control state), or nothing at all — in which case it live-reads the element's `aria-label` at hover time, the same thing `makeBtn` does, so a control that relabels itself (expand ⇄ reattach) needs no re-wiring. `asDataPoint` keeps the histogram-bar behaviour (tab stop, `role="img"`, `aria-label`) for elements that are not already controls; it is deliberately not applied to buttons, which would gain a nested focus stop and lose their own accessible name.
|
|
380
|
+
- **The pop-out window title bar and disabled menu rows used native `title` too.** The Minimize / Print / Maximize / Close buttons on every `openModal`/`openFloatingWindow` title bar — the Summary report, die list and user-guide windows, and any detached gallery card — plus the hover hint on a greyed-out dropdown row (e.g. "Available on hard/soft bin maps" on a bin-only overlay while in value mode). That last one is the worst case for an OS hover delay: the hint is the entire reason the disabled row stays on screen instead of being omitted. All now use `wireTooltip`.
|
|
381
|
+
- **Maximize and Close never told a screen reader their keyboard shortcuts.** They carried `title="Maximize (F)"`/`"Close (Esc)"` alongside a shorter `aria-label` of just "Maximize"/"Close", so the shortcut existed only in the visual hover hint. The shortcut is now in the accessible name, matching `makeBtn`'s convention (its tooltip is the `ariaLabel` verbatim).
|
|
382
|
+
- **The shared tooltip could obscure the element it was describing.** `positionTooltip` took the anchor element but used it only to resolve an overlay root — placement came from the cursor point alone, and because the box starts above the cursor (`clientY - 8`) it then extended down across the row, bar or button being pointed at. It now avoids the anchor's own box, flipping below it (or above, when there is no room below). Only for anchors up to 40% of viewport height: the map canvas is itself the anchor for die hover, where overlap is unavoidable and displacing the tooltip clear of the whole canvas would be far worse, so a large anchor keeps the previous cursor-following behaviour exactly.
|
|
383
|
+
- **The findings Kind/Region dropdowns appear only from 8 findings up** (or whenever a filter is active), having previously cost two rows of a 260px column above a list of four items.
|
|
384
|
+
|
|
25
385
|
## [0.26.1] — 2026-08-28
|
|
26
386
|
|
|
27
387
|
### Fixed
|