@bezda/rhp-mcp 0.0.0-stage → 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -2
  3. package/bin/rhp-mcp.js +125 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +363 -0
  6. package/skill/recipes/area.html +150 -0
  7. package/skill/recipes/bar.html +139 -0
  8. package/skill/recipes/box-plot.html +158 -0
  9. package/skill/recipes/bubble.html +182 -0
  10. package/skill/recipes/bullet.html +132 -0
  11. package/skill/recipes/candlestick.html +173 -0
  12. package/skill/recipes/column.html +125 -0
  13. package/skill/recipes/diverging-bars.html +144 -0
  14. package/skill/recipes/donut.html +225 -0
  15. package/skill/recipes/dumbbell.html +131 -0
  16. package/skill/recipes/gantt.html +186 -0
  17. package/skill/recipes/grouped-bars.html +136 -0
  18. package/skill/recipes/heatmap.html +177 -0
  19. package/skill/recipes/histogram.html +193 -0
  20. package/skill/recipes/line.html +164 -0
  21. package/skill/recipes/live.html +249 -0
  22. package/skill/recipes/lollipop.html +146 -0
  23. package/skill/recipes/multi-line.html +223 -0
  24. package/skill/recipes/pyramid.html +156 -0
  25. package/skill/recipes/race.html +272 -0
  26. package/skill/recipes/radial-bars.html +151 -0
  27. package/skill/recipes/scatter.html +193 -0
  28. package/skill/recipes/slope.html +193 -0
  29. package/skill/recipes/sparklines.html +169 -0
  30. package/skill/recipes/stacked-100.html +152 -0
  31. package/skill/recipes/stacked-bars.html +144 -0
  32. package/skill/recipes/strip.html +215 -0
  33. package/skill/recipes/violin.html +163 -0
  34. package/skill/recipes/waffle.html +159 -0
  35. package/skill/recipes/waterfall.html +121 -0
  36. package/skill/references/api.md +1556 -0
  37. package/skill/references/design.md +763 -0
  38. package/skill/references/environments.md +1570 -0
  39. package/skill/references/forms.md +160 -0
  40. package/skill/references/interaction.md +792 -0
  41. package/skill/references/pitfalls.md +157 -0
  42. package/src/check/README.md +163 -0
  43. package/src/check/browser.js +92 -0
  44. package/src/check/build.js +439 -0
  45. package/src/check/colors.js +324 -0
  46. package/src/check/findings.js +247 -0
  47. package/src/check/index.js +419 -0
  48. package/src/check/inpage.js +1268 -0
  49. package/src/check/interact.js +266 -0
  50. package/src/check/png.js +98 -0
  51. package/src/check/report.js +103 -0
  52. package/src/check/source.js +387 -0
  53. package/src/check/sourcemap.js +75 -0
  54. package/src/guard/note.js +30 -0
  55. package/src/guard/render.js +17 -0
  56. package/src/guard/rhp.js +608 -0
  57. package/src/guard/rules.js +125 -0
  58. package/src/server.js +265 -0
  59. package/src/skill.js +140 -0
