@bezda/rhp-mcp 0.0.0-stage → 0.1.1

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -2
  3. package/bin/rhp-mcp.js +129 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +450 -0
  6. package/skill/recipes/area.html +156 -0
  7. package/skill/recipes/bar.html +145 -0
  8. package/skill/recipes/box-plot.html +164 -0
  9. package/skill/recipes/bubble.html +188 -0
  10. package/skill/recipes/bullet.html +138 -0
  11. package/skill/recipes/candlestick.html +179 -0
  12. package/skill/recipes/column.html +138 -0
  13. package/skill/recipes/dial.html +349 -0
  14. package/skill/recipes/diverging-bars.html +150 -0
  15. package/skill/recipes/donut.html +231 -0
  16. package/skill/recipes/dumbbell.html +137 -0
  17. package/skill/recipes/gantt.html +196 -0
  18. package/skill/recipes/grouped-bars.html +142 -0
  19. package/skill/recipes/heatmap.html +183 -0
  20. package/skill/recipes/histogram.html +199 -0
  21. package/skill/recipes/line.html +170 -0
  22. package/skill/recipes/live.html +255 -0
  23. package/skill/recipes/log-line.html +493 -0
  24. package/skill/recipes/lollipop.html +152 -0
  25. package/skill/recipes/multi-line.html +228 -0
  26. package/skill/recipes/pyramid.html +162 -0
  27. package/skill/recipes/race.html +278 -0
  28. package/skill/recipes/radial-bars.html +157 -0
  29. package/skill/recipes/scatter.html +199 -0
  30. package/skill/recipes/silhouettes.html +352 -0
  31. package/skill/recipes/slope.html +199 -0
  32. package/skill/recipes/sparklines.html +175 -0
  33. package/skill/recipes/stacked-100.html +158 -0
  34. package/skill/recipes/stacked-bars.html +150 -0
  35. package/skill/recipes/strip.html +221 -0
  36. package/skill/recipes/unit-stack.html +276 -0
  37. package/skill/recipes/violin.html +169 -0
  38. package/skill/recipes/waffle.html +165 -0
  39. package/skill/recipes/waterfall.html +127 -0
  40. package/skill/references/api.md +1684 -0
  41. package/skill/references/design.md +740 -0
  42. package/skill/references/environments.md +1577 -0
  43. package/skill/references/forms.md +197 -0
  44. package/skill/references/interaction.md +794 -0
  45. package/skill/references/looks.md +267 -0
  46. package/skill/references/pitfalls.md +202 -0
  47. package/src/check/README.md +176 -0
  48. package/src/check/browser.js +92 -0
  49. package/src/check/build.js +439 -0
  50. package/src/check/colors.js +324 -0
  51. package/src/check/findings.js +247 -0
  52. package/src/check/index.js +508 -0
  53. package/src/check/inpage.js +1332 -0
  54. package/src/check/interact.js +266 -0
  55. package/src/check/png.js +98 -0
  56. package/src/check/report.js +106 -0
  57. package/src/check/source.js +387 -0
  58. package/src/check/sourcemap.js +75 -0
  59. package/src/guard/note.js +30 -0
  60. package/src/guard/render.js +17 -0
  61. package/src/guard/rhp.js +612 -0
  62. package/src/guard/rules.js +125 -0
  63. package/src/server.js +266 -0
  64. package/src/skill.js +140 -0
