@bezda/rhp-mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +20 -5
  2. package/bin/rhp-mcp.js +5 -1
  3. package/package.json +3 -3
  4. package/skill/SKILL.md +116 -26
  5. package/skill/recipes/area.html +6 -0
  6. package/skill/recipes/bar.html +9 -1
  7. package/skill/recipes/box-plot.html +9 -1
  8. package/skill/recipes/bubble.html +6 -0
  9. package/skill/recipes/bullet.html +6 -0
  10. package/skill/recipes/candlestick.html +9 -1
  11. package/skill/recipes/column.html +18 -5
  12. package/skill/recipes/dial.html +349 -0
  13. package/skill/recipes/diverging-bars.html +6 -0
  14. package/skill/recipes/donut.html +9 -1
  15. package/skill/recipes/dumbbell.html +6 -0
  16. package/skill/recipes/gantt.html +17 -5
  17. package/skill/recipes/grouped-bars.html +6 -0
  18. package/skill/recipes/heatmap.html +9 -1
  19. package/skill/recipes/histogram.html +9 -1
  20. package/skill/recipes/line.html +7 -1
  21. package/skill/recipes/live.html +6 -0
  22. package/skill/recipes/log-line.html +493 -0
  23. package/skill/recipes/lollipop.html +6 -0
  24. package/skill/recipes/multi-line.html +10 -5
  25. package/skill/recipes/pyramid.html +9 -1
  26. package/skill/recipes/race.html +6 -0
  27. package/skill/recipes/radial-bars.html +6 -0
  28. package/skill/recipes/scatter.html +23 -4
  29. package/skill/recipes/silhouettes.html +354 -0
  30. package/skill/recipes/slope.html +23 -4
  31. package/skill/recipes/sparklines.html +7 -1
  32. package/skill/recipes/stacked-100.html +6 -0
  33. package/skill/recipes/stacked-bars.html +6 -0
  34. package/skill/recipes/strip.html +9 -1
  35. package/skill/recipes/unit-stack.html +276 -0
  36. package/skill/recipes/violin.html +6 -0
  37. package/skill/recipes/waffle.html +6 -0
  38. package/skill/recipes/waterfall.html +6 -0
  39. package/skill/references/api.md +150 -22
  40. package/skill/references/design.md +303 -326
  41. package/skill/references/environments.md +11 -4
  42. package/skill/references/forms.md +41 -4
  43. package/skill/references/interaction.md +7 -2
  44. package/skill/references/looks.md +267 -0
  45. package/skill/references/performance.md +143 -0
  46. package/skill/references/pitfalls.md +52 -7
  47. package/src/check/README.md +33 -3
  48. package/src/check/browser.js +11 -5
  49. package/src/check/findings.js +15 -1
  50. package/src/check/index.js +182 -31
  51. package/src/check/inpage.js +82 -18
  52. package/src/check/interact.js +1 -19
  53. package/src/check/network.js +168 -0
  54. package/src/check/png.js +18 -0
  55. package/src/check/report.js +4 -1
  56. package/src/check/selection.js +123 -0
  57. package/src/guard/rhp.js +4 -0
  58. package/src/guard/rules.js +1 -1
  59. package/src/server.js +9 -8
@@ -968,6 +968,9 @@ In `towers-chart.css`, the two font stacks become `var(--font-archivo), system-u
968
968
  - The server's HTML has no chart, so the content below it moves down once the chart is drawn; give the parent a `min-height` if that matters.
969
969
  - Development: StrictMode (on by default in the App Router) leaves one chart, and Fast Refresh updated the page after an edit to every file, without a reload (checked).
970
970
  - Keep the data out of `components/towers/`: a `towers.ts` beside `Towers.tsx` broke the build, because macOS and Windows ignore case in file names and `import("./Towers")` found the data file (TS1149, checked).
971
+ - create-next-app's own page pads its `main` with `px-16` at every width, which leaves the chart 262px of a 390px phone and 192px of a 320px one.
972
+ When you add the chart to that page, let its box break out of the padding on phones: `<div className="-mx-12 self-stretch sm:mx-0">` around it (the `main` is a flex column, so the stretched box takes the margins back).
973
+ Measured in Next.js 16's starter: 358px at 390, 288px at 320, no sideways scroll, and the same box as before from 640px.
971
974
 
972
975
  **Pages Router** (`pages/`): a page may call `dynamic` with `ssr: false` itself.
973
976
  `Towers.jsx` is section 7's file with the wrapper imported from `@/lib/rhp/RhpChart.jsx` and no CSS import.
@@ -1531,13 +1534,17 @@ document.head.insertAdjacentHTML("beforeend", `
1531
1534
  Give the poster's root its own font, color, `letter-spacing` and `text-align`, and your own elements inside it their own sizes and margins.