@@ -0,0 +1,225 @@
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>Who ships the world's phones</title>
7
+ <meta name="description" content="Donut chart: a few parts of one whole, each labeled inside its slice (or in a key under the donut when its label does not fit), the total in the middle, and the slice the reader points at, taps or reaches with the keyboard read out in its place. Use it for two to six parts of a total, one or two of them the story.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400..800&family=Geist+Mono:wght@500&display=swap">
11
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
12
+ <style>
13
+ * { box-sizing: border-box; }
14
+ body { margin: 0; padding: 48px 16px; background: #cdd0d4; font-family: Geist, "Helvetica Neue", Arial, sans-serif; }
15
+ /* The poster: a phone's screen, white glass with a status bar, and two brands in color */
16
+ .poster { position: relative; display: grid; max-width: 1000px; margin: 0 auto; padding: 64px 52px 34px; border-radius: 36px; color: #16181b;
17
+ background: radial-gradient(120% 80% at 70% 0%, #ffffff, #f1f1ef 60%, #e6e7e8);
18
+ box-shadow: 0 0 0 1px #b9bdc3, 0 0 0 9px #23262b, 0 30px 60px -30px rgb(20 22 26 / .7); }
19
+ .poster > * { min-width: 0; }
20
+ .poster::before { content: "9:41"; position: absolute; top: 22px; left: 40px; font: 600 14px/1 Geist, Arial, sans-serif; }
21
+ .poster::after { content: ""; position: absolute; top: 22px; right: 40px; width: 62px; height: 13px;
22
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 62 13'%3E%3Cg fill='%2316181b'%3E%3Crect x='0' y='9' width='3' height='4' rx='1'/%3E%3Crect x='5' y='6' width='3' height='7' rx='1'/%3E%3Crect x='10' y='3' width='3' height='10' rx='1'/%3E%3Crect x='15' y='0' width='3' height='13' rx='1'/%3E%3Crect x='35' y='2.5' width='18' height='8' rx='2'/%3E%3Crect x='59' y='4.5' width='2' height='4' rx='1'/%3E%3C/g%3E%3Crect x='32.5' y='.5' width='25' height='12' rx='3.5' fill='none' stroke='%2316181b' opacity='.45'/%3E%3C/svg%3E") no-repeat; }
23
+ .poster figcaption { display: grid; gap: 14px; margin-bottom: 26px; }
24
+ .poster .kicker { font: 500 12px/1.3 "Geist Mono", ui-monospace, Menlo, monospace; letter-spacing: .12em; text-transform: uppercase; color: #5d636c; }
25
+ .poster .headline { font-size: 54px; font-weight: 800; line-height: .98; letter-spacing: -.04em; text-wrap: balance; }
26
+ .poster .headline em { font-style: normal; color: #cf3b20; }
27
+ .poster .headline em + em { color: #2954c8; }
28
+ .poster .dek { max-width: 40ch; font-size: 17px; line-height: 1.5; color: #4a4f57; }
29
+ .poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #5d636c; }
30
+ .chart { width: 100%; max-width: 560px; justify-self: center; }
31
+ /* The key: the slices whose labels do not fit inside them at this size, in the ring's order */
32
+ .key { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 16px; margin: 14px 0 0; padding: 0; list-style: none; font-size: 13px; color: #4a4f57; }
33
+ .key li { display: flex; align-items: center; gap: 6px; cursor: pointer; }
34
+ .key i { width: 11px; height: 11px; border-radius: 3px; }
35
+ .key b { color: #16181b; }
36
+ /* The middle of the ring: the total, or the company being read */
37
+ .hub { position: relative; z-index: 2; display: grid; place-content: center; text-align: center; pointer-events: none; }
38
+ .hub small { font-size: clamp(11px, 2.6cqw, 14px); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #5d636c; }
39
+ .hub b { font-size: clamp(34px, 12cqw, 72px); font-weight: 750; line-height: 1; letter-spacing: -.045em; }
40
+ .hub span { font-size: clamp(11px, 2.6cqw, 14px); color: #4a4f57; }
41
+ @media (min-width: 860px) {
42
+ .poster { grid-template-columns: minmax(0, 1fr) minmax(0, 1.12fr); grid-template-rows: auto 1fr; column-gap: 48px; }
43
+ .poster figcaption { grid-column: 1; grid-row: 1; }
44
+ .poster .chart { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
45
+ .poster .key { grid-column: 2; grid-row: 3; }
46
+ .poster .note { grid-column: 1; grid-row: 2; align-self: end; }
47
+ }
48
+ @media (max-width: 520px) {
49
+ body { padding: 24px 16px 32px; }
50
+ .poster { padding: 54px 20px 22px; border-radius: 30px; }
51
+ .poster::before { left: 26px; }
52
+ .poster::after { right: 26px; }
53
+ .poster .headline { font-size: 36px; }
54
+ .poster .dek { font-size: 15px; }
55
+ }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <div id="chart"></div>
60
+ <script type="module">
61
+ import { Chart, Plot, Bar, Label, Poster, slat, shares, stackUp, html, render, createSignal, onMount, onCleanup } from "@bezda/rhp/standalone";
62
+
63
+ // Data: smartphones shipped worldwide in 2024, in millions, by company (the parent of each company's brands), from
64
+ // IDC's preliminary results of January 2025. Replace with your own.
65
+ // A company with a `color` is part of the story; the others are drawn gray, and their names carry them.
66
+ // A company with no phones has no slice, so it is left out (the keyboard would stop on a slice that isn't drawn).
67
+ const COMPANIES = [
68
+ { company: "Apple", units: 232.1, color: "#cf3b20" },
69
+ { company: "Samsung", units: 223.4, color: "#2954c8" },
70
+ { company: "Xiaomi", units: 168.5 },
71
+ { company: "Transsion", units: 106.9 },
72
+ { company: "OPPO", units: 104.8 },
73
+ { company: "Others", units: 402.9, color: "#b9bdc3" },
74
+ ].filter((c) => c.units > 0);
75
+ const GRAY = "#7d848e";
76
+ const TOTAL = COMPANIES.reduce((sum, c) => sum + c.units, 0);
77
+ const { from, to } = stackUp(shares(COMPANIES.map((c) => c.units))); // each slice's start and end, 0 to 100
78
+ const fill = COMPANIES.map((c) => c.color ?? GRAY);
79
+
80
+ const number = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
81
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 });
82
+ // White text on a dark fill, ink on a light one
83
+ const textOn = (hex) => {
84
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
85
+ return r * 0.299 + g * 0.587 + b * 0.114 > 128 ? "#16181b" : "#ffffff";
86
+ };
87
+
88
+ // A slice is an SVG path in a 100 x 100 box around the middle: a ring from radius 29.5 to 47.5 (2.5 left for a slice
89
+ // to slide out), angles in turns from twelve o'clock. Each edge is pushed aside along its length, so the gap is as
90
+ // wide at the hole as at the rim, and a stroke in the fill's color rounds the corners.
91
+ const TAU = Math.PI * 2;
92
+ const ROUND = 1.6, GAP = 1.6, RIM = 47.5, HOLE = 29.5;
93
+ const OUTER = RIM - ROUND, INNER = HOLE + ROUND, PUSH = GAP / 2 + ROUND;
94
+ const xy = (t, r) => `${r * Math.sin(t * TAU)},${-r * Math.cos(t * TAU)}`;
95
+ const aside = (r) => Math.asin(Math.min(1, PUSH / r)) / TAU;
96
+ const big = (turns) => (turns > 0.5 ? 1 : 0);
97
+ const ring = (start, end) => {
98
+ const a = start / 100, b = end / 100, o = aside(OUTER), i = aside(INNER);
99
+ if (b - a <= 2 * o) return ""; // too thin to draw
100
+ const rim = `M${xy(a + o, OUTER)}A${OUTER},${OUTER} 0 ${big(b - a - 2 * o)} 1 ${xy(b - o, OUTER)}`;
101
+ // A slice too narrow to reach the hole ends where its two edges meet
102
+ if (b - a <= 2 * i) return `${rim}L${xy((a + b) / 2, PUSH / Math.sin((b - a) * Math.PI))}Z`;
103
+ return `${rim}L${xy(b - i, INNER)}A${INNER},${INNER} 0 ${big(b - a - 2 * i)} 0 ${xy(a + i, INNER)}Z`;
104
+ };
105
+
106
+ // Slat type: one company. The Bar keeps rhp's numbers and gives up its box to the SVG slice; --rhp-lo and --rhp-hi
107
+ // (the Bar's ends, 0 to 1) give the angle a slice slides out along, and --rhp-p (the Label's value) its label's angle.
108
+ // Keyboard focus draws a ring around the slice: an ink line outside a line of the paper, so it shows on any fill.
109
+ // A label that does not fit inside its slice (cramped) is hidden, and the key under the donut names the slice.
110
+ const Company = slat({
111
+ room: { start: 0, end: 0 },
112
+ css: `
113
+ .slice { container-type: size; pointer-events: none; }
114
+ .slice:focus-visible { outline: none; }
115
+ .wedge { inset: 0; width: 100%; height: 100%; translate: none; background: none; --mid: calc((var(--rhp-lo) + var(--rhp-hi)) / 2); }
116
+ .wedge svg { display: block; width: 100%; height: 100%; overflow: visible; }
117
+ .wedge path { fill: var(--rhp-color); stroke: var(--rhp-color); stroke-width: 3.2; stroke-linejoin: round; pointer-events: auto; cursor: pointer; transition: translate .2s ease-out, filter .2s; }
118
+ .wedge .ring { display: none; }
119
+ .slice:focus-visible .ring { display: inline; fill: none; stroke: var(--rhp-ink); stroke-width: 6.4; pointer-events: none; }
120
+ .slice:focus-visible .ring + .ring { stroke: var(--rhp-surface); stroke-width: 4.8; }
121
+ .tag { left: 50%; top: 50%; translate: -50% -50%; padding: 0; text-align: center; color: var(--text); pointer-events: none;
122
+ transform: rotate(calc(var(--rhp-p) * 1turn)) translateY(-38.5cqmin) rotate(calc(var(--rhp-p) * -1turn)); }
123
+ .tag > span { display: block; transition: translate .2s ease-out; }
124
+ .tag i { display: block; font-size: 12px; font-style: normal; font-weight: 600; }
125
+ .tag b { display: block; font-size: 16px; font-weight: 750; letter-spacing: -.02em; }
126
+ .cramped .tag { visibility: hidden; }
127
+ /* Lengths on an SVG path are the viewBox's units: 2.4 is 2.4% of the donut */
128
+ .on .wedge path { translate: calc(sin(var(--mid) * 1turn) * 2.4px) calc(cos(var(--mid) * 1turn) * -2.4px); filter: drop-shadow(0 1.2px 1.6px rgb(22 24 27 / .3)); }
129
+ .on .tag > span { translate: calc(sin(var(--rhp-p) * 1turn) * 2.4cqmin) calc(cos(var(--rhp-p) * 1turn) * -2.4cqmin); }
130
+ @container (max-width: 400px) { .tag i { font-size: 11px; } .tag b { font-size: 14px; } }
131
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
132
+ `,
133
+ }, (d) => html`
134
+ <div class=${() => ["slice", d.on && "on", d.cramped && "cramped"].filter(Boolean).join(" ")} data-row=${() => d.index}>
135
+ <${Bar} from=${() => d.from} to=${() => d.to} color=${() => d.fill} class="wedge">
136
+ <svg viewBox="-50 -50 100 100" aria-hidden="true">
137
+ <path class="ring" d=${() => ring(d.from, d.to)} /><path class="ring" d=${() => ring(d.from, d.to)} /><path d=${() => ring(d.from, d.to)} />
138
+ </svg>
139
+ <//>
140
+ <${Label} at=${() => (d.from + d.to) / 2} class="tag" style=${() => ({ "--text": textOn(d.fill) })} aria-hidden="true">
141
+ <span><i>${() => d.company}</i><b>${() => percent.format(d.units / TOTAL)}</b></span>
142
+ <//>
143
+ <span class="say">${() => `${d.company}: ${number.format(d.units)} million phones, ${percent.format(d.units / TOTAL)} of all`}</span>
144
+ </div>`);
145
+
146
+ // Chart component: the slice the reader points at, taps or reaches with the keyboard slides out, and the middle reads it
147
+ function PhoneMakers() {
148
+ const [on, setOn] = createSignal(null);
149
+ // Which slices' labels are cramped at this size: a label fits when each of its lines of text (as wide as its words,
150
+ // as tall as the line) lies inside the ring and between its slice's edges. Measured again when the donut's size
151
+ // changes and once the fonts have loaded.
152
+ const [cramped, setCramped] = createSignal([]);
153
+ let plot;
154
+ const fit = () => {
155
+ const box = plot.getBoundingClientRect();
156
+ const unit = box.width / 100; // the slices' units, in px
157
+ const words = document.createRange();
158
+ setCramped(COMPANIES.map((_, i) => {
159
+ const tag = plot.querySelector(`[data-row="${i}"] .tag`);
160
+ const slid = tag.firstElementChild.getBoundingClientRect(), at = tag.getBoundingClientRect(); // a picked slice's label slides out
161
+ return [...tag.querySelectorAll("i, b")].some((line) => {
162
+ words.selectNodeContents(line);
163
+ const { left, right } = words.getBoundingClientRect(), { top, bottom } = line.getBoundingClientRect();
164
+ return [[left, top], [right, top], [left, bottom], [right, bottom]].some(([x, y]) => {
165
+ x -= slid.left - at.left + box.left + box.width / 2;
166
+ y -= slid.top - at.top + box.top + box.height / 2;
167
+ const far = Math.hypot(x, y) / unit;
168
+ const turn = (Math.atan2(x, -y) / TAU + 1) % 1;
169
+ const edge = Math.asin(GAP / 2 / far) / TAU; // the gap beside a slice, as a turn at this distance
170
+ return far < HOLE || far > RIM || turn < from[i] / 100 + edge || turn > to[i] / 100 - edge;
171
+ });
172
+ });
173
+ }));
174
+ };
175
+ onMount(() => {
176
+ const watch = new ResizeObserver(fit);
177
+ watch.observe(plot);
178
+ document.fonts.ready.then(fit);
179
+ onCleanup(() => watch.disconnect());
180
+ });
181
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
182
+ const rowOf = (el) => {
183
+ const root = el.closest("[data-row]");
184
+ return root ? +root.dataset.row : null;
185
+ };
186
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
187
+ const pick = (e) => {
188
+ const row = rowOf(e.target);
189
+ if (row != null) setOn(row);
190
+ };
191
+ // Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
192
+ const leave = (e) => {
193
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
194
+ setOn(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
195
+ };
196
+ const company = () => (on() == null ? null : COMPANIES[on()]);
197
+ return html`
198
+ <${Poster}
199
+ kicker="Smartphones · shipments in 2024"
200
+ title=${html`<em>Apple</em> and <em>Samsung</em> ship more than a third of all smartphones`}
201
+ dek="Of 1.24 billion smartphones shipped in 2024, the two biggest companies shipped 455.5 million. The next three together shipped 380.2 million. Tap or point at a slice."
202
+ note="Source: IDC Worldwide Quarterly Mobile Phone Tracker, preliminary results for 2024 (January 2025), in millions of phones."
203
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
204
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
205
+ <${Chart} class="chart" aspect=${1} ticks=${false} label="Smartphones shipped in 2024 by company, as shares of the total"
206
+ theme=${{ font: "Geist, 'Helvetica Neue', Arial, sans-serif", ink: "#16181b", muted: "#5d636c", surface: "#f1f1ef" }}>
207
+ <div class="hub" aria-live="polite">
208
+ <small>${() => company()?.company ?? "All companies"}</small>
209
+ <b>${() => number.format(company() ? company().units : TOTAL / 1000)}</b>
210
+ <span>${() => (company() ? "million phones" : "billion phones")}</span>
211
+ </div>
212
+ <${Plot} overlap=${true} keyboard=${true} rows=${COMPANIES} from=${from} to=${to} fill=${fill} on=${(d) => on() === d.index}
213
+ cramped=${(d) => !!cramped()[d.index]} ref=${(el) => (plot = el)}>${Company}<//>
214
+ <//>
215
+ ${() => cramped().some(Boolean) && html`
216
+ <ul class="key" aria-hidden="true">
217
+ ${COMPANIES.map((c, i) => cramped()[i] && html`<li data-row=${i}><i style=${{ background: fill[i] }}></i>${c.company} <b>${percent.format(c.units / TOTAL)}</b></li>`)}
218
+ </ul>`}
219
+ <//>`;
220
+ }
221
+
222
+ render(() => html`<${PhoneMakers} />`, document.getElementById("chart"));
223
+ </script>
224
+ </body>
225
+ </html>
@@ -0,0 +1,131 @@
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>Ethiopia gained 21 years of life</title>
7
+ <meta name="description" content="Dumbbell chart of life expectancy in 1990 and 2020 for ten countries: a ring for the first value, a dot for the second and a bar between them, with a switch that re-sorts the countries by level or by change. Use it to compare two values per item and the gap between them.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400..900&display=swap">
11
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
12
+ <style>
13
+ :root { color-scheme: light; }
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; padding: 48px 16px; background: #e6e4de; font-family: "Schibsted Grotesk", "Helvetica Neue", Arial, sans-serif; color: #18181b; }
16
+ /* The poster: a health report on warm white, black type, teal for years gained and red for years lost */
17
+ .poster { max-width: 900px; margin: 0 auto; padding: 40px 48px 26px; border-top: 8px solid #18181b; line-height: 1.45;
18
+ background: #f7f6f2; box-shadow: 0 18px 40px -26px rgb(40 36 30 / .55); }
19
+ .poster figcaption { display: grid; gap: 10px; margin-bottom: 8px; }
20
+ .poster .kicker { font-size: 12.5px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #006b58; }
21
+ .poster .headline { font-size: 54px; font-weight: 850; line-height: 1; letter-spacing: -.03em; text-wrap: balance; }
22
+ .poster .dek { max-width: 60ch; font-size: 16px; color: #4b4b52; }
23
+ .poster .dek i { display: inline-block; width: 11px; height: 11px; margin: 0 4px 0 2px; border-radius: 50%; vertical-align: -1px; }
24
+ .poster .dek i.then { box-shadow: inset 0 0 0 2px #18181b; }
25
+ .poster .dek i.now { background: #00836c; }
26
+ .poster .note { display: block; margin-top: 14px; font-size: 12px; color: #6b6b72; }
27
+ /* A pulse across the page: the one ornament */
28
+ .pulse { height: 28px; margin: 14px 0 12px; background: linear-gradient(#18181b, #18181b) 0 50% / 100% 1.5px no-repeat; }
29
+ .pulse svg { display: block; width: 120px; height: 28px; margin-left: 9%; background: #f7f6f2; }
30
+ /* The order switch */
31
+ .order { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0 0 12px; font-size: 13px; font-weight: 600; color: #4b4b52; }
32
+ .order button { padding: 6px 12px 5px; border: 1.5px solid #18181b; border-radius: 99px; background: none; color: #18181b; cursor: pointer;
33
+ font: 700 13px/1.2 "Schibsted Grotesk", "Helvetica Neue", Arial, sans-serif; }
34
+ .order button[aria-pressed="true"] { background: #18181b; color: #f7f6f2; }
35
+ .order button:focus-visible { outline: 2px solid #00836c; outline-offset: 2px; }
36
+ .axis-name { margin: 4px 0 0; font-size: 12px; font-weight: 600; color: #6b6b72; text-align: right; }
37
+ @media (max-width: 600px) {
38
+ body { padding: 16px; }
39
+ .poster { padding: 26px 16px 18px; }
40
+ .poster .kicker { font-size: 11px; letter-spacing: .12em; }
41
+ .poster .headline { font-size: 34px; }
42
+ .poster .dek { font-size: 14.5px; }
43
+ .pulse svg { margin-left: 4%; }
44
+ .order span { flex-basis: 100%; } /* the label above, both buttons on one line */
45
+ }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div id="chart"></div>
50
+ <script type="module">
51
+ import { Chart, Plot, Bar, Dot, Label, Poster, slat, sortBy, nice, html, render, createSignal } from "@bezda/rhp/standalone";
52
+
53
+ // Data: life expectancy at birth in years, 1990 and 2020, from UN World Population Prospects 2024 via Our World in Data. Replace with your own.
54
+ const COUNTRIES = [
55
+ { country: "Japan", y1990: 79.0, y2020: 84.7 },
56
+ { country: "South Korea", y1990: 71.0, y2020: 83.7 },
57
+ { country: "China", y1990: 68.2, y2020: 78.0 },
58
+ { country: "United States", y1990: 75.4, y2020: 77.0 },
59
+ { country: "Brazil", y1990: 65.9, y2020: 74.5 },
60
+ { country: "Bangladesh", y1990: 55.8, y2020: 71.4 },
61
+ { country: "India", y1990: 58.6, y2020: 70.2 },
62
+ { country: "Ethiopia", y1990: 45.1, y2020: 66.0 },
63
+ { country: "Lesotho", y1990: 59.3, y2020: 55.1 },
64
+ { country: "Nigeria", y1990: 45.7, y2020: 53.1 },
65
+ ];
66
+ const ORDERS = [ // the switch's choices; each key is the field of a row to sort by
67
+ { key: "gain", name: "Years gained" },
68
+ { key: "y2020", name: "Life expectancy in 2020" },
69
+ ];
70
+
71
+ const ROWS = COUNTRIES.map((c) => ({ ...c, gain: Math.round((c.y2020 - c.y1990) * 10) / 10 }));
72
+ const all = ROWS.flatMap((c) => [c.y1990, c.y2020]);
73
+ const range = nice(Math.min(...all), Math.max(...all)); // 40 to 90, ticks every 10
74
+ const years = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
75
+ const change = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: "exceptZero" });
76
+
77
+ // Slat type: a country; a ring at 1990, a dot at 2020 and a bar between them, teal when it rose and red when it fell
78
+ const Country = slat({
79
+ thickness: 44,
80
+ room: "auto",
81
+ css: `
82
+ .name { font-size: 15px; font-weight: 700; --rhp-label-gap: 14px; }
83
+ .span { --rhp-radius: 99px; background: linear-gradient(var(--rhp-toward-end), #c9c7c0, var(--move)); }
84
+ .then { background: var(--rhp-surface); box-shadow: inset 0 0 0 2.5px #18181b; }
85
+ .now { background: var(--move); box-shadow: 0 0 0 2px var(--rhp-surface); }
86
+ .value { min-width: calc(4.4em + 12px); font-size: 15px; font-weight: 800; font-variant-numeric: normal; --rhp-label-gap: 12px; } /* fits either order's text */
87
+ .value small { font-size: 12px; font-weight: 600; color: var(--rhp-muted); }
88
+ .country { --move: #00836c; }
89
+ .country.fell { --move: #c8402c; }
90
+ @container (max-width: 480px) { .name { font-size: 13px; } .value { min-width: calc(3em + 10px); font-size: 13px; --rhp-label-gap: 10px; } .value small { display: none; } }
91
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
92
+ `,
93
+ }, (d) => html`
94
+ <div class=${() => (d.gain < 0 ? "country fell" : "country")}>
95
+ <${Label} edge="start" class="name">${() => d.country}<//>
96
+ <${Bar} from=${() => d.y1990} to=${() => d.y2020} thick="4px" class="span" />
97
+ <${Dot} at=${() => d.y1990} size="13px" class="then" />
98
+ <${Dot} at=${() => d.y2020} size="13px" class="now" />
99
+ <${Label} edge="end" class="value">${() => (d.by === "gain" ? change.format(d.gain) : years.format(d.y2020))}<small> yrs</small><//>
100
+ <span class="say">${() => `: ${years.format(d.y1990)} years in 1990, ${years.format(d.y2020)} in 2020`}</span>
101
+ </div>`);
102
+
103
+ // Chart component: the switch sorts the countries by years gained or by life expectancy in 2020; each slat slides to its place
104
+ function LifeExpectancy() {
105
+ const [by, setBy] = createSignal("gain");
106
+ return html`
107
+ <${Poster}
108
+ kicker="Life expectancy at birth, 1990 and 2020"
109
+ title="Ethiopia gained 21 years of life; the US gained under 2"
110
+ dek=${html`<span>The years a newborn could expect to live, <i class="then"></i>in 1990 and <i class="now"></i>in 2020. Nine of these ten countries gained, Ethiopia most of all; Lesotho lost four years.</span>`}
111
+ note="Source: UN World Population Prospects 2024, via Our World in Data. 2020 includes the first year of COVID-19.">
112
+ <div class="pulse" aria-hidden="true">
113
+ <svg viewBox="0 0 120 28"><path d="M0 14H38L44 14L49 5L55 25L60 1L64 17L68 14H120" fill="none" stroke="#00836c" stroke-width="2" stroke-linejoin="round" /></svg>
114
+ </div>
115
+ <div class="order" role="group" aria-label="Order the countries by">
116
+ <span>Order by</span>
117
+ ${ORDERS.map((o) => html`
118
+ <button type="button" aria-pressed=${() => String(by() === o.key)} onClick=${() => setBy(o.key)}>${o.name}</button>`)}
119
+ </div>
120
+ <${Chart} scale=${[range.min, range.max]} ticks=${range.ticks} label="Life expectancy at birth in 1990 and 2020, ten countries"
121
+ theme=${{ font: '"Schibsted Grotesk", "Helvetica Neue", Arial, sans-serif', ink: "#18181b", muted: "#6b6b72", grid: "#e2e0da", surface: "#f7f6f2" }}>
122
+ <${Plot} rows=${ROWS} key="country" by=${by} order=${sortBy((d) => d[by()], "desc")}>${Country}<//>
123
+ <//>
124
+ <p class="axis-name">Life expectancy at birth, in years</p>
125
+ <//>`;
126
+ }
127
+
128
+ render(() => html`<${LifeExpectancy} />`, document.getElementById("chart"));
129
+ </script>
130
+ </body>
131
+ </html>
@@ -0,0 +1,186 @@
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>The build is a week behind</title>
7
+ <meta name="description" content="Gantt chart of a twelve-week product launch: a bar per task from its start to its end, the part done, the work that is late, a milestone, a today line drawn by an overlay Plot and week ticks drawn by a Scale, with a readout of the picked task and what it depends on. Use it for tasks over time and a schedule's progress.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400..700&display=swap">
11
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
12
+ <style>
13
+ :root { color-scheme: light; }
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; padding: 56px 16px 48px; background: #3a3d42; font-family: "Space Grotesk", "Helvetica Neue", Arial, sans-serif; color: #1c1f24; }
16
+ /* The poster: a sheet of dotted planner paper, held to the wall by a strip of tape */
17
+ .poster { position: relative; max-width: 1000px; margin: 0 auto; padding: 44px 48px 26px; border-radius: 3px; line-height: 1.45;
18
+ background: radial-gradient(circle, rgb(28 31 36 / .2) 1px, transparent 1.3px) 0 0 / 18px 18px, #fbfbf8;
19
+ box-shadow: 0 24px 44px -20px rgb(0 0 0 / .6); }
20
+ .poster::before { content: ""; position: absolute; top: -14px; left: 50%; width: 150px; height: 30px; translate: -50% 0; rotate: -2deg;
21
+ background: repeating-linear-gradient(135deg, rgb(255 255 255 / .35) 0 6px, transparent 6px 12px), rgb(214 212 200 / .85);
22
+ box-shadow: 0 1px 2px rgb(0 0 0 / .15); }
23
+ .poster figcaption { display: grid; gap: 10px; margin-bottom: 18px; }
24
+ .poster .kicker { font-size: 12.5px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: #5d6168; }
25
+ .poster .headline { font-size: 52px; font-weight: 700; line-height: 1.02; letter-spacing: -.035em; text-wrap: balance; }
26
+ .poster .dek { max-width: 62ch; font-size: 16px; color: #474b52; }
27
+ .poster .dek i { display: inline-block; width: 18px; height: 9px; margin: 0 4px 0 2px; border-radius: 5px; vertical-align: 0; }
28
+ .poster .dek i.done { background: #1c1f24; }
29
+ .poster .dek i.late { background: #d93a32; }
30
+ .poster .dek i.todo { background: #dcdad3; }
31
+ .poster .note { display: block; margin-top: 14px; font-size: 12px; color: #63676e; }
32
+ /* The readout: the picked task in one line (two on a phone), so the chart never moves */
33
+ .readout { min-height: 2.2em; margin: 0 0 18px; padding: 9px 12px; border-radius: 6px; background: #1c1f24; color: #f4f4f1; font-size: 15px; }
34
+ .readout b { font-weight: 700; }
35
+ .readout strong { font-weight: 400; }
36
+ .readout .late { color: #ff8a80; font-weight: 700; }
37
+ .readout .after { color: #b9bcc2; }
38
+ /* On a phone the chart is taller than half the screen: the readout stays at the top of the screen while the chart
39
+ scrolls under it */
40
+ @media (max-width: 600px) {
41
+ body { padding: 40px 16px 16px; }
42
+ .poster { padding: 30px 16px 18px; }
43
+ .poster .kicker { font-size: 11px; letter-spacing: .1em; }
44
+ .poster .headline { font-size: 32px; }
45
+ .poster .dek { font-size: 14.5px; }
46
+ .readout { position: sticky; top: 8px; z-index: 2; min-height: 4.6em; font-size: 14px; }
47
+ }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div id="chart"></div>
52
+ <script type="module">
53
+ import { Chart, Plot, Scale, Bar, Dot, Tick, Label, Poster, Show, slat, every, html, render, createSignal } from "@bezda/rhp/standalone";
54
+
55
+ // Data: a product launch plan; weeks counted from START, the share of each task done, and the tasks it waits on. Illustrative. Replace with your own.
56
+ const START = "2026-09-07"; // Monday of week 0
57
+ const TODAY = "2026-10-21";
58
+ const WEEKS = 12;
59
+ const TASKS = [
60
+ { task: "Research & scope", team: "Product", start: 0, end: 2, done: 1, after: [] },
61
+ { task: "Design", team: "Design", start: 1, end: 4, done: 1, after: [] },
62
+ { task: "Build the app", team: "Engineering", start: 3, end: 8, done: 0.45, after: ["Design"] },
63
+ { task: "Website & docs", team: "Marketing", start: 5, end: 9, done: 0.35, after: ["Design"] },
64
+ { task: "Beta testing", team: "QA", start: 7, end: 10, done: 0, after: ["Build the app"] },
65
+ { task: "Launch campaign", team: "Marketing", start: 8, end: 12, done: 0, after: ["Website & docs"] },
66
+ { task: "Fix & polish", team: "Engineering", start: 8.5, end: 11, done: 0, after: ["Beta testing"] },
67
+ { task: "Launch day", team: "Everyone", start: 11.5, end: 11.5, done: 0, after: ["Fix & polish", "Launch campaign"] },
68
+ ];
69
+ const FOCUS = "Build the app"; // the task the readout opens on
70
+
71
+ const DAY = 864e5;
72
+ const now = (Date.parse(TODAY) - Date.parse(START)) / (7 * DAY); // today, in weeks
73
+ const dateOf = (week) => new Date(Date.parse(START) + week * 7 * DAY);
74
+ const short = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
75
+ const long = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" });
76
+ const percent = new Intl.NumberFormat("en-US", { style: "percent" });
77
+ // Each task's progress: where its done part ends, and how many weeks of work should be done by today but aren't
78
+ const ROWS = TASKS.map((t) => {
79
+ const reached = t.start + t.done * (t.end - t.start);
80
+ const late = t.end > t.start && t.start < now ? Math.max(0, Math.min(now, t.end) - reached) : 0;
81
+ return { ...t, reached, late };
82
+ });
83
+ const related = (a, b) => a.task === b.task || a.after.includes(b.task) || b.after.includes(a.task);
84
+ const opening = Math.max(0, ROWS.findIndex((t) => t.task === FOCUS));
85
+ const span = (t) => (t.end > t.start ? `${short.format(dateOf(t.start))} to ${short.format(dateOf(t.end - 1 / 7))}` : short.format(dateOf(t.start)));
86
+ const statusOf = (t) => {
87
+ if (t.done === 1) return "done";
88
+ if (t.late >= 0.5) return `${percent.format(t.done)} done, ${Math.round(t.late)} week${Math.round(t.late) === 1 ? "" : "s"} behind`;
89
+ return t.start > now ? "not started" : `${percent.format(t.done)} done, on time`;
90
+ };
91
+
92
+ // Slat types: a week of the plan (from a Scale), a task, and the today line
93
+ const Week = slat({
94
+ room: { before: 26 },
95
+ css: `
96
+ .line { --rhp-tick-width: 1px; background: var(--rhp-grid); }
97
+ .date { top: -22px; translate: -50% 0; padding: 0; font-size: 12px; font-weight: 600; color: var(--rhp-muted); }
98
+ @container (max-width: 520px) { .odd .date { display: none; } }
99
+ `,
100
+ }, (w) => html`
101
+ <div class=${() => (w.at % 4 ? "week odd" : "week")}>
102
+ <${Tick} at=${() => w.at} thick=${1} class="line" />
103
+ <${Show} when=${() => w.at % 2 === 0 && !w.last}><${Label} at=${() => w.at} class="date">${() => short.format(dateOf(w.at))}<//><//>
104
+ </div>`);
105
+
106
+ const Task = slat({
107
+ thickness: 48,
108
+ room: { start: "auto", end: 16 },
109
+ css: `
110
+ .name { font-size: 15px; font-weight: 700; line-height: 1.2; --rhp-label-gap: 16px; }
111
+ .name small { display: block; font-size: 12px; font-weight: 500; color: var(--rhp-muted); }
112
+ .plan { --rhp-radius: 99px; background: #dcdad3; }
113
+ .done { --rhp-radius: 99px; background: #1c1f24; }
114
+ .late { --rhp-start-radius: 0px; --rhp-end-radius: 99px; background: #d93a32; }
115
+ .milestone { border-radius: 2px; rotate: 45deg; background: #1c1f24; box-shadow: 0 0 0 3px var(--rhp-surface); }
116
+ .task:focus-visible { outline: 2px solid #1c1f24; outline-offset: -2px; border-radius: 6px; }
117
+ .task.on { background: rgb(28 31 36 / .06); border-radius: 6px; }
118
+ .task.off > :not(.name) { opacity: .25; }
119
+ .task.off .name { color: var(--rhp-muted); }
120
+ @container (max-width: 520px) { .name { font-size: 13px; } .name small { font-size: 11px; } }
121
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
122
+ `,
123
+ }, (d) => html`
124
+ <div class=${() => ["task", d.picked === d.index && "on", d.picked != null && !related(d, d.focus) && "off"].filter(Boolean).join(" ")} data-row=${() => d.index}>
125
+ <${Label} edge="start" class="name">${() => d.task}<small>${() => d.team}</small><//>
126
+ <${Show} when=${() => d.end > d.start}>
127
+ <${Bar} from=${() => d.start} to=${() => d.end} thick="14px" class="plan" />
128
+ <${Bar} from=${() => d.start} to=${() => d.reached} thick="14px" class="done" />
129
+ <${Bar} from=${() => d.reached} to=${() => d.reached + d.late} thick="14px" class="late" />
130
+ <//>
131
+ <${Show} when=${() => d.end === d.start}><${Dot} at=${() => d.start} size="15px" class="milestone" /><//>
132
+ <span class="say">${() => `: ${span(d)}, ${statusOf(d)}`}</span>
133
+ </div>`);
134
+
135
+ const Today = slat({
136
+ room: { after: 30 },
137
+ css: `
138
+ .now { --rhp-tick-width: 2px; background: #d93a32; }
139
+ .flag { top: calc(100% + 8px); translate: -50% 0; padding: 3px 8px; border-radius: 99px; background: #d93a32; color: #fff;
140
+ font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
141
+ `,
142
+ }, () => html`<div><${Tick} at=${now} thick=${1} class="now" /><${Label} at=${now} class="flag">Today<//></div>`);
143
+
144
+ // Chart component: the picked task (pointer, tap or arrow keys) is read out, with the tasks it waits on and that wait on it
145
+ function LaunchPlan() {
146
+ const [picked, setPicked] = createSignal(null);
147
+ const shown = () => ROWS[picked() ?? opening];
148
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
149
+ const rowOf = (el) => {
150
+ const root = el.closest("[data-row]");
151
+ return root ? +root.dataset.row : null;
152
+ };
153
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
154
+ const pick = (e) => {
155
+ const row = rowOf(e.target);
156
+ if (row != null) setPicked(row);
157
+ };
158
+ // Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
159
+ const leave = (e) => {
160
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
161
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
162
+ };
163
+ return html`
164
+ <${Poster}
165
+ kicker=${`Launch plan · week ${Math.floor(now) + 1} of ${WEEKS}`}
166
+ title="The build is a week behind, and testing waits on it"
167
+ dek=${html`<span>Twelve weeks from scope to launch day, as of ${long.format(dateOf(now))}. Each bar is a task: <i class="done"></i>done, <i class="late"></i>should be done by now, <i class="todo"></i>still to do.</span>`}
168
+ note="Illustrative data. Point at a task or tap it to see what it waits on, or Tab into the chart and use the arrow keys."
169
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
170
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
171
+ <p class="readout">
172
+ <b>${() => shown().task}</b>, ${() => span(shown())}: <strong class=${() => (shown().late >= 0.5 ? "late" : "")}>${() => statusOf(shown())}</strong>${() => (shown().after.length ? html`<span class="after"> · after ${shown().after.join(" and ")}</span>` : "")}
173
+ </p>
174
+ <${Chart} scale=${[0, WEEKS]} label="Gantt chart of a twelve-week product launch plan, with today marked"
175
+ theme=${{ font: '"Space Grotesk", "Helvetica Neue", Arial, sans-serif', ink: "#1c1f24", muted: "#63676e", grid: "#e4e2dc", surface: "#fbfbf8" }}>
176
+ <${Scale} ticks=${every(1)}>${Week}<//>
177
+ <${Plot} rows=${ROWS} picked=${picked} focus=${shown} keyboard=${true}>${Task}<//>
178
+ <${Plot} overlap=${true} slats=${1} style=${{ "pointer-events": "none" }}>${Today}<//>
179
+ <//>
180
+ <//>`;
181
+ }
182
+
183
+ render(() => html`<${LaunchPlan} />`, document.getElementById("chart"));
184
+ </script>
185
+ </body>
186
+ </html>