@@ -0,0 +1,221 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Burger places are hit or miss</title>
7
+ <meta name="description" content="Strip plot of restaurant ratings by cuisine: one dot per restaurant in its cuisine's band (an overlap Plot inside each slat), a median tick, and a readout of the restaurant the reader points at, taps or reaches with the arrow keys. Use it to show every value of a few groups.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: an overlap Plot of Dots inside each group's slat, a median Tick, and a readout of the dot under the pointer.
11
+ Leave: the design. This poster was made for its own subject (restaurant ratings as a chalkboard menu in a wooden frame, its slate smudged where it was wiped).
12
+ Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
13
+ -->
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cabin+Sketch:wght@700&family=Karla:wght@400..800&display=swap">
17
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
18
+ <style>
19
+ :root { color-scheme: light; } /* the page around the chalkboard is light */
20
+ * { box-sizing: border-box; }
21
+ body { margin: 0; padding: 48px 16px; background: #d8d6d1; font-family: Karla, "Helvetica Neue", Arial, sans-serif; color: #f2f1ec; }
22
+ /* The poster: a chalkboard menu in a wooden frame, its slate smudged where it was wiped */
23
+ .poster { max-width: 940px; margin: 0 auto; padding: 34px 40px 24px; line-height: 1.45; border: 12px solid #6b4a2f; border-radius: 4px;
24
+ background:
25
+ radial-gradient(60% 40% at 22% 18%, rgb(255 255 255 / .05), transparent 70%),
26
+ radial-gradient(50% 35% at 78% 72%, rgb(255 255 255 / .04), transparent 70%),
27
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='360'%3E%3Cfilter id='f' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='2' seed='5' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 .09 0 0 0 -.035'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E"),
28
+ #2b2e2d;
29
+ box-shadow: inset 0 0 0 1px rgb(0 0 0 / .5), inset 0 0 40px rgb(0 0 0 / .45), 0 0 0 1px #4e341f, 0 22px 44px -18px rgb(0 0 0 / .6); }
30
+ .poster figcaption { display: grid; gap: 8px; margin-bottom: 20px; }
31
+ .poster .kicker { font-size: 13px; font-weight: 700; letter-spacing: .22em; text-transform: uppercase; color: #f3d36b; }
32
+ .poster .headline { font: 700 58px/1.02 "Cabin Sketch", "Comic Sans MS", cursive; letter-spacing: .005em; text-wrap: balance; }
33
+ .poster .dek { max-width: 60ch; font-size: 16px; color: #cfcdc6; }
34
+ .poster .note { display: block; margin-top: 16px; font-size: 12px; color: #a9a7a0; }
35
+ /* The readout: the restaurant under the pointer, two lines tall so the chart below never moves */
36
+ .readout { display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 14px; min-height: 58px; margin: 0 0 14px; padding: 10px 0;
37
+ border-block: 1px dashed rgb(242 241 236 / .35); }
38
+ .readout .stars { font: 700 34px/1 "Cabin Sketch", "Comic Sans MS", cursive; color: #f3d36b; font-variant-numeric: tabular-nums; }
39
+ .readout b { display: block; font-size: 17px; font-weight: 800; }
40
+ .readout span { font-size: 14px; color: #cfcdc6; }
41
+ @media (max-width: 600px) {
42
+ body { padding: 16px 12px; }
43
+ .poster { padding: 24px 16px 18px; border-width: 9px; }
44
+ .poster .kicker { font-size: 11px; letter-spacing: .16em; }
45
+ .poster .headline { font-size: 38px; }
46
+ .poster .dek { font-size: 14.5px; }
47
+ .readout { min-height: 64px; }
48
+ .readout .stars { font-size: 28px; }
49
+ .readout b { font-size: 15.5px; }
50
+ .readout span { font-size: 13px; }
51
+ }
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <div id="chart"></div>
56
+ <script type="module">
57
+ import { Chart, Plot, Dot, Tick, Label, Poster, slat, summary, sortBy, every, html, render, createSignal } from "@bezda/rhp/standalone";
58
+
59
+ // Data: invented restaurants in one city, their cuisine, average rating out of 5 and number of reviews. Illustrative.
60
+ // Replace with your own.
61
+ const PLACES = [
62
+ { name: "Blue Nile Kitchen", cuisine: "Ethiopian", rating: 4.7, reviews: 412 },
63
+ { name: "Lalibela Table", cuisine: "Ethiopian", rating: 4.6, reviews: 288 },
64
+ { name: "Addis Café", cuisine: "Ethiopian", rating: 4.4, reviews: 156 },
65
+ { name: "Injera House", cuisine: "Ethiopian", rating: 4.8, reviews: 521 },
66
+ { name: "Habesha Corner", cuisine: "Ethiopian", rating: 4.5, reviews: 203 },
67
+ { name: "Teff & Tea", cuisine: "Ethiopian", rating: 4.3, reviews: 97 },
68
+ { name: "Kumo Ramen", cuisine: "Japanese", rating: 4.8, reviews: 1240 },
69
+ { name: "Tanuki Sushi Bar", cuisine: "Japanese", rating: 4.6, reviews: 880 },
70
+ { name: "Yuzu Izakaya", cuisine: "Japanese", rating: 4.4, reviews: 610 },
71
+ { name: "Hinode Bento", cuisine: "Japanese", rating: 4.1, reviews: 325 },
72
+ { name: "Sakura Garden", cuisine: "Japanese", rating: 3.9, reviews: 402 },
73
+ { name: "Mochi Mochi", cuisine: "Japanese", rating: 4.5, reviews: 290 },
74
+ { name: "Shiro Udon", cuisine: "Japanese", rating: 4.3, reviews: 515 },
75
+ { name: "Oni Grill", cuisine: "Japanese", rating: 4.0, reviews: 188 },
76
+ { name: "Kaiseki Mori", cuisine: "Japanese", rating: 4.7, reviews: 264 },
77
+ { name: "Tokyo Express", cuisine: "Japanese", rating: 3.6, reviews: 733 },
78
+ { name: "Saffron Lane", cuisine: "Indian", rating: 4.6, reviews: 970 },
79
+ { name: "Masala Theory", cuisine: "Indian", rating: 4.4, reviews: 540 },
80
+ { name: "Chai Wallah", cuisine: "Indian", rating: 4.2, reviews: 311 },
81
+ { name: "Tandoor Nights", cuisine: "Indian", rating: 4.0, reviews: 466 },
82
+ { name: "Bombay Tiffin", cuisine: "Indian", rating: 4.5, reviews: 288 },
83
+ { name: "Curry Leaf", cuisine: "Indian", rating: 4.3, reviews: 602 },
84
+ { name: "Delhi Darbar", cuisine: "Indian", rating: 3.7, reviews: 380 },
85
+ { name: "Goa Shack", cuisine: "Indian", rating: 4.1, reviews: 215 },
86
+ { name: "Spice Route", cuisine: "Indian", rating: 3.5, reviews: 450 },
87
+ { name: "La Paloma", cuisine: "Mexican", rating: 4.5, reviews: 810 },
88
+ { name: "Taquería El Sol", cuisine: "Mexican", rating: 4.7, reviews: 1055 },
89
+ { name: "Casa Verde", cuisine: "Mexican", rating: 4.2, reviews: 390 },
90
+ { name: "Mole Mole", cuisine: "Mexican", rating: 4.0, reviews: 245 },
91
+ { name: "Burrito Barn", cuisine: "Mexican", rating: 3.4, reviews: 930 },
92
+ { name: "Agave Azul", cuisine: "Mexican", rating: 4.3, reviews: 520 },
93
+ { name: "El Comal", cuisine: "Mexican", rating: 4.6, reviews: 342 },
94
+ { name: "Tortilla Flats", cuisine: "Mexican", rating: 3.8, reviews: 611 },
95
+ { name: "Salsa Brava", cuisine: "Mexican", rating: 3.9, reviews: 288 },
96
+ { name: "Cantina 52", cuisine: "Mexican", rating: 3.2, reviews: 474 },
97
+ { name: "Trattoria Nonna", cuisine: "Italian", rating: 4.6, reviews: 1320 },
98
+ { name: "Pizzeria Bella", cuisine: "Italian", rating: 4.3, reviews: 2010 },
99
+ { name: "Osteria Verde", cuisine: "Italian", rating: 4.4, reviews: 540 },
100
+ { name: "Pasta Fresca", cuisine: "Italian", rating: 4.1, reviews: 690 },
101
+ { name: "Il Forno", cuisine: "Italian", rating: 3.9, reviews: 820 },
102
+ { name: "Gelato & Co", cuisine: "Italian", rating: 4.7, reviews: 310 },
103
+ { name: "Luigi's Slice", cuisine: "Italian", rating: 3.5, reviews: 1150 },
104
+ { name: "Bistro Roma", cuisine: "Italian", rating: 3.7, reviews: 402 },
105
+ { name: "Vino e Cucina", cuisine: "Italian", rating: 4.2, reviews: 288 },
106
+ { name: "Mamma Mia Express", cuisine: "Italian", rating: 3.1, reviews: 960 },
107
+ { name: "Patty Shack", cuisine: "Burgers", rating: 4.8, reviews: 1420 },
108
+ { name: "Smash Bros Burgers", cuisine: "Burgers", rating: 4.4, reviews: 980 },
109
+ { name: "Grill Republic", cuisine: "Burgers", rating: 3.9, reviews: 640 },
110
+ { name: "Bun Run", cuisine: "Burgers", rating: 3.3, reviews: 720 },
111
+ { name: "The Beef Joint", cuisine: "Burgers", rating: 2.9, reviews: 530 },
112
+ { name: "Double Deck", cuisine: "Burgers", rating: 3.6, reviews: 810 },
113
+ { name: "Burger Barn", cuisine: "Burgers", rating: 2.6, reviews: 1090 },
114
+ { name: "Stack & Sauce", cuisine: "Burgers", rating: 4.1, reviews: 450 },
115
+ { name: "Drive Thru Deluxe", cuisine: "Burgers", rating: 3.0, reviews: 2300 },
116
+ ];
117
+ const FOCUS = "Patty Shack"; // the restaurant the readout opens on
118
+
119
+ // Each dot takes the first of a few lanes across its band that has no dot with a close rating, so ties never hide each other
120
+ const LANES = [0.5, 0.24, 0.76, 0.08, 0.92];
121
+ const lanes = (ratings) => {
122
+ const taken = LANES.map(() => []);
123
+ return ratings.map((r) => {
124
+ const k = Math.max(0, LANES.findIndex((_, i) => taken[i].every((q) => Math.abs(q - r) >= 0.15)));
125
+ taken[k].push(r);
126
+ return LANES[k];
127
+ });
128
+ };
129
+ // One row per cuisine: its restaurants from the lowest rating up (each with its index in PLACES and its lane), and their median
130
+ const CUISINES = [...new Set(PLACES.map((p) => p.cuisine))].map((cuisine) => {
131
+ const places = PLACES.filter((p) => p.cuisine === cuisine).sort((a, b) => a.rating - b.rating);
132
+ const across = lanes(places.map((p) => p.rating));
133
+ return {
134
+ cuisine,
135
+ places: places.map((p, i) => ({ ...p, id: PLACES.indexOf(p), across: across[i] })),
136
+ median: summary(places.map((p) => p.rating)).median,
137
+ };
138
+ });
139
+ const opening = Math.max(0, PLACES.findIndex((p) => p.name === FOCUS));
140
+ const lowest = Math.floor(Math.min(...PLACES.map((p) => p.rating)) * 2) / 2; // the scale starts on the half star below the lowest rating
141
+ const one = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
142
+ const count = new Intl.NumberFormat("en-US");
143
+
144
+ // Slat types: a restaurant is a chalk dot at its rating; a cuisine is a band of dots with a line at its median
145
+ const Place = slat({
146
+ css: `
147
+ .place { width: 13px; height: 13px; background: #f2f1ec; box-shadow: 0 0 0 2px var(--rhp-surface); cursor: pointer; }
148
+ .place::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; } /* a bigger target for a finger */
149
+ .place.on { z-index: 1; width: 19px; height: 19px; background: #f3d36b; box-shadow: 0 0 0 2px var(--rhp-surface), 0 0 0 4px #f3d36b; }
150
+ .place:focus-visible { outline: 2px solid #f3d36b; outline-offset: 4px; }
151
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
152
+ `,
153
+ }, (p) => html`
154
+ <${Dot} at=${() => p.rating} across=${() => p.across} class=${() => (p.picked === p.id ? "place on" : "place")} data-place=${() => p.id}>
155
+ <span class="say">${() => `${p.name}, ${one.format(p.rating)} stars from ${count.format(p.reviews)} reviews`}</span>
156
+ <//>`);
157
+
158
+ const Cuisine = slat({
159
+ thickness: 62,
160
+ room: { start: "auto", end: 16 },
161
+ css: `
162
+ .name { font-size: 16px; font-weight: 800; line-height: 1.2; --rhp-label-gap: 16px; }
163
+ .name small { display: block; font-size: 12.5px; font-weight: 600; color: var(--rhp-muted); }
164
+ .median { --rhp-tick-width: 3px; border-radius: 2px; background: rgb(242 241 236 / .55); }
165
+ .cuisine { border-top: 1px dashed rgb(242 241 236 / .16); }
166
+ @container (max-width: 480px) { .name { font-size: 14px; --rhp-label-gap: 12px; } .name small { font-size: 11.5px; } }
167
+ `,
168
+ }, (d) => html`
169
+ <div class="cuisine">
170
+ <${Label} edge="start" class="name">${() => d.cuisine}<small>${() => `median ${one.format(d.median)}`}</small><//>
171
+ <${Tick} at=${() => d.median} thick=${0.92} class="median" />
172
+ <${Plot} overlap=${true} rows=${() => d.places} picked=${() => d.picked} keyboard=${true}>${Place}<//>
173
+ </div>`);
174
+
175
+ // Chart component: the restaurant the reader points at, taps or reaches with the arrow keys is lit and read out
176
+ function RestaurantRatings() {
177
+ const [picked, setPicked] = createSignal(null);
178
+ const shown = () => picked() ?? opening;
179
+ const place = () => PLACES[shown()];
180
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
181
+ const rowOf = (el) => {
182
+ const root = el.closest("[data-place]");
183
+ return root ? +root.dataset.place : null;
184
+ };
185
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
186
+ const pick = (e) => {
187
+ const row = rowOf(e.target);
188
+ if (row != null) setPicked(row);
189
+ };
190
+ // Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
191
+ const leave = (e) => {
192
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
193
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
194
+ };
195
+ return html`
196
+ <${Poster}
197
+ kicker="City food guide · ratings out of 5"
198
+ title="Burger places are hit or miss"
199
+ dek="Every dot is a restaurant, placed by its average rating. Ethiopian kitchens all score between 4.3 and 4.8; burgers run from under 3 to almost 5."
200
+ note="Illustrative data for invented restaurants. Point at a dot or tap it for the restaurant, or Tab into a cuisine and use the arrow keys."
201
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
202
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
203
+ <div class="readout">
204
+ <span class="stars">${() => one.format(place().rating)} ★</span>
205
+ <div>
206
+ <b>${() => place().name}</b>
207
+ <span>${() => `${place().cuisine} · ${count.format(place().reviews)} reviews`}</span>
208
+ </div>
209
+ </div>
210
+ <${Chart} scale=${[lowest, 5]} ticks=${every(0.5)} format=${(v) => `${one.format(v)} ★`}
211
+ label="Strip plot of restaurant ratings out of 5, one dot per restaurant, by cuisine"
212
+ theme=${{ font: 'Karla, "Helvetica Neue", Arial, sans-serif', ink: "#f2f1ec", muted: "#b3b1aa", grid: "rgb(242 241 236 / .16)", surface: "#2b2e2d" }}>
213
+ <${Plot} rows=${CUISINES} order=${sortBy("median", "desc")} picked=${shown}>${Cuisine}<//>
214
+ <//>
215
+ <//>`;
216
+ }
217
+
218
+ render(() => html`<${RestaurantRatings} />`, document.getElementById("chart"));
219
+ </script>
220
+ </body>
221
+ </html>
@@ -0,0 +1,276 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Monthly spending</title>
7
+ <meta name="description" content="Unit stack: stacked columns cut into counted units (coins), the picked part moved to the foot of every stack with its amount under the column, a picker of parts ranked by their total, and columns that lie down as rows on a phone. Use it for a total made of parts over periods, with one part to follow.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: stacked columns cut into units of one fixed amount by a mask (every full unit the same size, the last one of each part cut to what is left), a picked part moved to the foot of every stack with its amount printed under the column, a picker of parts ranked by their total, headline and figures computed from one data block, and columns that lie down as rows when they get too narrow.
11
+ Leave: the design. This poster was made for its own subject (a household's monthly spending as piles of coins on one counter: mint paper where coins are struck, copper for the picked category, milled edges top and bottom).
12
+ Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
13
+ -->
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Old+Standard+TT:wght@400;700&family=Hanken+Grotesk:wght@400;500;600;700&display=swap">
17
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
18
+ <style>
19
+ /* Tokens: mint paper (where coins are struck), deep green ink, copper for the coins of the picked category */
20
+ :root {
21
+ --page: #b9d3c1; --paper: #dcebdf; --ink: #11301f; --soft: #46634f; --rule: #b2ccb9;
22
+ --accent: #a03a14; --quiet: #5d8569;
23
+ --display: "Old Standard TT", "Iowan Old Style", Georgia, serif;
24
+ --text: "Hanken Grotesk", "Helvetica Neue", Arial, sans-serif;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body { margin: 0; padding: 48px 16px; background: var(--page, #b9d3c1); }
28
+
29
+ /* The poster, its top and bottom edges milled like the rim of a coin */
30
+ .poster { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); row-gap: 14px; max-width: 1080px; margin: 0 auto; padding: 50px 52px 34px;
31
+ background: var(--paper); color: var(--ink); font: 16px/1.5 var(--text);
32
+ grid-template-areas: "kicker" "headline" "dek" "rail" "stage" "average" "note"; }
33
+ .poster::before, .poster::after { content: ""; position: absolute; left: 0; right: 0; height: 7px;
34
+ background: repeating-linear-gradient(90deg, var(--quiet) 0 2px, transparent 2px 5px); }
35
+ .poster::before { top: 0; }
36
+ .poster::after { bottom: 0; }
37
+ .poster figcaption { display: contents; }
38
+ .poster .kicker { grid-area: kicker; font: 700 12px/1.3 var(--text); letter-spacing: .2em; text-transform: uppercase; color: var(--accent); }
39
+ .poster .headline { grid-area: headline; max-width: 17em; font: 700 clamp(31px, 4.3vw, 46px)/1.06 var(--display); letter-spacing: -.01em; text-wrap: balance; }
40
+ .poster .dek { grid-area: dek; max-width: 54ch; font-size: 17px; line-height: 1.5; color: var(--soft); }
41
+ .poster .note { grid-area: note; display: block; margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--rule); font-size: 12px; line-height: 1.5; color: var(--soft); }
42
+ .stage { grid-area: stage; min-width: 0; }
43
+
44
+ /* The average month, set like the total on a statement */
45
+ .average { grid-area: average; display: grid; gap: 2px; margin: 0; padding-top: 14px; border-top: 2px solid var(--ink); }
46
+ .average b { font: 700 44px/1 var(--display); font-variant-numeric: tabular-nums; }
47
+ .average span { font-size: 14px; color: var(--soft); }
48
+ .average span:first-of-type { font-weight: 600; color: var(--ink); }
49
+
50
+ /* The rail: the categories ranked by their year, each a button that puts its coins at the foot of every month */
51
+ .rail { grid-area: rail; display: grid; align-content: start; gap: 10px; min-width: 0; }
52
+ .hint { margin: 0; font-size: 14px; font-weight: 600; }
53
+ .picker { display: flex; flex-wrap: wrap; gap: 6px; }
54
+ .picker button { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; padding: 6px 12px 6px 9px; border: 1px solid var(--rule); border-radius: 3px;
55
+ background: none; color: var(--ink); font: 500 14px/1.2 var(--text); text-align: left; cursor: pointer; }
56
+ .picker .coin { flex: none; width: 14px; height: 14px; border-radius: 50%; background: var(--quiet); }
57
+ .picker .sum { display: none; margin-left: auto; padding-left: 12px; font-variant-numeric: tabular-nums; color: var(--soft); }
58
+ .picker button[aria-pressed="true"] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color: var(--accent); font-weight: 700; }
59
+ .picker button[aria-pressed="true"] .coin { background: var(--accent); }
60
+ .picker button[aria-pressed="true"] .sum { color: var(--accent); }
61
+ .poster :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
62
+ .readout { display: grid; margin: 0; font-size: 14px; line-height: 1.45; color: var(--soft); font-variant-numeric: tabular-nums; }
63
+ .readout span { grid-area: 1 / 1; }
64
+ .readout .off { visibility: hidden; }
65
+ .readout b { color: var(--ink); }
66
+
67
+ @media (min-width: 700px) {
68
+ .picker .sum { display: inline; }
69
+ }
70
+
71
+ /* From 1120px: the words over the chart on the left, the average beside the headline, the categories in a rail */
72
+ @media (min-width: 1120px) {
73
+ .poster { grid-template-columns: minmax(0, 1fr) 268px; column-gap: 56px;
74
+ grid-template-areas: "kicker kicker" "headline average" "dek average" "stage rail" "note note"; }
75
+ .poster .dek { margin-bottom: 18px; }
76
+ .average { align-self: end; margin-bottom: 18px; }
77
+ .average b { font-size: 58px; }
78
+ .rail { padding-top: 4px; }
79
+ .picker { display: grid; gap: 0; border-bottom: 1px solid var(--rule); }
80
+ .picker button { min-height: 40px; padding: 8px 6px; border: 0; border-top: 1px solid var(--rule); border-radius: 0; font-size: 15px; }
81
+ .picker button[aria-pressed="true"] { box-shadow: none; background: color-mix(in oklab, var(--accent) 9%, var(--paper)); }
82
+ }
83
+
84
+ @media (max-width: 640px) {
85
+ body { padding: 16px 16px 32px; }
86
+ .poster { padding: 30px 18px 22px; row-gap: 12px; }
87
+ .poster .kicker { font-size: 11px; letter-spacing: .12em; }
88
+ .poster .dek { font-size: 15px; }
89
+ .rail { gap: 8px; }
90
+ .picker button { min-height: 30px; padding: 4px 10px 4px 8px; font-size: 13px; }
91
+ .picker .coin { width: 12px; height: 12px; }
92
+ .average b { font-size: 38px; }
93
+ }
94
+ </style>
95
+ </head>
96
+ <body>
97
+ <div id="chart"></div>
98
+ <script type="module">
99
+ import { Chart, Plot, Bar, Label, Tick, Poster, slat, nice, html, render, createSignal, createMemo, onMount, onCleanup } from "@bezda/rhp/standalone";
100
+
101
+ // Your data
102
+ // Illustrative numbers for an invented household: put your own in their place. One line per month ("YYYY-MM"),
103
+ // then what you spent in each category, in the order of CATEGORIES. Any number of months and categories works,
104
+ // and every title and number on the page is worked out from these lines.
105
+ const CURRENCY = "USD";
106
+ const LOCALE = "en-US";
107
+ const CATEGORIES = ["Rent", "Bills", "Groceries", "Eating out", "Transport", "Shopping", "Travel"];
108
+ const MONTHS = [
109
+ ["2025-10", 1650, 196, 548, 286, 164, 212, 84],
110
+ ["2025-11", 1650, 238, 572, 254, 158, 468, 46],
111
+ ["2025-12", 1650, 284, 694, 412, 196, 836, 612],
112
+ ["2026-01", 1650, 302, 516, 168, 152, 124, 0],
113
+ ["2026-02", 1650, 288, 488, 236, 148, 146, 38],
114
+ ["2026-03", 1650, 246, 532, 248, 171, 198, 142],
115
+ ["2026-04", 1650, 208, 541, 274, 486, 236, 96],
116
+ ["2026-05", 1650, 182, 559, 318, 182, 184, 228],
117
+ ["2026-06", 1650, 176, 566, 342, 174, 252, 312],
118
+ ["2026-07", 1700, 214, 498, 296, 216, 208, 448],
119
+ ["2026-08", 1700, 226, 462, 188, 138, 316, 1864],
120
+ ["2026-09", 1700, 192, 551, 302, 168, 238, 72],
121
+ ];
122
+ const NOTE = "Illustrative data for an invented household.";
123
+
124
+ // What the page says, worked out from the data above
125
+ const money = new Intl.NumberFormat(LOCALE, { style: "currency", currency: CURRENCY, maximumFractionDigits: 0 });
126
+ const compact = new Intl.NumberFormat(LOCALE, { style: "currency", currency: CURRENCY, notation: "compact", maximumFractionDigits: 1 });
127
+ const percent = new Intl.NumberFormat(LOCALE, { style: "percent" });
128
+ const sum = (values) => values.reduce((a, b) => a + b, 0);
129
+
130
+ const DATES = MONTHS.map(([ym]) => new Date(+ym.slice(0, 4), +ym.slice(5, 7) - 1, 1));
131
+ const AMOUNTS = MONTHS.map(([, ...amounts]) => amounts);
132
+ const TOTALS = AMOUNTS.map(sum);
133
+ const ALL = sum(TOTALS);
134
+ const AVERAGE = ALL / MONTHS.length;
135
+ const short = new Intl.DateTimeFormat(LOCALE, { month: "short" });
136
+ const shortYear = new Intl.DateTimeFormat(LOCALE, { month: "short", year: "numeric" });
137
+ const long = new Intl.DateTimeFormat(LOCALE, { month: "long" });
138
+ const longYear = new Intl.DateTimeFormat(LOCALE, { month: "long", year: "numeric" });
139
+ const SHORT = DATES.map((d) => short.format(d));
140
+ // A month named in a sentence takes its year when the data has that month twice, and so do the months under the coins
141
+ const named = (i) => (SHORT.filter((m) => m === SHORT[i]).length > 1 ? longYear : long).format(DATES[i]);
142
+ const DATED = new Set(SHORT).size < SHORT.length;
143
+ const LABELS = DATED ? DATES.map((d) => new Intl.DateTimeFormat(LOCALE, { month: "short", year: "2-digit" }).format(d)) : SHORT;
144
+ // Amounts under the coins: whole units, or compact with one decimal once they need five digits
145
+ const BIG = Math.max(...TOTALS) >= 10000;
146
+ const tenths = new Intl.NumberFormat(LOCALE, { style: "currency", currency: CURRENCY, notation: "compact", minimumFractionDigits: 1, maximumFractionDigits: 1 });
147
+ const amount = (v) => (BIG ? tenths : money).format(v);
148
+
149
+ // Each category's year, ranked; and the one that swings most from month to month, which the headline is about
150
+ const YEAR = CATEGORIES.map((_, c) => sum(AMOUNTS.map((a) => a[c])));
151
+ const RANKED = CATEGORIES.map((_, c) => c).sort((a, b) => YEAR[b] - YEAR[a]);
152
+ const column = (c) => AMOUNTS.map((a) => a[c]);
153
+ const swing = (c) => Math.max(...column(c)) - Math.min(...column(c));
154
+ const SWING = CATEGORIES.map((_, c) => c).reduce((best, c) => (swing(c) > swing(best) ? c : best), 0);
155
+ // When a value came: "in August", or "in 3 months" when several months share it
156
+ const when = (values, v) => {
157
+ const months = values.flatMap((x, i) => (x === v ? [i] : []));
158
+ return months.length > 1 ? `in ${months.length} months` : `in ${named(months[0])}`;
159
+ };
160
+ const headline = (() => {
161
+ const values = column(SWING);
162
+ const [low, high] = [Math.min(...values), Math.max(...values)];
163
+ return `${CATEGORIES[SWING]} swings your spending most: ${money.format(high)} ${when(values, high)}, ${low === 0 ? "nothing" : "as little as " + money.format(low)} ${when(values, low)}`;
164
+ })();
165
+
166
+ // The scale, and what one coin is worth: a round sum that keeps the tallest month under about 30 coins
167
+ const scale = nice(0, Math.max(...TOTALS));
168
+ const COIN = (() => {
169
+ for (let p = 1; ; p *= 10) for (const s of [1, 2, 2.5, 5]) if (s * p * 30 >= Math.max(...TOTALS)) return s * p;
170
+ })();
171
+ // A coin as a share of the part it is in: tiles that size, laid from the part's foot, make every full coin worth COIN
172
+ // and cut the last one to what is left
173
+ const coin = (v) => (v > 0 ? COIN / v : 1);
174
+ const RANGE = `${longYear.format(DATES[0])} to ${longYear.format(DATES.at(-1))}`;
175
+
176
+ // The chart's font and colors, read from the page's tokens
177
+ const THEME = { font: "var(--text)", ink: "var(--ink)", muted: "var(--soft)", grid: "var(--rule)", surface: "var(--paper)", series: ["var(--accent)", "var(--quiet)"] };
178
+
179
+ // Slat types: a month is a pile of coins on the counter, the picked category's coins at its foot (series-1, copper) and
180
+ // the rest of the month above them (series-2). A mask cuts each part into coins from its foot: every full coin is worth
181
+ // the same and is the same size, and the last one is cut to what is left, so a part keeps its exact height and its coins
182
+ // can be counted. Each coin is bevelled at its corners and stands 2px clear of what is under it, so a part's cut coin
183
+ // never touches the next part's first one.
184
+ const Month = slat({
185
+ inset: { vertical: 0.2, horizontal: 0.18 },
186
+ thickness: { horizontal: 30 },
187
+ room: { vertical: { start: 40, end: 22 }, horizontal: { start: "auto", end: 62 } },
188
+ css: `
189
+ .coins { --rhp-radius: 0px; }
190
+ .coins:vertical {
191
+ mask:
192
+ linear-gradient(to top, #0000 3px, #000 0 calc(100% - 1px), #0000 0) 0 100% / 100% calc(100% * var(--coin)),
193
+ linear-gradient(to top, #0000 2px, #000 0) 50% 100% / calc(100% - 3px) calc(100% * var(--coin)) repeat-y;
194
+ }
195
+ .coins:horizontal {
196
+ mask:
197
+ linear-gradient(to right, #0000 3px, #000 0 calc(100% - 1px), #0000 0) 0 0 / calc(100% * var(--coin)) 100%,
198
+ linear-gradient(to right, #0000 2px, #000 0) 0 50% / calc(100% * var(--coin)) calc(100% - 3px) repeat-x;
199
+ }
200
+ .name { --rhp-label-size: 13px; font-weight: 500; }
201
+ .name .amt { font-weight: 700; color: var(--rhp-series-1); font-variant-numeric: tabular-nums; }
202
+ .name:vertical .m, .name:vertical .amt { display: block; text-align: center; line-height: 1.3; }
203
+ .name:horizontal .m { display: inline-block; width: 2.4em; }
204
+ .dated .name:horizontal .m { width: 3.8em; }
205
+ .name:horizontal .amt { display: inline-block; min-width: 4.6em; text-align: right; }
206
+ .total { font-size: 12px; font-weight: 600; color: var(--rhp-muted); font-variant-numeric: tabular-nums; }
207
+ .most .total { font-weight: 700; color: var(--rhp-ink); }
208
+ `,
209
+ }, (d) => html`
210
+ <div class=${() => ["month", d.most && "most", DATED && "dated"].filter(Boolean).join(" ")}
211
+ aria-label=${() => `${longYear.format(d.date)}: ${money.format(d.total)} in all. ` + RANKED.map((c) => `${CATEGORIES[c]} ${money.format(d.amounts[c])}`).join(", ")}>
212
+ <${Label} edge="start" class="name"><span class="m">${() => d.month}</span><span class="amt">${() => amount(d.lit)}</span><//>
213
+ <${Bar} to=${() => d.lit} color="series-1" class="coins" style=${() => ({ "--coin": coin(d.lit) })} />
214
+ <${Bar} from=${() => d.lit} to=${() => d.total} color="series-2" class="coins" style=${() => ({ "--coin": coin(d.total - d.lit) })} />
215
+ <${Label} at=${() => d.total} class="total">${() => amount(d.total)}<//>
216
+ </div>`);
217
+
218
+ // The counter the coins stand on: one thick line at zero across the plot
219
+ const Counter = slat({ room: {}, css: `.counter { --rhp-tick-width: 3px; }` }, () => html`
220
+ <div><${Tick} at=${0} thick=${1} class="counter" /></div>`);
221
+
222
+ // Chart component: the picked category is the app's state. The months stand as columns while each has 44px or more,
223
+ // and lie as rows when they do not (a phone, or many months).
224
+ function Spending() {
225
+ const [pick, setPick] = createSignal(SWING);
226
+ const lit = createMemo(() => AMOUNTS.map((a) => a[pick()]));
227
+ const [width, setWidth] = createSignal(0);
228
+ const upright = () => width() - 42 >= MONTHS.length * 44;
229
+ let stage;
230
+ onMount(() => {
231
+ setWidth(stage.clientWidth);
232
+ const watch = new ResizeObserver(() => setWidth(stage.clientWidth));
233
+ watch.observe(stage);
234
+ onCleanup(() => watch.disconnect());
235
+ });
236
+ const most = Math.max(...TOTALS);
237
+ // What the picked category came to. Every category's line is laid in one grid cell and only the picked one shows,
238
+ // so the cell is as tall as the longest line and the chart under it never moves.
239
+ const said = (c) => {
240
+ const high = Math.max(...column(c));
241
+ return `${money.format(YEAR[c])} in all (${percent.format(YEAR[c] / ALL)}), most ${when(column(c), high)}: ${money.format(high)}.`;
242
+ };
243
+ return html`
244
+ <${Poster}
245
+ kicker=${`Monthly spending · ${shortYear.format(DATES[0])} to ${shortYear.format(DATES.at(-1))}`}
246
+ title=${headline}
247
+ dek=${`What went out each month, ${RANGE}, piled in coins of ${money.format(COIN)} on one counter.`}
248
+ note=${NOTE}>
249
+ <p class="average"><b>${money.format(AVERAGE)}</b><span>an average month</span><span>${money.format(ALL)} over ${MONTHS.length} months</span></p>
250
+ <div class="rail">
251
+ <p class="hint" id="pick-hint">Pick a category: its coins go in copper at the foot of each pile</p>
252
+ <div class="picker" role="group" aria-labelledby="pick-hint">
253
+ ${RANKED.map((c) => html`
254
+ <button type="button" aria-pressed=${() => pick() === c} onClick=${() => setPick(c)}>
255
+ <i class="coin" aria-hidden="true"></i><span>${CATEGORIES[c]}</span><span class="sum">${money.format(YEAR[c])}</span>
256
+ </button>`)}
257
+ </div>
258
+ <p class="readout" aria-live="polite">
259
+ ${RANKED.map((c) => html`<span class=${() => (pick() === c ? "" : "off")}><b>${CATEGORIES[c]}</b>: ${said(c)}</span>`)}
260
+ </p>
261
+ </div>
262
+ <div class="stage" ref=${(el) => (stage = el)}>
263
+ <${Chart} orientation=${() => (upright() ? "vertical" : "horizontal")} height=${360}
264
+ scale=${[scale.min, scale.max]} ticks=${() => (width() < 360 ? scale.ticks.filter((t, i) => i % 2 === 0) : scale.ticks)} format=${(v) => compact.format(v)}
265
+ label=${`Spending each month, ${RANGE}, in ${CURRENCY}`} theme=${THEME}>
266
+ <${Plot} month=${LABELS} date=${DATES} amounts=${AMOUNTS} total=${TOTALS} lit=${lit} most=${(d) => d.total === most}>${Month}<//>
267
+ <${Plot} overlap=${true} slats=${1} style=${{ "pointer-events": "none" }}>${Counter}<//>
268
+ <//>
269
+ </div>
270
+ <//>`;
271
+ }
272
+
273
+ render(() => html`<${Spending} />`, document.getElementById("chart"));
274
+ </script>
275
+ </body>
276
+ </html>