1532
1535
  2. Scoped component styles never reach the chart (Vue `scoped`, Svelte, Astro, Angular's emulated encapsulation): rhp makes the chart's elements, not the component's template.
1533
1536
  Use a plain CSS file, imported where section 2's table says.
1534
- 3. Inside the chart, style with the slat type's `css` and the Chart's `theme` only: page CSS cannot change rhp's blocks (api.md, section 11).
1537
+ 3. Style regular slat blocks with the slat type's `css` and the Chart's `theme`: page CSS cannot change their protected appearance (api.md, section 11).
1538
+ The experimental [ManyDots collection](api.md#manydots-experimental-point-collection) instead accepts application classes and `pointStyle` for point appearance while retaining its geometry guard.
1539
+ In frameworks with scoped component CSS, put those application classes in the plain CSS file too.
1535
1540
  4. Only Solid draws the chart on the server (SolidStart, Astro islands).
1536
1541
  Everywhere else the server sends an empty box and the browser draws the chart, so the content below moves down once; give the box a `min-height` if that matters.
1537
- 5. macOS and Windows ignore case in file names: `towers.ts` beside `Towers.tsx` made `./Towers` load the data file (checked in Next.js).
1542
+ 5. Data the app loads (a fetch, a Refresh button, a feed) has three more states than the chart: loading, failed and empty.
1543
+ Draw each in the chart's place, at the chart's height: a quiet "Loading…", a line that says what failed with a way to try again, and a sentence for no data ("No activity yet"), never an empty plot, an axis of 0.2, 0.4…, or a headline computed from nothing.
1544
+ 6. macOS and Windows ignore case in file names: `towers.ts` beside `Towers.tsx` made `./Towers` load the data file (checked in Next.js).
1538
1545
  Give such files different names or folders.
1539
- 6. In an app that is not a Solid app, import everything from `@bezda/rhp/standalone` and nothing from `solid-js` (api.md, section 2).
1540
- 7. Solid's html template compiles each template with `new Function`, so a page whose Content-Security-Policy forbids `unsafe-eval` cannot draw html-template charts: allow it for that page, or use a Solid app with JSX, which needs no eval.
1546
+ 7. In an app that is not a Solid app, import everything from `@bezda/rhp/standalone` and nothing from `solid-js` (api.md, section 2).
1547
+ 8. Solid's html template compiles each template with `new Function`, so a page whose Content-Security-Policy forbids `unsafe-eval` cannot draw html-template charts: allow it for that page, or use a Solid app with JSX, which needs no eval.
1541
1548
 
1542
1549
  ## 15. What was verified, and the versions
1543
1550
 
@@ -1,11 +1,12 @@
1
1
  # Choosing the form: data, story, slat and recipe
2
2
 
3
3
  Use this file in step 3 of the workflow, after the brief.
4
- It goes from what the reader should see, to the form, to the slat that draws it, to the recipe to start from.
4
+ It goes from the subject and what the reader should see, to the form, to the slat that draws it, to the recipe that has the technique.
5
5
  When the user named a form ("a donut", "a gantt"), use it, and start at the recipe catalog below.
6
6
 
7
7
  ## Contents
8
8
 
9
+ - Start from the subject's own form
9
10
  - Start from the reader's question
10
11
  - The compositions rhp charts are made of
11
12
  - The recipe catalog
@@ -13,6 +14,31 @@ When the user named a form ("a donut", "a gantt"), use it, and start at the reci
13
14
  - Combining recipes
14
15
  - What rhp does not draw, and the closest answer
15
16
 
17
+ ## Start from the subject's own form
18
+
19
+ Before the data's shape, look at the subject's.
20
+ A reader already knows what it looks like, and a chart that takes that form is recognized before a word is read.
21
+ rhp draws all of these; the recipe named has the technique, not the design.
22
+
23
+ | The subject has | The form that carries it | Technique |
24
+ |---|---|---|
25
+ | a cycle that turns (hours of a day, days of a week, months, seasons, a rotation) | a ring or a dial with the cycle around it, or a calendar grid | `dial` (arcs around a 24-hour face), `donut` (wedges from shares), `radial-bars` (arcs), `heatmap` (a grid of Cells) |
26
+ | things known by their outline (buildings, mountains, animals, ships, bottles) | each one drawn in its own outline, to scale, on one ground line | `silhouettes` (a `shape()` per row on a vertical Bar) |
27
+ | units a reader can count (people, seats, days, coins, floors) | one small mark per unit, or a bar cut into units | `waffle` (Cells, a picture in each), `unit-stack` (a Bar cut by a mask) |
28
+ | something that fills (rain, fuel, a battery, a budget, a quota) | a vessel up to the top of the scale, with its content inside | `column` (a tube and its water), `bullet` (bands and a target) |
29
+ | a level things rise above or sink below (sea level, ground, zero, par, a freezing point) | bars or lines hanging from, or standing on, that one line | `diverging-bars`; in a vertical chart, values below the level are negative and hang from it |
30
+ | a route or a sequence in space (stops, stages, a course, a river) | one line with its stations in order | `stacked-100` (its key is a metro line), `gantt`, `slope` |
31
+ | a document the reader knows (a receipt, a ledger, a timetable, a scoreboard, a league table, a menu) | the chart laid out as that document, its lines the slats | `lollipop`, `waterfall`, `sparklines`, `live` |
32
+ | a place (states, districts, seats in a hall, a pitch, a shelf) | a grid of tiles that echoes the place | `heatmap`'s composition (a Plot of rows, each a Plot of Cells) |
33
+ | an instrument that measures it (a thermometer, a gauge, a ruler, a clock, a scale) | the instrument's own scale and pointer | a `Scale` drawn your own way, a Tick as the pointer, `radial-bars` |
34
+
35
+ - The native form is the hero, and the numbers still have to be read: print each value on or beside its mark, since a ring, an outline or a tile is read less exactly than a bar on a shared baseline.
36
+ - When an exact comparison is the whole point, keep bars from one baseline and let the subject show in how the marks are drawn (design.md section 2).
37
+ - When the subject has no form of its own (abstract values, quarters of a year, A against B), the form comes from the reader's question below, and the idea from what the numbers do: the gap, the turn, the outlier.
38
+ - A ring of the parts of a day reads as a clock: a part drawn from midnight to 9 is read as "from midnight to 9".
39
+ Put each part where it happens in the day, or keep the ring in order of size and leave the hour numerals off it.
40
+ - Never bend the data to fit the form: a ring is for parts of a cycle or of a whole, an outline is scaled in one direction only, and units are all one size.
41
+
16
42
  ## Start from the reader's question
17
43
 
18
44
  Write the one question the chart answers before you pick a form.
@@ -33,6 +59,7 @@ The headline will be its answer.
33
59
  | see progress toward goals | a share of a target per goal | radial bars, or bullets | `radial-bars`, `bullet` |
34
60
  | see actual against target | actual, target, quality bands | bullet | `bullet` |
35
61
  | follow one measure over time | a value per date | line | `line` |
62
+ | follow a measure that grows by multiples, and its falls | a value per date, over a wide range | a line on a log scale | `log-line` |
36
63
  | compare a few measures over time | a value per date per series | multi-line | `multi-line` |
37
64
  | see a quantity's volume over time | a value per hour or day | area | `area` |
38
65
  | see change between two dates per item | two values per item | slope | `slope` |
@@ -48,6 +75,9 @@ The headline will be its answer.
48
75
  | see two measures and a size | three values per item | bubble | `bubble` |
49
76
  | see a value over two categories | a grid of values (day by hour) | heatmap | `heatmap` |
50
77
  | see two groups across ordered bands | two values per band | pyramid | `pyramid` |
78
+ | see things known by their shape at their size | one value per item, each item with an outline | silhouettes to scale | `silhouettes` |
79
+ | see how a day or another cycle splits | parts that add up to the cycle | a dial | `dial` |
80
+ | follow one part of a total over periods, and count it | parts per period that add up | unit stacks with the part at the foot | `unit-stack` |
51
81
  | see a schedule and its progress | tasks with start, end, progress | gantt | `gantt` |
52
82
  | follow prices with their range | open, high, low, close per day | candlestick | `candlestick` |
53
83
 
@@ -56,19 +86,21 @@ Use a donut only for two to six parts with one or two of them the story; otherwi
56
86
 
57
87
  ## The compositions rhp charts are made of
58
88
 
59
- Every rhp chart is one or more Plots of slats inside a Chart.
89
+ The slat compositions below use one or more Plots inside a Chart.
90
+ For bulk scatter points, an experimental [ManyDots collection](api.md#manydots-experimental-point-collection) can instead sit directly inside a Chart with `cross`, without a Plot or per-point slat.
60
91
  These are the patterns the recipes use; recognizing them lets you build a chart no recipe has.
61
92
 
62
93
  1. **One value per slat**: an edge Label for the name, a Bar from 0 (or `from`) to the value, a value Label at its end.
63
- Recipes: `bar`, `column`, `lollipop` (a thin Bar and a Dot), `histogram`, `waterfall` (Bars from the running total before to the one after).
94
+ Recipes: `bar`, `column`, `lollipop` (a thin Bar and a Dot), `histogram`, `waterfall` (Bars from the running total before to the one after), `silhouettes` (a Bar wearing its row's `shape()`).
64
95
  2. **Several values on one slat, same scale**: several blocks in the same slat, each at its own value.
65
- Recipes: `dumbbell` (two Dots and the Bar between them), `bullet` (band Bars, the actual Bar, a target Tick), `box-plot` (Bars for whiskers and box, Ticks for the median), `candlestick` (a thin Bar for the wick, a wide one for the body), `gantt` (planned, done and late Bars), `pyramid` (a Bar to each side of zero).
96
+ Recipes: `dumbbell` (two Dots and the Bar between them), `bullet` (band Bars, the actual Bar, a target Tick), `box-plot` (Bars for whiskers and box, Ticks for the median), `candlestick` (a thin Bar for the wick, a wide one for the body), `gantt` (planned, done and late Bars), `pyramid` (a Bar to each side of zero), `unit-stack` (a Bar to the picked part and a Bar from it to the total, each cut into coins by a mask).
66
97
  3. **Parts of each slat**: a Plot inside the slat, one inner slat per part.
67
98
  An `overlap` inner Plot puts the parts in one band, which makes a stack (`stacked-bars`, `stacked-100`, with `stackUp()` and `shares()`); an inner Plot without `overlap` lays the parts side by side across the band (`grouped-bars`).
68
99
  4. **A grid**: a Plot of Cells inside each slat, one Cell per column of the grid.
69
100
  Recipes: `heatmap` (days by hours), `waffle` (ten slats of ten people).
70
101
  5. **Points on two axes**: `cross={[min, max]}` on the Chart adds a second axis across the slats; an `overlap` Plot's slats then share the whole plot, and a Dot or Label takes `at` (along the scale) and `cross` (across it), a Line takes `[x, y]` points.
71
102
  Recipes: `scatter`, `bubble` (Dot size from a value), `line`, `multi-line`, `area` (a filled Line), `slope` (a Line of two points per item).
103
+ `ManyDots` is an explicit alternative for bulk points with individual colors, sizes, shapes, classes, and styles; keep `Dot` for rich point content and Plot's automatic interaction features.
72
104
  6. **Every value of a group in its band**: an `overlap` Plot of Dots inside each group's slat.
73
105
  Recipe: `strip`.
74
106
  7. **A distribution's outline**: an Area from `density()` points, `mirror` for violins.
@@ -86,6 +118,7 @@ Inside any slat, add ordinary elements where the story needs them: an icon or a
86
118
  ## The recipe catalog
87
119
 
88
120
  Each recipe is a complete page in `recipes/<name>.html` with a designed poster, fixed data and one interaction.
121
+ The comment at the top of each says what to take (its technique) and what to leave (its design, made for its own subject).
89
122
  The interaction listed is the recipe's; interaction.md has the others.
90
123
 
91
124
  | Recipe | Shows | Composition | Interaction |
@@ -115,6 +148,10 @@ The interaction listed is the recipe's; interaction.md has the others.
115
148
  | `violin` | full distributions of groups | 7, `density()` | a switch that morphs the shapes |
116
149
  | `strip` | every value of a few groups | 6 | readout of a dot |
117
150
  | `pyramid` | two sides of a population by age | 2 | an age picked lights everyone that age or older |
151
+ | `silhouettes` | ten towers to scale, each in its own outline, on one street | 1, vertical, `shape()` per row | readout of the tower picked, its level carried to the tallest |
152
+ | `dial` | the parts of a day around a 24-hour dial | 10 + 9 | a hand that turns to the part picked, a readout in the hub |
153
+ | `log-line` | ten years of a price on a log scale with a line at each doubling, its falls shaded | 5 (the falls as one filled Line) + 8 + 9, log scale | a crosshair readout by pointer, tap, drag or keys |
154
+ | `unit-stack` | monthly spending as piles of coins, one part at the foot of every pile | 2 (the picked part and the rest), units by a mask | a picker of parts |
118
155
  | `gantt` | tasks over weeks, progress, today | 2 + 8 + 9 | readout of a task and its dependencies |
119
156
  | `candlestick` | daily open, high, low, close | 2 + 8 | a crosshair and price readout |
120
157
  | `sparklines` | a table of trends, one per slat, each scaled to itself | 1 + a Line in each slat + 8 | a year scrubber (pointer, tap, drag; a keyboard slider) |
@@ -142,9 +142,11 @@ function RainChart() {
142
142
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
143
143
  setOn(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
144
144
  };
145
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
146
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
145
147
  return html`
146
148
  <${Poster} title="City A gets more than three times City D's October rain" note="Illustrative data."
147
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
149
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
148
150
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
149
151
  <p class="readout" aria-hidden="true">${() => (on() == null ? "Tap or point at a city." : say(on()))}</p>
150
152
  <${Chart} scale=${[0, 200]} ticks=${[0, 50, 100, 150, 200]} label="Rain in October, in mm">
@@ -173,6 +175,8 @@ render(() => html`<${RainChart} />`, document.getElementById("chart"));
173
175
  rhp fades the Bar's `color` itself; the opacity changes at once, because a `transition` on a block would replace rhp's own (rule 7 in section 11).
174
176
  - **The readout** is one line of fixed height, so the chart under it never moves; keep its text short enough for 390px.
175
177
  It is `aria-hidden` because each slat's `aria-label` says the same to a screen reader.
178
+ - **Where it sits is part of the design.** A band above the chart is this example's choice, not a rule.
179
+ The readout can be the big number of the composition, a line in the poster's rail or title block, or a note in the slat itself, as long as it keeps its size when its text changes (design.md, sections 2 and 6).
176
180
  - **Next to the slat.** The readout can sit in the slat itself: `<${Show} when=${() => d.on}><${Label} at=…>…<//><//>` draws it in that one slat only (the pyramid and histogram recipes).
177
181
  Do that when the chart is taller than about half a phone's screen, or pin the readout to the screen while the chart is in view: `position: sticky; bottom: 0` on a readout after the chart, with `z-index: 2`, the paper as its background and a hairline on its top edge (`box-shadow: 0 -1px 0 #d9d4ca`), so it reads as a strip laid over the chart.
178
182
  A readout above the chart pins (`top: 0`, and the hairline on its bottom edge) only while a pick exists, with a class such as `.readout.picked`: pinned at rest, it covers the plot's top as soon as the reader scrolls to the chart.
@@ -181,6 +185,7 @@ render(() => html`<${RainChart} />`, document.getElementById("chart"));
181
185
  Checked: the checker reported a change for each slat it pointed at (City A, City C, City F) and tapped (City A, City F); its Tab reached City A and ArrowDown moved focus to City B.
182
186
  Playwright with a mouse: at rest City A was lit and the readout gave the hint; on City C's bar the readout named City C, City C was lit, and the five others were at opacity .3 with rhp's transition still on their bars; from City F down onto the axis the readout stayed on City F; leaving the poster cleared it all.
183
187
  With focus on City A, the mouse over City D took the readout, and leaving the poster gave it back to City A.
188
+ Off the chart, in Chromium and WebKit: from City C onto the headline the readout went back to the hint, and with City A clicked, from City D onto the readout it went back to City A; the checker reported nothing.
184
189
  At 390px with touch: a tap on City B kept the readout after the finger lifted, a scroll that started on City E moved the page and left the readout on City B, a tap on City E's number picked City E, and a tap on the headline or below the poster cleared it.
185
190
  With slats 110px thick (a chart taller than a 390 × 664 screen), a readout pinned with `position: sticky` and `z-index: 2`, after the chart or above it, showed City B on screen, and City E after a scroll, with its hairline edge; without `z-index: 2` the slats covered it.
186
191
  Scrolled to the chart before any pick, the readout above it that pins only while a pick exists was out of the way, and one pinned at rest covered the plot's top.
@@ -384,7 +389,7 @@ const WEEKS = VISITORS.map((v, i) => i + 1);
384
389
  const X = [1, 52];
385
390
 
386
391
  // Slat types: the line, and the week picked (a hairline and a dot) on an overlay Plot
387
- const Trend = (d) => html`<div><${Line} points=${() => d.points} /></div>`;
392
+ const Trend = (d) => html`<${Line} points=${() => d.points} />`;
388
393
  const Picked = (d) => html`
389
394
  <div>
390
395
  <${Tick} at=${() => d.week} thick=${1} color="muted" />
@@ -0,0 +1,267 @@
1
+ # Looks: eight written out in full
2
+
3
+ Eight looks for an editorial poster, each written out in full: tokens, fonts, paper texture, type and one ornament.
4
+ Each goes on top of the base CSS and reads the tokens of design.md section 1, and each was tested on two recipes with the checker at 1280px and 390px.
5
+
6
+ They are worked examples, not a menu.
7
+ A look picked because it matches the topic (newsprint for anything in the news, a dark screen for anything technical) gives the chart the look every chart on that topic gets, and a reader sees the template.
8
+ A look is also only the palette and the type (decisions 4 and 5 of design.md section 2): the idea, the marks and the composition are not in it.
9
+ So write the look for your subject, and take from these what is hard to get right: paper and ink values that pass the contrast floors, a texture, the CSS of a key or a button group.
10
+ design.md section 3 says how a look goes onto a page.
11
+
12
+ | Look | The object it is taken from | Tested on |
13
+ |---|---|---|
14
+ | Broadsheet | a newspaper's front page | bar, diverging-bars |
15
+ | Swiss grid | a 1960s typographic poster | stacked-100 (light and dark), stacked-bars |
16
+ | Field guide | a naturalist's plate | column, waffle |
17
+ | Luxury magazine | a fashion monthly's feature page | donut, waterfall |
18
+ | Sports page | a matchday programme | race, grouped-bars |
19
+ | Scientific figure | a journal's figure | pyramid, sparklines |
20
+ | Night instrument | a cockpit display after dark | heatmap, area |
21
+ | Photocopied zine | a stapled fanzine | lollipop, radial-bars |
22
+
23
+ ## Broadsheet
24
+
25
+ Newsprint with a fine halftone, black ink and one spot blue, a condensed serif headline under the masthead's double rule.
26
+ Marks: flat, square-ended bars (`--rhp-radius: 0px`), the story in the spot blue, a second series in orange, the rest grey.
27
+
28
+ ```html
29
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Libre+Franklin:wght@400..800&family=Noto+Serif+Display:wdth,wght@62.5..100,500..800&display=swap">
30
+ ```
31
+
32
+ ```css
33
+ /* Look: Broadsheet */
34
+ :root {
35
+ --page: #d5d3cc; --paper: #f3f2ed; --ink: #151515; --soft: #64625c; --rule: #d3d1ca;
36
+ --accent: #1d58a8; --s2: #ca4a07; --s3: #262626; --quiet: #868580;
37
+ --display: "Noto Serif Display", Georgia, "Times New Roman", serif;
38
+ --text: "Libre Franklin", "Franklin Gothic Medium", Arial, sans-serif;
39
+ }
40
+ .poster { border-top: 6px solid var(--ink); box-shadow: inset 0 3px var(--paper), inset 0 4px var(--ink);
41
+ background-image: radial-gradient(rgb(0 0 0 / .05) .6px, transparent .9px); background-size: 3px 3px; }
42
+ .poster .kicker { letter-spacing: .1em; }
43
+ .poster .headline { font: 700 condensed clamp(42px, 6.5vw, 62px)/.96 var(--display); max-width: 16em; }
44
+ .poster .dek { font-size: 18px; color: var(--ink); }
45
+ .poster .note { border-top-color: var(--ink); }
46
+ .poster .keys { font-size: 13px; font-weight: 600; }
47
+ .poster .choice button { border-color: var(--ink); font: 700 12px/1 var(--text); letter-spacing: .08em; text-transform: uppercase; }
48
+ ```
49
+
50
+ ## Swiss grid
51
+
52
+ The International Typographic Style: white stock with its twelve columns showing faintly, one strict left edge, a large grotesk, red and black.
53
+ Marks: thick square bars, red for the story, black (`series-2`) for a second series, grey for the rest.
54
+
55
+ ```html
56
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Host+Grotesk:wght@400..800&display=swap">
57
+ ```
58
+
59
+ ```css
60
+ /* Look: Swiss grid */
61
+ :root {
62
+ --page: #d9d9d5; --paper: #f7f7f4; --ink: #111111; --soft: #676765; --rule: #d6d6d3;
63
+ --accent: #ca2218; --s2: #1c1c1c; --s3: #2a5bd7; --quiet: #888887;
64
+ --display: "Host Grotesk", "Helvetica Neue", Helvetica, Arial, sans-serif; --text: var(--display);
65
+ }
66
+ .poster { background-image: repeating-linear-gradient(90deg, rgb(0 0 0 / .035) 0 1px, transparent 1px calc(100% / 12)); background-origin: content-box; }
67
+ .poster .rhp-chart { background-color: var(--paper); }
68
+ .poster .kicker { display: flex; align-items: center; gap: 10px; font-size: 13px; letter-spacing: .02em; text-transform: none; color: var(--ink); }
69
+ .poster .kicker::before { content: ""; flex: none; width: 14px; height: 14px; background: var(--accent); }
70
+ .poster .headline { font: 800 clamp(44px, 7.3vw, 70px)/.94 var(--display); letter-spacing: -.035em; max-width: 12em; }
71
+ .poster .dek { max-width: 46ch; font-size: 18px; line-height: 1.45; color: var(--ink); }
72
+ .poster .note { border-top: 2px solid var(--ink); }
73
+ .poster .keys i { width: 14px; height: 14px; }
74
+ .poster .choice button { margin-left: -2px; border: 2px solid var(--ink); font-weight: 700; }
75
+ .poster .choice button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--paper); }
76
+ ```
77
+
78
+ ## Field guide
79
+
80
+ A naturalist's plate: ivory stock with faint foxing, a ruled plate frame, a bookish serif with italics for names, earth inks.
81
+ Marks: flat earth inks, hairline outlines for containers (a gauge's tube), names of species in italics (`<em>`).
82
+
83
+ ```html
84
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Alegreya:ital,wght@0,400..800;1,400..800&family=Alegreya+Sans:wght@400;500;700&display=swap">
85
+ ```
86
+
87
+ ```css
88
+ /* Look: Field guide */
89
+ :root {
90
+ --page: #cfcbbf; --paper: #f2f0e8; --ink: #22201b; --soft: #66615a; --rule: #d6d1c4;
91
+ --accent: #a73703; --s2: #3a6b8f; --s3: #56644f; --quiet: #87847c;
92
+ --display: Alegreya, "Iowan Old Style", Georgia, serif; --text: "Alegreya Sans", "Gill Sans", "Trebuchet MS", sans-serif;
93
+ }
94
+ .poster { box-shadow: inset 0 0 0 14px var(--paper), inset 0 0 0 15px var(--soft), inset 0 0 0 18px var(--paper), inset 0 0 0 19px var(--rule);
95
+ background-image: radial-gradient(circle at 14% 22%, rgb(120 100 70 / .07), transparent 16%), radial-gradient(circle at 86% 78%, rgb(120 100 70 / .06), transparent 20%), radial-gradient(circle at 64% 8%, rgb(120 100 70 / .05), transparent 9%); }
96
+ .poster .kicker { font-size: 13px; letter-spacing: .18em; }
97
+ .poster .headline { font: 800 clamp(38px, 5.6vw, 54px)/1.02 var(--display); letter-spacing: -.01em; }
98
+ .poster .dek { font: 400 19px/1.45 var(--display); }
99
+ .poster .note { border-top: 0; font: italic 13px/1.5 var(--display); }
100
+ .poster .keys i { border-radius: 50%; }
101
+ .poster .choice button { border-color: var(--ink); font: 700 12px/1 var(--text); letter-spacing: .1em; text-transform: uppercase; }
102
+ @media (max-width: 640px) {
103
+ .poster { box-shadow: inset 0 0 0 7px var(--paper), inset 0 0 0 8px var(--soft); }
104
+ }
105
+ ```
106
+
107
+ ## Luxury magazine
108
+
109
+ A fashion or travel monthly: bone stock with a linen weave, a high-contrast serif at large sizes, wide margins, letterspaced capitals, one deep color.
110
+ Marks: square-ended bars (`--rhp-radius: 0px`), the story in the deep red, the rest in petrol and grey, large italic numbers for the key value.
111
+
112
+ ```html
113
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair:ital,opsz,wght@0,5..1200,300..800;1,5..1200,300..800&family=Urbanist:wght@400..700&display=swap">
114
+ ```
115
+
116
+ ```css
117
+ /* Look: Luxury magazine */
118
+ :root {
119
+ --page: #24211e; --paper: #f6f4ef; --ink: #15120f; --soft: #68625a; --rule: #ddd8ce;
120
+ --accent: #8a1c2f; --s2: #a57d3d; --s3: #2f4b5f; --quiet: #898682;
121
+ --display: Playfair, "Bodoni 72", Didot, "Times New Roman", serif; --text: Urbanist, "Avenir Next", "Century Gothic", sans-serif;
122
+ }
123
+ .poster { padding: 72px 80px 40px;
124
+ background-image: repeating-linear-gradient(0deg, rgb(40 30 20 / .035) 0 1px, transparent 1px 3px), repeating-linear-gradient(90deg, rgb(40 30 20 / .035) 0 1px, transparent 1px 3px); }
125
+ .poster figcaption { gap: 18px; margin-bottom: 44px; }
126
+ .poster .kicker { font: 600 11px/1.6 var(--text); letter-spacing: .32em; color: var(--ink); }
127
+ .poster .kicker::before { content: ""; display: block; width: 56px; height: 1px; margin-bottom: 16px; background: currentColor; }
128
+ .poster .headline { font: 400 clamp(46px, 8.1vw, 78px)/.96 var(--display); letter-spacing: -.02em; max-width: 11em; }
129
+ .poster .headline em { color: var(--accent); }
130
+ .poster .dek { max-width: 42ch; font: italic 400 21px/1.45 var(--display); }
131
+ .poster .note { margin-top: 40px; border-top: 0; font: 500 11px/1.6 var(--text); letter-spacing: .04em; }
132
+ .poster .keys { font: 600 11px/1.3 var(--text); letter-spacing: .2em; text-transform: uppercase; }
133
+ .poster .keys i { width: 22px; height: 2px; vertical-align: 3px; }
134
+ .poster .choice button { min-width: 24px; margin: 0 20px 0 0; padding: 8px 0; border: 0; border-bottom: 1px solid transparent; font: 600 12px/1 var(--text); letter-spacing: .24em; text-transform: uppercase; color: var(--soft); }
135
+ .poster .choice button[aria-pressed="true"] { background: none; border-bottom-color: var(--ink); color: var(--ink); }
136
+ @media (max-width: 640px) {
137
+ .poster { padding: 36px 20px 22px; }
138
+ .poster figcaption { margin-bottom: 28px; }
139
+ .poster .dek { font-size: 17px; }
140
+ }
141
+ ```
142
+
143
+ ## Sports page
144
+
145
+ A matchday programme: slanted extra-condensed capitals, speed stripes along the top, a jersey-mesh weave, racing orange and navy.
146
+ Marks: bars with a slanted end (`shape=${SLANT}` with `const SLANT = shape(["M", 0, 0], ["L", 1, 0], ["L", "-8px", 1], ["L", 0, 1], ["Z"])`), big tabular numbers, the leader in the accent.
147
+
148
+ ```html
149
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sofia+Sans+Extra+Condensed:ital,wght@0,700..900;1,700..900&family=Sofia+Sans:ital,wght@0,400..800;1,800&display=swap">
150
+ ```
151
+
152
+ ```css
153
+ /* Look: Sports page */
154
+ :root {
155
+ --page: #c8cacb; --paper: #f3f3f0; --ink: #121418; --soft: #616468; --rule: #d5d6d4;
156
+ --accent: #b83900; --s2: #1d3b8f; --s3: #257656; --quiet: #858686;
157
+ --display: "Sofia Sans Extra Condensed", "Arial Narrow", Impact, sans-serif; --text: "Sofia Sans", "Segoe UI", Arial, sans-serif;
158
+ }
159
+ .poster { position: relative; padding-top: 58px;
160
+ background-image: radial-gradient(rgb(0 0 0 / .05) 1px, transparent 1.4px), radial-gradient(rgb(0 0 0 / .05) 1px, transparent 1.4px);
161
+ background-size: 6px 6px; background-position: 0 0, 3px 3px; }
162
+ .poster::before { content: ""; position: absolute; inset: 0 0 auto; height: 12px; background: repeating-linear-gradient(-60deg, var(--accent) 0 14px, transparent 14px 24px); }
163
+ .poster .kicker { font: italic 800 14px/1.2 var(--text); letter-spacing: .06em; }
164
+ .poster .headline { font: italic 900 clamp(54px, 8.75vw, 84px)/.86 var(--display); text-transform: uppercase; max-width: 13em; }
165
+ .poster .note { border-top: 3px solid var(--ink); font-weight: 500; }
166
+ .poster .keys { font: 800 13px/1.2 var(--text); letter-spacing: .04em; text-transform: uppercase; }
167
+ .poster .keys i { width: 18px; height: 10px; transform: skewX(-20deg); }
168
+ .poster .choice { gap: 4px; }
169
+ .poster .choice button { margin: 0; padding: 9px 14px; border: 0; background: var(--rule); font: italic 800 14px/1 var(--text); text-transform: uppercase; }
170
+ @media (max-width: 640px) {
171
+ .poster { padding-top: 34px; }
172
+ .poster::before { height: 8px; }
173
+ }
174
+ ```
175
+
176
+ ## Scientific figure
177
+
178
+ A journal's figure: white coated stock, a figure label, a plain sans for the figure and a serif for its caption, colors that hold for color-blind readers.
179
+ Marks: thin lines, small dots and square bars, the accent on what the finding is about, `n` and the method in the note.
180
+
181
+ ```html
182
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400..700&family=STIX+Two+Text:ital,wght@0,400..700;1,400&display=swap">
183
+ ```
184
+
185
+ ```css
186
+ /* Look: Scientific figure */
187
+ :root {
188
+ --page: #e6e7e9; --paper: #ffffff; --ink: #1a1c1f; --soft: #63676d; --rule: #e1e3e6;
189
+ --accent: #b45000; --s2: #3563d0; --s3: #18805f; --quiet: #8d8e8f;
190
+ --display: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif; --text: var(--display); --caption: "STIX Two Text", "Times New Roman", serif;
191
+ }
192
+ .poster { max-width: 880px; border: 1px solid #d3d6da; background-image: linear-gradient(170deg, #ffffff 50%, #f6f7f8); }
193
+ .poster .kicker { width: fit-content; padding: 3px 8px; background: var(--ink); color: var(--paper); font-size: 12px; letter-spacing: .06em; }
194
+ .poster .headline { font: 700 clamp(26px, 3.6vw, 34px)/1.14 var(--display); letter-spacing: -.01em; max-width: 24em; }
195
+ .poster .dek { max-width: 66ch; font: 400 17px/1.5 var(--caption); color: var(--ink); }
196
+ .poster .note { font: 400 13px/1.5 var(--caption); }
197
+ .poster .keys { font-size: 13px; }
198
+ .poster .keys i { border-radius: 50%; }
199
+ .poster .choice button { border-color: #aab0b8; font-weight: 500; font-size: 13px; }
200
+ .poster .choice button:first-child { border-radius: 4px 0 0 4px; }
201
+ .poster .choice button:last-child { border-radius: 0 4px 4px 0; }
202
+ ```
203
+
204
+ ## Night instrument
205
+
206
+ A cockpit display after dark: graphite glass with a fine screen mesh, corner brackets, a typeface made for instruments, amber for what matters.
207
+ Marks: thin lines and small dots, cells from dark to bright (section 4), no glow: a flat bright color on a dark ground is already the brightest thing on the page.
208
+
209
+ ```html
210
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=B612:wght@400;700&family=B612+Mono&display=swap">
211
+ ```
212
+
213
+ ```css
214
+ /* Look: Night instrument */
215
+ :root {
216
+ color-scheme: dark;
217
+ --page: #070708; --paper: #121314; --ink: #e8eaec; --soft: #8f9499; --rule: #2e3134;
218
+ --accent: #ffb020; --s2: #69ddfc; --s3: #d26eca; --quiet: #666769;
219
+ --display: B612, "Segoe UI", system-ui, sans-serif; --text: var(--display); --mono: "B612 Mono", ui-monospace, Menlo, monospace;
220
+ }
221
+ .poster { position: relative; border-radius: 4px;
222
+ background-image: radial-gradient(rgb(255 255 255 / .035) .6px, transparent .9px), radial-gradient(ellipse at 50% 0%, rgb(255 255 255 / .05), transparent 70%);
223
+ background-size: 4px 4px, 100% 100%; }
224
+ .poster::before { content: ""; position: absolute; inset: 12px; border: 1px solid var(--soft); pointer-events: none;
225
+ mask: conic-gradient(at 18px 18px, transparent 75%, #000 0) 0 0 / calc(100% - 18px) calc(100% - 18px); }
226
+ .poster .kicker { font: 400 12px/1.3 var(--mono); letter-spacing: .16em; }
227
+ .poster .headline { font: 700 clamp(30px, 4.6vw, 44px)/1.08 var(--display); letter-spacing: -.01em; }
228
+ .poster .note { border-top-style: dashed; font-size: 12px; }
229
+ .poster .keys { font: 400 12px/1.3 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
230
+ .poster .keys i { width: 14px; height: 3px; vertical-align: 3px; }
231
+ .poster .choice button { border-color: var(--rule); font: 400 12px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--soft); }
232
+ .poster .choice button[aria-pressed="true"] { background: none; border-color: var(--accent); color: var(--accent); }
233
+ .poster :focus-visible { outline-color: var(--accent); }
234
+ @media (max-width: 640px) {
235
+ .poster::before { inset: 6px; }
236
+ }
237
+ ```
238
+
239
+ ## Photocopied zine
240
+
241
+ A stapled fanzine: speckled copier paper with a dark copier edge, the headline cut out on a black strip, typewriter text, one hot pink.
242
+ Marks: black for most marks, pink for the one value that matters, dotted stems like a photocopied leader line.
243
+
244
+ ```html
245
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Courier+Prime:wght@400;700&display=swap">
246
+ ```
247
+
248
+ ```css
249
+ /* Look: Photocopied zine */
250
+ :root {
251
+ --page: #3a3936; --paper: #f1f0ec; --ink: #121212; --soft: #62615d; --rule: #cfcdc8;
252
+ --accent: #c30f59; --s2: #1f3fd1; --s3: #121212; --quiet: #848482;
253
+ --display: Anton, Impact, "Arial Black", sans-serif; --text: "Courier Prime", "Courier New", monospace;
254
+ }
255
+ .poster { position: relative; box-shadow: inset 16px 0 22px -16px rgb(0 0 0 / .22);
256
+ background-image: radial-gradient(rgb(0 0 0 / .3) .5px, transparent .9px), radial-gradient(rgb(0 0 0 / .22) .6px, transparent 1px), radial-gradient(rgb(0 0 0 / .26) .4px, transparent .8px), radial-gradient(rgb(0 0 0 / .18) .7px, transparent 1.1px);
257
+ background-size: 23px 31px, 47px 37px, 61px 89px, 113px 71px; background-position: 0 0, 17px 9px, 31px 52px, 7px 40px; }
258
+ .poster::before { content: ""; position: absolute; top: 14px; left: 30px; width: 36px; height: 4px; border-radius: 1px; background: #8d8d8d; rotate: -6deg; }
259
+ .poster .rhp-chart { background-color: var(--paper); }
260
+ .poster .kicker { width: fit-content; padding: 3px 8px; background: var(--accent); color: var(--paper); letter-spacing: .08em; }
261
+ .poster .headline { width: fit-content; max-width: 100%; padding: .04em .18em .08em; background: var(--ink); color: var(--paper); font: 400 clamp(40px, 6.5vw, 62px)/1.06 var(--display); text-transform: uppercase; text-wrap: wrap; rotate: -1deg; }
262
+ .poster .dek { color: var(--ink); }
263
+ .poster .note { border-top: 2px dashed var(--ink); }
264
+ .poster .keys { font-size: 13px; font-weight: 700; }
265
+ .poster .keys i { border: 1.5px solid var(--ink); }
266
+ .poster .choice button { margin-left: -2px; border: 2px solid var(--ink); font-weight: 700; text-transform: uppercase; }
267
+ ```
@@ -0,0 +1,143 @@
1
+ # Keeping a big chart light
2
+
3
+ Use this reference when a finished chart has measurable interaction or loading costs, particularly with many slats or many blocks per slat.
4
+ Measure the chart before choosing an optimization.
5
+ These patterns preserve rhp's slats, blocks, nested Plots, and public APIs.
6
+ They do not require any particular interaction.
7
+
8
+ ## Measure the work
9
+
10
+ Count elements in the page and hidden slats, and observe attribute and child-list mutations during a representative action.
11
+ Separate DOM writes from rendering cost: fewer JavaScript writes can still cause the browser to restyle and paint many marks.
12
+ Compare actual interaction latency as well as counts when speed matters.
13
+
14
+ ```js
15
+ const chart = document.querySelector('.rhp-chart');
16
+ console.log(chart.querySelectorAll('*').length);
17
+ const changes = [];
18
+ const observer = new MutationObserver(records => changes.push(...records));
19
+ observer.observe(chart, { subtree: true, attributes: true, childList: true });
20
+ // Perform the action, then disconnect and inspect changes.
21
+ ```
22
+
23
+ ## Switch mark colors through a slat class
24
+
25
+ When one slat's state changes many marks' colors, give the marks their colors once and switch a CSS variable through a class on the owning slat.
26
+ Keep the `color` API so all consumers of the mark's color, including outlines or descendant styling, continue to see the active color.
27
+ A generic `background` shorthand override can erase background images and changes only that paint property.
28
+ Use an override of a specific paint property only when that is the intended behavior of the existing chart.
29
+
30
+ ```js
31
+ const Segment = slat({
32
+ css: `
33
+ .seg { --active-tone: var(--base-tone); }
34
+ .lit .seg { --active-tone: var(--lit-tone); }
35
+ `,
36
+ }, s => html`
37
+ <${Bar} class="seg" from=${() => s.from} to=${() => s.to}
38
+ color="var(--active-tone)"
39
+ style=${() => ({ '--base-tone': s.tone, '--lit-tone': s.gold })} />`);
40
+
41
+ const Shelf = slat({ thickness: 24, room: { start: 70, end: 40 } }, d => html`
42
+ <div class=${() => d.lit ? 'shelf lit' : 'shelf'}>
43
+ <${Label} edge="start">${() => d.name}<//>
44
+ <${Plot} overlap=${true} from=${() => d.from} to=${() => d.to}
45
+ tone=${() => d.tones} gold=${() => d.golds}>${Segment}<//>
46
+ </div>`);
47
+ ```
48
+
49
+ `tone` and `gold` here are CSS colors, including theme variables such as `var(--rhp-series-8)`, rather than bare theme-key strings.
50
+ Keep their data groups independent of the interaction state.
51
+ Continue passing reactive getters when the underlying data or palette can change.
52
+ Only the shelf's `lit` data group needs to read the state that chooses the highlighted shelf.
53
+ This does not change how the chart chooses that state.
54
+
55
+ Put the rule in the `css` of the slat type that owns the mark, here `Segment`.
56
+ Its selector may name ancestors inside the same chart, including the shelf root.
57
+ A shelf's slat CSS does not style another nested slat type's marks.
58
+ See api.md, “Slat CSS scope”.
59
+
60
+ ## Put shared dimming state on a parent
61
+
62
+ When a shared selection dims many slats, a class on their Plot or grouping slat can replace a flag rewritten on every row.
63
+ Keep the classes that identify the exceptions.
64
+ For example, inside the mark-owning slat's CSS:
65
+
66
+ ```css
67
+ .picking .shelf:not(.lit):not(.pointed) .seg { opacity: .3; }
68
+ ```
69
+
70
+ Set `picking` on a Plot or slat root inside the chart when the existing interaction has an active selection.
71
+ Keep `lit` and `pointed` consistent with the existing selection and preview behavior.
72
+ Charts with multiple selections, disabled marks, separate groups, or other exceptions need selectors that preserve all those states.
73
+ A class outside the chart, on the poster for example, is outside slat CSS's ancestor scope.
74
+
75
+ ## Render hidden slats' contents conditionally
76
+
77
+ A Plot row whose `d.position` is `null` is hidden, but its contents normally remain mounted and reactive.
78
+ For expensive rows hidden by a filter or top-N view, Solid's `Show` can leave only the slat root mounted:
79
+
80
+ ```js
81
+ const Shelf = slat({ thickness: 24, room: { start: 70, end: 40 } }, d => html`
82
+ <div class=${() => d.lit ? 'shelf lit' : 'shelf'}>
83
+ <${Show} when=${() => d.position != null}>
84
+ <${Label} edge="start">${() => d.name}<//>
85
+ <${Plot} overlap=${true} from=${() => d.from} to=${() => d.to}
86
+ tone=${() => d.tones} gold=${() => d.golds}>${Segment}<//>
87
+ <//>
88
+ </div>`);
89
+ ```
90
+
91
+ This is normal Solid composition and retains rhp's slat design.
92
+ Keep the root outside `Show`, so rhp retains its identity, position, focus target, and keyboard order.
93
+ The contents mount when the row receives a position and unmount when it loses that position.
94
+ Check leaving animations against when the chart's own order actually becomes `null`.
95
+
96
+ State owned by the slat root survives this cycle.
97
+ State, native input values, subscriptions, and effects owned inside the conditional content follow Solid's mount and cleanup lifecycle.
98
+ Keep state that must survive in the root or application, or leave that content mounted.
99
+ Showing many rows at once can cost more because their blocks must be created then.
100
+ Measure that action before deciding whether this tradeoff suits the chart.
101
+
102
+ Automatic gutters already follow the visible edge labels in the tested eager and conditional versions.
103
+ Both widened when a longer label became visible, with identical widths before and after filtering.
104
+ Conditional rendering does not by itself require fixed room.
105
+ Verify the chart's gutter geometry, including its own sizing constraints, when changing which labels are mounted.
106
+
107
+ ## Verified example and limits
108
+
109
+ The vocabulary article already uses these patterns.
110
+ A review on 2026-10-16 compared four isolated variants of the article at source commit `4101fdb`: eager contents with per-mark color and dimming updates, then each optimization added in turn.
111
+ The test used Chromium and WebKit, widths 1280 and 390, and dark and light themes, for 32 configurations.
112
+
113
+ | Measurement | Earlier implementation | Optimized implementation |
114
+ |---|---:|---:|
115
+ | Style writes for one tested shelf hover | 14 | 0 |
116
+ | Shelf class changes for that hover | 1 | 1 |
117
+ | Changed DOM nodes in the era chart after opening a rapper | 122 | 9 |
118
+ | Page elements at desktop rest | 3,101 | 1,449 |
119
+ | Descendants retained inside hidden ranking slats | 1,652 | 0 |
120
+ | Retained JS heap after collection, Chromium desktop | 16.7 MB | about 10 MB |
121
+
122
+ These figures describe this page and these actions, not a complexity or frame-rate guarantee.
123
+ Visible mark geometry, computed colors and opacity, readout text, tab order, and accessible breakdown content matched in seven states: rest, hover, open breakdown, normalized measure, era filter, show all, and search.
124
+ The hovered ranking screenshots matched pixel for pixel in all eight engine/width/theme combinations.
125
+ Color transition values matched at 0, 100, and 200ms, and face dimming matched at 0, 100, and 250ms in both engines.
126
+ A separate lifecycle fixture verified root identity, root-owned state, arrow-key navigation, nested state reset, native input reset, and cleanup behavior in both engines.
127
+ The color-variable example above was also checked with reactive palette changes, an outline that consumes the mark color, and a background image in both engines.
128
+
129
+ In five fresh-page trials per action at 390px with Chromium CPU throttled 4 times, showing all 149 rows took a median 175ms with mounted contents and 258ms with conditional contents, measured from the action through two animation frames with reduced motion.
130
+ Search medians were 239ms and 217ms respectively, with overlapping ranges (206 to 310ms and 195 to 310ms), so these trials establish no consistent search advantage or regression.
131
+ CPU throttling is a reproducible browser test setting, not a measurement on a particular phone.
132
+
133
+ ## Validate the specific chart
134
+
135
+ Compare before and after at the same viewport, theme, scroll position, and interaction state.
136
+ Check geometry, colors, labels, readouts, keyboard navigation, touch actions, and accessibility content.
137
+ For motion, pause animations at equal times from their starts and compare intermediate frames as well as settled output.
138
+ Use a before-versus-before control when screenshots vary between identical builds.
139
+ Run the checker again after the change.
140
+
141
+ Use the class patterns when they reduce measured work and preserve the chart's styling and state semantics.
142
+ Choose conditional contents when their lifecycle and reveal cost fit the chart, and explain a material tradeoff to the user before changing existing behavior.
143
+ No pattern here removes an rhp capability or calls for replacing slats with a different rendering model.