@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.
- package/LICENSE +21 -0
- package/README.md +222 -2
- package/bin/rhp-mcp.js +129 -0
- package/package.json +62 -4
- package/skill/SKILL.md +450 -0
- package/skill/recipes/area.html +156 -0
- package/skill/recipes/bar.html +145 -0
- package/skill/recipes/box-plot.html +164 -0
- package/skill/recipes/bubble.html +188 -0
- package/skill/recipes/bullet.html +138 -0
- package/skill/recipes/candlestick.html +179 -0
- package/skill/recipes/column.html +138 -0
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +150 -0
- package/skill/recipes/donut.html +231 -0
- package/skill/recipes/dumbbell.html +137 -0
- package/skill/recipes/gantt.html +196 -0
- package/skill/recipes/grouped-bars.html +142 -0
- package/skill/recipes/heatmap.html +183 -0
- package/skill/recipes/histogram.html +199 -0
- package/skill/recipes/line.html +170 -0
- package/skill/recipes/live.html +255 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +152 -0
- package/skill/recipes/multi-line.html +228 -0
- package/skill/recipes/pyramid.html +162 -0
- package/skill/recipes/race.html +278 -0
- package/skill/recipes/radial-bars.html +157 -0
- package/skill/recipes/scatter.html +199 -0
- package/skill/recipes/silhouettes.html +352 -0
- package/skill/recipes/slope.html +199 -0
- package/skill/recipes/sparklines.html +175 -0
- package/skill/recipes/stacked-100.html +158 -0
- package/skill/recipes/stacked-bars.html +150 -0
- package/skill/recipes/strip.html +221 -0
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +169 -0
- package/skill/recipes/waffle.html +165 -0
- package/skill/recipes/waterfall.html +127 -0
- package/skill/references/api.md +1684 -0
- package/skill/references/design.md +740 -0
- package/skill/references/environments.md +1577 -0
- package/skill/references/forms.md +197 -0
- package/skill/references/interaction.md +794 -0
- package/skill/references/looks.md +267 -0
- package/skill/references/pitfalls.md +202 -0
- package/src/check/README.md +176 -0
- package/src/check/browser.js +92 -0
- package/src/check/build.js +439 -0
- package/src/check/colors.js +324 -0
- package/src/check/findings.js +247 -0
- package/src/check/index.js +508 -0
- package/src/check/inpage.js +1332 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +106 -0
- package/src/check/source.js +387 -0
- package/src/check/sourcemap.js +75 -0
- package/src/guard/note.js +30 -0
- package/src/guard/render.js +17 -0
- package/src/guard/rhp.js +612 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +266 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// The color probes, read from screenshots: text contrast (against what is behind each text, from a screenshot with the
|
|
2
|
+
// glyphs hidden), marks that barely show against what is behind them, and series colors that look alike to readers
|
|
3
|
+
// with color blindness. Then the color math they use: WCAG contrast, color blindness simulated (Machado, Oliveira and
|
|
4
|
+
// Fernandes 2009, at severity 1), and CIEDE2000 color differences.
|
|
5
|
+
import { pixelsIn } from "./png.js";
|
|
6
|
+
|
|
7
|
+
// The pixel of a list whose luminance is the median, and how much the luminance spreads (10th to 90th percentile)
|
|
8
|
+
function median(pixels) {
|
|
9
|
+
|
|
10
|
+
const ranked = pixels.map((p) => [luminance(p), p]).sort((a, b) => a[0] - b[0]);
|
|
11
|
+
const at = (q) => ranked[Math.min(ranked.length - 1, Math.floor(q * ranked.length))];
|
|
12
|
+
|
|
13
|
+
return { color: at(0.5)[1], spread: at(0.9)[0] - at(0.1)[0] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const scaled = (box, k) => ({ left: box.left * k, top: box.top * k, right: box.right * k, bottom: box.bottom * k });
|
|
17
|
+
|
|
18
|
+
const rgbOf = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
19
|
+
|
|
20
|
+
// The color closest to fg, on the way from fg to white or to black, that reaches the contrast asked for against bg (as
|
|
21
|
+
// a hex color, which still reaches it once rounded). Away from bg first (a dark text gets darker); when that end can't
|
|
22
|
+
// reach it and flip is allowed, the other way (white text on a mid green becomes a dark text). Null when none can.
|
|
23
|
+
function fixColor(fg, bg, target, flip = true) {
|
|
24
|
+
|
|
25
|
+
const away = luminance(fg) >= luminance(bg) ? [255, 255, 255] : [0, 0, 0];
|
|
26
|
+
const back = away[0] ? [0, 0, 0] : [255, 255, 255];
|
|
27
|
+
|
|
28
|
+
for (const toward of flip ? [away, back] : [away]) {
|
|
29
|
+
if (contrast(toward, bg) < target) continue;
|
|
30
|
+
const at = (t) => over(toward, t, fg);
|
|
31
|
+
let t = 0;
|
|
32
|
+
while (t < 1 && contrast(at(t), bg) < target) {
|
|
33
|
+
t = Math.min(1, t + 0.02);
|
|
34
|
+
}
|
|
35
|
+
let lo = Math.max(0, t - 0.02);
|
|
36
|
+
let hi = t;
|
|
37
|
+
for (let i = 0; i < 12; i++) {
|
|
38
|
+
const mid = (lo + hi) / 2;
|
|
39
|
+
if (contrast(at(mid), bg) >= target) hi = mid;
|
|
40
|
+
else lo = mid;
|
|
41
|
+
}
|
|
42
|
+
while (hi < 1 && contrast(rgbOf(hex(at(hi))), bg) < target) {
|
|
43
|
+
hi = Math.min(1, hi + 0.002);
|
|
44
|
+
}
|
|
45
|
+
return hex(at(hi));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Text whose contrast with what is behind it is too low. WCAG AA asks for 4.5:1, or 3:1 for large text (24px, or
|
|
52
|
+
// 18.66px bold). Under 3:1 (2:1 for large text) the text is hard to read for anyone: an error. From there up to AA it
|
|
53
|
+
// reads, but not for every reader: a warning.
|
|
54
|
+
export function textContrast(items, behind, k) {
|
|
55
|
+
|
|
56
|
+
const worst = new Map();
|
|
57
|
+
|
|
58
|
+
for (const t of items) {
|
|
59
|
+
if (t.kind !== "text") continue;
|
|
60
|
+
const pixels = t.boxes.flatMap((b) => pixelsIn(behind, scaled(b, k)));
|
|
61
|
+
if (pixels.length < 4) continue;
|
|
62
|
+
const bg = median(pixels);
|
|
63
|
+
const alpha = t.color[3] * t.opacity;
|
|
64
|
+
if (alpha < 0.05) continue;
|
|
65
|
+
const fg = over(t.color, alpha, bg.color);
|
|
66
|
+
const ratio = contrast(fg, bg.color);
|
|
67
|
+
const large = t.size >= 24 || (t.size >= 18.66 && t.bold);
|
|
68
|
+
const need = large ? 3 : 4.5;
|
|
69
|
+
if (ratio >= need) continue;
|
|
70
|
+
const level = ratio < (large ? 2 : 3) ? "error" : "warning";
|
|
71
|
+
const key = level + hex(fg) + hex(bg.color);
|
|
72
|
+
const had = worst.get(key);
|
|
73
|
+
if (!had || ratio < had.ratio) worst.set(key, { level, text: t.text, fg, bg: bg.color, ratio, need, large, count: (had?.count ?? 0) + 1 });
|
|
74
|
+
else had.count++;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [...worst.values()].sort((a, b) => a.ratio - b.ratio).map((w) => {
|
|
78
|
+
const size = w.large ? "large text" : "text this size";
|
|
79
|
+
// the text may turn from light to dark, but what is behind it only moves away from the text (a white page is not
|
|
80
|
+
// made dark)
|
|
81
|
+
const text = fixColor(w.fg, w.bg, w.need);
|
|
82
|
+
const back = fixColor(w.bg, w.fg, w.need, false);
|
|
83
|
+
const fixes = [text && `make the text ${text}`, back && `the color behind it ${back}`].filter(Boolean);
|
|
84
|
+
return {
|
|
85
|
+
level: w.level,
|
|
86
|
+
code: "low-contrast",
|
|
87
|
+
key: `low-contrast ${w.level} ${hex(w.fg)} ${w.text}`,
|
|
88
|
+
message: w.level === "error"
|
|
89
|
+
? `${JSON.stringify(w.text)} is ${hex(w.fg)} on ${hex(w.bg)}: contrast ${w.ratio.toFixed(2)}:1, too faint to read (under ${w.large ? 2 : 3}:1; WCAG AA asks for ${w.need}:1 for ${size})${w.count > 1 ? ` (${w.count} texts like it)` : ""}.`
|
|
90
|
+
: `${JSON.stringify(w.text)} is ${hex(w.fg)} on ${hex(w.bg)}: contrast ${w.ratio.toFixed(2)}:1, under the ${w.need}:1 WCAG AA asks for ${size}${w.count > 1 ? ` (${w.count} texts like it)` : ""}.`,
|
|
91
|
+
fix: fixes.length
|
|
92
|
+
? `${fixes.join(", or ")} (${fixes.length > 1 ? "each the nearest color" : "the nearest color"} that reaches ${w.need}:1).`.replace(/^./, (c) => c.toUpperCase())
|
|
93
|
+
: "Change the text's color or what is behind it: no shade of this color reaches it on that background.",
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// An edge (a border, an outline or a box-shadow) counts when its color, drawn with its alpha over what is behind the
|
|
99
|
+
// mark, reaches the 3:1 that WCAG asks of the parts of a graphic.
|
|
100
|
+
const EDGE = 3;
|
|
101
|
+
const edged = (m, bg) => m.edges?.some((e) => contrast(over(e.color, e.color[3], bg), bg) >= EDGE);
|
|
102
|
+
|
|
103
|
+
const holds = (outer, inner) => outer.left <= inner.left + 1 && outer.top <= inner.top + 1 && outer.right >= inner.right - 1 && outer.bottom >= inner.bottom - 1;
|
|
104
|
+
|
|
105
|
+
// Marks close to invisible against what is behind them: each pixel a mark draws (read from a screenshot with the
|
|
106
|
+
// marks) against the same pixel without them. A mark counts as seen when a tenth of its pixels reach 1.25:1 (an
|
|
107
|
+
// outline, the colored half of a two-tone capsule), or when it or a mark it lies in (a cup around its layers) has a
|
|
108
|
+
// border, an outline or a box-shadow that reaches 3:1. Quiet marks drawn on purpose (the work still to do in a Gantt
|
|
109
|
+
// chart, leader lines, connectors) sit around 1.35:1 to 1.45:1 and read without effort, so the line is lower than that.
|
|
110
|
+
const FAINT = 1.25;
|
|
111
|
+
export function faintMarks(items, shown, hidden, k) {
|
|
112
|
+
|
|
113
|
+
const faint = new Map();
|
|
114
|
+
const marks = items.filter((m) => m.kind === "mark");
|
|
115
|
+
|
|
116
|
+
for (const m of marks) {
|
|
117
|
+
if (m.track) continue;
|
|
118
|
+
const box = scaled(m.boxes[0], k);
|
|
119
|
+
const a = pixelsIn(shown, box);
|
|
120
|
+
const b = pixelsIn(hidden, box);
|
|
121
|
+
const ratios = [];
|
|
122
|
+
for (let i = 0; i < a.length; i++) {
|
|
123
|
+
if (Math.abs(a[i][0] - b[i][0]) + Math.abs(a[i][1] - b[i][1]) + Math.abs(a[i][2] - b[i][2]) > 6) ratios.push([contrast(a[i], b[i]), a[i], b[i]]);
|
|
124
|
+
}
|
|
125
|
+
// a mark that changes few of its pixels is an outline or empty: not what this probe is about
|
|
126
|
+
if (ratios.length < 6 || ratios.length < a.length * 0.3) continue;
|
|
127
|
+
ratios.sort((x, y) => x[0] - y[0]);
|
|
128
|
+
const [ratio, fg, bg] = ratios[Math.floor(ratios.length * 0.9)];
|
|
129
|
+
if (ratio >= FAINT) continue;
|
|
130
|
+
const behind = median(b).color;
|
|
131
|
+
if (edged(m, behind) || marks.some((o) => o !== m && o.slat != null && o.slat === m.slat && holds(o.boxes[0], m.boxes[0]) && edged(o, behind))) continue;
|
|
132
|
+
const key = m.block + hex(m.color);
|
|
133
|
+
const had = faint.get(key);
|
|
134
|
+
faint.set(key, { block: m.block, fg: had?.fg ?? fg, bg: had?.bg ?? bg, ratio: Math.min(ratio, had?.ratio ?? Infinity), count: (had?.count ?? 0) + 1 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...faint.values()].map((f) => ({
|
|
138
|
+
level: "warning",
|
|
139
|
+
code: "faint-marks",
|
|
140
|
+
key: `faint-marks ${f.block} ${hex(f.fg)}`,
|
|
141
|
+
message: `${f.count > 1 ? `${f.count} ${f.block}s` : `A ${f.block}`} in ${hex(f.fg)} ${f.count > 1 ? "are" : "is"} ${f.ratio.toFixed(2)}:1 against ${hex(f.bg)} behind ${f.count > 1 ? "them" : "it"}, hard to see.`,
|
|
142
|
+
fix: `Give the marks a color with more contrast against the background (${fixColor(f.fg, f.bg, 3) ?? "a darker or lighter one"} reaches 3:1), or an edge that does: a border, an outline or a box-shadow in the slat's css.`,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Series colors that look alike: 2 to 8 distinct mark colors in a chart, and two of them, with no labels in their slats
|
|
147
|
+
// that tell them apart, either touching somewhere (stacked segments, mixed dots) and under a CIEDE2000 difference of 10,
|
|
148
|
+
// or anywhere and under 5 (the same color, to that reader), for normal vision or simulated protanopia, deuteranopia or
|
|
149
|
+
// tritanopia. Colors that never touch are told apart by their place too (the first and last segments of a stack). A
|
|
150
|
+
// see-through color (rgba, color-mix with transparent, a mark's opacity) is the color it makes over what is behind
|
|
151
|
+
// the mark, read from the screenshot with the marks hidden.
|
|
152
|
+
export function colorBlind(items, hidden, k) {
|
|
153
|
+
|
|
154
|
+
const out = [];
|
|
155
|
+
const charts = new Map();
|
|
156
|
+
|
|
157
|
+
for (const m of items) {
|
|
158
|
+
const alpha = m.color[3] * m.opacity;
|
|
159
|
+
if (m.kind !== "mark" || m.track || alpha < 0.2) continue;
|
|
160
|
+
const behind = alpha < 1 && hidden ? pixelsIn(hidden, scaled(m.boxes[0], k)) : [];
|
|
161
|
+
const rgb = behind.length ? over(m.color, alpha, median(behind).color) : m.color.slice(0, 3);
|
|
162
|
+
if (!charts.has(m.chart)) charts.set(m.chart, []);
|
|
163
|
+
charts.get(m.chart).push({ ...m, rgb });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const marks of charts.values()) {
|
|
167
|
+
// one entry per color, colors that look the same merged
|
|
168
|
+
const colors = [];
|
|
169
|
+
for (const m of marks) {
|
|
170
|
+
const rgb = m.rgb;
|
|
171
|
+
let c = colors.find((x) => difference(x.rgb, rgb) < 2);
|
|
172
|
+
if (!c) colors.push((c = { rgb, names: new Set(), marks: [] }));
|
|
173
|
+
c.marks.push(m);
|
|
174
|
+
for (const n of m.names) {
|
|
175
|
+
c.names.add(n);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (colors.length < 2 || colors.length > 8) continue;
|
|
179
|
+
|
|
180
|
+
let worst = null;
|
|
181
|
+
for (let i = 0; i < colors.length; i++) {
|
|
182
|
+
for (let j = i + 1; j < colors.length; j++) {
|
|
183
|
+
const [a, b] = [colors[i], colors[j]];
|
|
184
|
+
const labelled = a.names.size && b.names.size && !(a.names.size === b.names.size && [...a.names].every((n) => b.names.has(n)));
|
|
185
|
+
if (labelled) continue;
|
|
186
|
+
const near = touching(a.marks, b.marks);
|
|
187
|
+
for (const vision of VISIONS) {
|
|
188
|
+
const d = difference(a.rgb, b.rgb, vision);
|
|
189
|
+
if (d < (near ? 10 : 5) && (!worst || d < worst.d)) worst = { a, b, vision, d, near };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (worst) {
|
|
195
|
+
const who = worst.vision === "normal" ? "anyone" : `readers with ${worst.vision}`;
|
|
196
|
+
out.push({
|
|
197
|
+
level: "warning",
|
|
198
|
+
code: "colorblind",
|
|
199
|
+
message: `Two of the ${colors.length} series colors, ${hex(worst.a.rgb)} and ${hex(worst.b.rgb)}, look alike to ${who} (CIEDE2000 difference ${worst.d.toFixed(1)}${worst.near ? ", where their marks touch" : ""}).`,
|
|
200
|
+
fix: "Make the colors differ in lightness as well as hue (one light, one dark), or name each series next to its marks instead of only in a legend.",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Whether a mark of one list comes within 4px of a mark of the other
|
|
209
|
+
function touching(a, b) {
|
|
210
|
+
|
|
211
|
+
const gap = (p, q) => Math.max(0, p.left - q.right, q.left - p.right, p.top - q.bottom, q.top - p.bottom);
|
|
212
|
+
|
|
213
|
+
for (const m of a.slice(0, 3000)) {
|
|
214
|
+
for (const n of b.slice(0, 3000)) {
|
|
215
|
+
if (gap(m.boxes[0], n.boxes[0]) <= 4) return true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const linear = (c) => {
|
|
223
|
+
const v = c / 255;
|
|
224
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const gamma = (v) => {
|
|
228
|
+
const c = Math.min(1, Math.max(0, v));
|
|
229
|
+
return 255 * (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// WCAG 2 relative luminance of an sRGB color [r, g, b] (0 to 255)
|
|
233
|
+
const luminance = ([r, g, b]) => 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
234
|
+
|
|
235
|
+
function contrast(a, b) {
|
|
236
|
+
|
|
237
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
238
|
+
|
|
239
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A color drawn with alpha over a background, as the browser blends them (in sRGB)
|
|
243
|
+
const over = ([r, g, b], alpha, [br, bg, bb]) => [r * alpha + br * (1 - alpha), g * alpha + bg * (1 - alpha), b * alpha + bb * (1 - alpha)];
|
|
244
|
+
|
|
245
|
+
const hex = ([r, g, b]) => "#" + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
|
|
246
|
+
|
|
247
|
+
// Machado et al. 2009, severity 1, applied to linear RGB
|
|
248
|
+
const MACHADO = {
|
|
249
|
+
protanopia: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
|
|
250
|
+
deuteranopia: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]],
|
|
251
|
+
tritanopia: [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.3039]],
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const VISIONS = ["normal", ...Object.keys(MACHADO)];
|
|
255
|
+
|
|
256
|
+
function simulate(rgb, vision) {
|
|
257
|
+
|
|
258
|
+
const m = MACHADO[vision];
|
|
259
|
+
if (!m) return rgb;
|
|
260
|
+
|
|
261
|
+
const lin = rgb.map(linear);
|
|
262
|
+
|
|
263
|
+
return m.map((row) => gamma(row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2]));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// sRGB to CIE Lab (D65)
|
|
267
|
+
function lab(rgb) {
|
|
268
|
+
|
|
269
|
+
const [r, g, b] = rgb.map(linear);
|
|
270
|
+
const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047;
|
|
271
|
+
const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
|
|
272
|
+
const z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883;
|
|
273
|
+
const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
|
|
274
|
+
|
|
275
|
+
return [116 * f(y) - 16, 500 * (f(x) - f(y)), 200 * (f(y) - f(z))];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// CIEDE2000 (Sharma, Wu and Dalal 2005)
|
|
279
|
+
function ciede2000([L1, a1, b1], [L2, a2, b2]) {
|
|
280
|
+
|
|
281
|
+
const rad = Math.PI / 180;
|
|
282
|
+
const C1 = Math.hypot(a1, b1);
|
|
283
|
+
const C2 = Math.hypot(a2, b2);
|
|
284
|
+
const Cbar = (C1 + C2) / 2;
|
|
285
|
+
const G = 0.5 * (1 - Math.sqrt(Cbar ** 7 / (Cbar ** 7 + 25 ** 7)));
|
|
286
|
+
const a1p = (1 + G) * a1;
|
|
287
|
+
const a2p = (1 + G) * a2;
|
|
288
|
+
const C1p = Math.hypot(a1p, b1);
|
|
289
|
+
const C2p = Math.hypot(a2p, b2);
|
|
290
|
+
const hue = (b, a) => (b === 0 && a === 0 ? 0 : (Math.atan2(b, a) / rad + 360) % 360);
|
|
291
|
+
const h1p = hue(b1, a1p);
|
|
292
|
+
const h2p = hue(b2, a2p);
|
|
293
|
+
|
|
294
|
+
const dLp = L2 - L1;
|
|
295
|
+
const dCp = C2p - C1p;
|
|
296
|
+
let dhp = 0;
|
|
297
|
+
if (C1p * C2p !== 0) {
|
|
298
|
+
dhp = h2p - h1p;
|
|
299
|
+
if (dhp > 180) dhp -= 360;
|
|
300
|
+
else if (dhp < -180) dhp += 360;
|
|
301
|
+
}
|
|
302
|
+
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * rad);
|
|
303
|
+
|
|
304
|
+
const Lbp = (L1 + L2) / 2;
|
|
305
|
+
const Cbp = (C1p + C2p) / 2;
|
|
306
|
+
let hbp = h1p + h2p;
|
|
307
|
+
if (C1p * C2p !== 0) {
|
|
308
|
+
if (Math.abs(h1p - h2p) <= 180) hbp = (h1p + h2p) / 2;
|
|
309
|
+
else hbp = h1p + h2p < 360 ? (h1p + h2p + 360) / 2 : (h1p + h2p - 360) / 2;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const T = 1 - 0.17 * Math.cos((hbp - 30) * rad) + 0.24 * Math.cos(2 * hbp * rad) + 0.32 * Math.cos((3 * hbp + 6) * rad) - 0.2 * Math.cos((4 * hbp - 63) * rad);
|
|
313
|
+
const dTheta = 30 * Math.exp(-(((hbp - 275) / 25) ** 2));
|
|
314
|
+
const RC = 2 * Math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25 ** 7));
|
|
315
|
+
const SL = 1 + (0.015 * (Lbp - 50) ** 2) / Math.sqrt(20 + (Lbp - 50) ** 2);
|
|
316
|
+
const SC = 1 + 0.045 * Cbp;
|
|
317
|
+
const SH = 1 + 0.015 * Cbp * T;
|
|
318
|
+
const RT = -Math.sin(2 * dTheta * rad) * RC;
|
|
319
|
+
|
|
320
|
+
return Math.sqrt((dLp / SL) ** 2 + (dCp / SC) ** 2 + (dHp / SH) ** 2 + RT * (dCp / SC) * (dHp / SH));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// How far apart two colors look, for one kind of vision
|
|
324
|
+
const difference = (a, b, vision = "normal") => ciede2000(lab(simulate(a, vision)), lab(simulate(b, vision)));
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Every finding the checker reports: what the probes in the page saw, what the page printed or threw, as
|
|
2
|
+
// { level, code, message, fix }. The codes and their fixes are listed in README.md (next to this file).
|
|
3
|
+
|
|
4
|
+
const q = (text) => `"${text}"`;
|
|
5
|
+
|
|
6
|
+
// What an interaction is, said as something a reader does ("clicking button "Q2"")
|
|
7
|
+
const DOING = { hover: "pointing at", tap: "tapping", click: "clicking", input: "setting", select: "setting", key: "pressing", pick: "picking" };
|
|
8
|
+
|
|
9
|
+
// How far something moved: "88px down", "12px right"
|
|
10
|
+
const shift = ({ dx, dy }) => [dy && `${Math.abs(dy)}px ${dy > 0 ? "down" : "up"}`, dx && `${Math.abs(dx)}px ${dx > 0 ? "right" : "left"}`].filter(Boolean).join(" and ");
|
|
11
|
+
|
|
12
|
+
// A finding from what measure() saw in the page
|
|
13
|
+
export function fromProbe(f, context) {
|
|
14
|
+
|
|
15
|
+
switch (f.code) {
|
|
16
|
+
case "no-chart":
|
|
17
|
+
return {
|
|
18
|
+
level: "error",
|
|
19
|
+
code: f.code,
|
|
20
|
+
message: "No chart was drawn: the page has no rhp chart after it loaded.",
|
|
21
|
+
fix: context.errors ? "Fix the errors above first: the chart is drawn by the code that threw." : 'Render the chart into an element the page has: render(() => html`<${Chart} ...>`, document.getElementById("chart")) with <div id="chart"></div> in the body (in a Solid or React file, export the chart component as default).',
|
|
22
|
+
};
|
|
23
|
+
case "empty-plot":
|
|
24
|
+
return { level: "error", code: f.code, message: "A Plot draws no slats: its data groups are empty, or its order hides every row.", fix: "Give the Plot its data: a list per data group with one item per row (name={names} value={values}), or rows={objects}." };
|
|
25
|
+
case "small-plot":
|
|
26
|
+
if (f.thin) {
|
|
27
|
+
return {
|
|
28
|
+
level: "error",
|
|
29
|
+
code: f.code,
|
|
30
|
+
message: `Each slat of a plot is only ${f.band}px thick, too thin to see.`,
|
|
31
|
+
fix: f.vertical
|
|
32
|
+
? "Show fewer columns (bin or group the data), or turn the chart horizontal: a horizontal chart grows with its rows."
|
|
33
|
+
: "Give the slat type a thickness (slat({ thickness: 28, ... }, fn)), or show fewer rows.",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
level: "error",
|
|
38
|
+
code: f.code,
|
|
39
|
+
message: `A plot is only ${f.length}px ${f.vertical ? "tall" : "wide"}, too small to read.`,
|
|
40
|
+
fix: f.vertical ? "Give the Chart a height (height={320}) or an aspect (aspect={16 / 9}), and its parent the room for it." : "Give the chart more width: its parent is too narrow, or the room for labels (room start and end) takes it all.",
|
|
41
|
+
};
|
|
42
|
+
case "nan-value":
|
|
43
|
+
if (f.printed) return { level: "error", code: f.code, message: `${f.what} in the chart shows ${q(f.text)}: a value it prints is missing or not a number.`, fix: "Check the data the label prints (a missing value, a misspelled name, a division by zero), and format numbers before printing them." };
|
|
44
|
+
return { level: "error", code: f.code, message: `${f.block}'s ${f.prop} is ${f.value}.`, fix: `Give ${f.block} a number for ${f.prop}: check the data it is computed from (a missing value, a name that is misspelled, a division by zero).` };
|
|
45
|
+
case "plot-outside-chart":
|
|
46
|
+
return {
|
|
47
|
+
level: "error",
|
|
48
|
+
code: f.code,
|
|
49
|
+
message: `${f.count > 1 ? `${f.count} Plots are` : "A Plot is"} drawn outside a Chart, so ${f.count > 1 ? "they get" : "it gets"} no scale and none of rhp's CSS: nothing in ${f.count > 1 ? "them" : "it"} is placed.`,
|
|
50
|
+
fix: "Put every Plot inside a Chart: <${Chart} scale=${[0, 100]}><${Plot} ...>${Slat}<//><//> (a Plot inside a slat is fine: the slat is in a Chart).",
|
|
51
|
+
};
|
|
52
|
+
case "collapsed-chart":
|
|
53
|
+
return {
|
|
54
|
+
level: "error",
|
|
55
|
+
code: f.code,
|
|
56
|
+
message: `The chart is only ${f.width}px wide and its plot 0px${f.why ? `: ${f.why}, which shrinks it to fit its content` : ""}, so its marks have no room.`,
|
|
57
|
+
fix: "A chart takes the width of its box. In a flex row give the chart's box flex: 1; min-width: 0, and don't put it in an inline-block, a float or a width: fit-content box (or give that box a width).",
|
|
58
|
+
};
|
|
59
|
+
case "cramped":
|
|
60
|
+
if (f.vertical) return { level: "warning", code: f.code, message: "The vertical chart is at the default height (240px), and the labels in its slats crowd each other.", fix: "Give the Chart the height its labels need (height={360}), or an aspect (aspect={4 / 3}), or show fewer labels per slat." };
|
|
61
|
+
return { level: "warning", code: f.code, message: `Each slat is only ${f.band}px thick, too thin for the labels it holds: text and marks crowd each other.`, fix: "Give the slat type a thickness of 24px or more (slat({ thickness: 28, ... }, fn)), or the Chart the height for it." };
|
|
62
|
+
case "points-past-scale": {
|
|
63
|
+
const scale = f.cross ? "cross" : "scale";
|
|
64
|
+
return {
|
|
65
|
+
level: "error",
|
|
66
|
+
code: f.code,
|
|
67
|
+
message: `${f.block === "Area" ? "An Area's" : "A Line's"} points reach ${f.value}, outside the Chart's ${f.cross ? "cross scale" : "scale"} [${f.min}, ${f.max}]: rhp squeezes the whole outline into the scale, so every point is drawn in the wrong place${f.count > 1 ? ` (${f.count} values)` : ""}.`,
|
|
68
|
+
fix: `Keep every point inside the scale: ${scale}=\${[${Math.min(f.min, f.value)}, ${Math.max(f.max, f.value)}]} or wider (nice() gives round ends), or cut the points to it (density() and bins() take a domain).`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
case "series-past-theme": {
|
|
72
|
+
const keys = f.keys.map((k) => "series-" + k).join(", ");
|
|
73
|
+
return {
|
|
74
|
+
level: "error",
|
|
75
|
+
code: f.code,
|
|
76
|
+
message: `${keys} ${f.keys.length > 1 ? "are" : "is"} past the theme's ${f.have} series colors, so ${f.count > 1 ? `${f.count} marks fall` : "a mark falls"} back to series-1.`,
|
|
77
|
+
fix: `Use series(${f.have}) for a theme of ${f.have} colors (series() counts 6 by default), or give the theme more series colors.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
case "block-transition":
|
|
81
|
+
return {
|
|
82
|
+
level: "warning",
|
|
83
|
+
code: f.code,
|
|
84
|
+
message: `The slat's CSS gives ${f.path ? `the outline of ${f.what}` : f.what} a transition of its own (${f.set}), which replaces rhp's (${f.dropped.join(", ")}), so ${f.count > 1 ? `${f.count} ${f.block}s jump` : `the ${f.block} jumps`} to new values instead of moving.`,
|
|
85
|
+
fix: `Put the transition on an element inside the ${f.block} (a span in it) and leave the ${f.block}'s own to rhp, or name rhp's too: transition-property: ${[...f.dropped, f.set].join(", ")}.`,
|
|
86
|
+
};
|
|
87
|
+
case "page-css-ignored": {
|
|
88
|
+
const colors = f.props.some((p) => /color|background|fill|stroke/.test(p));
|
|
89
|
+
const font = f.props.some((p) => /^font/.test(p));
|
|
90
|
+
return {
|
|
91
|
+
level: "warning",
|
|
92
|
+
code: f.code,
|
|
93
|
+
message: `The page's CSS rule ${f.selector} sets ${f.props.join(", ")} on ${f.what}${f.count > 1 ? ` and ${f.count - 1} more` : ""} in a chart, where rhp's own CSS wins, so it changes nothing.`,
|
|
94
|
+
fix: `Style what is in a chart in the slat type's css (slat({ css: \`.bar { ... }\` }, fn))${colors ? ", and give colors with the block's color prop or the Chart's theme" : ""}${font ? ", and the font with the theme's font" : ""}. Page CSS can only set opacity, cursor, filter, transform, z-index and pointer-events there.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
case "value-past-scale": {
|
|
98
|
+
const where = `${f.cross ? "the Chart's cross scale" : "the Chart's scale"} [${f.min}, ${f.max}]`;
|
|
99
|
+
const count = f.count > 1 ? ` (${f.count} values)` : "";
|
|
100
|
+
let message = `A ${f.block} sits at ${f.value}, outside ${where}, so it is drawn outside the plot${count}.`;
|
|
101
|
+
if (f.kept) message = `A ${f.block} sits at ${f.value}, outside ${where}: rhp keeps it at the ${f.value < f.min ? "start" : "end"} of the scale, away from its value${count}.`;
|
|
102
|
+
if (f.block === "Bar") message = `A Bar reaches ${f.value}, outside ${where}: rhp cuts a bar at the ends of the scale, so this one ${f.gone ? "draws nothing" : "shows the wrong value"}${count}.`;
|
|
103
|
+
return {
|
|
104
|
+
level: "error",
|
|
105
|
+
code: f.code,
|
|
106
|
+
message,
|
|
107
|
+
fix: `Make the scale cover the data: ${f.cross ? "cross" : "scale"}=\${[${Math.min(f.min, f.value)}, ${Math.max(f.max, f.value)}]} or wider (nice(lo, hi).min and .max give round ends)${f.block === "Label" ? ", or place the Label at the value its mark shows" : ""}.`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
case "sticks-out": {
|
|
111
|
+
// the side the value axis starts on (left, or bottom in a vertical chart) and the side it ends on
|
|
112
|
+
const along = f.vertical ? f.side === "top" || f.side === "bottom" : f.side === "left" || f.side === "right";
|
|
113
|
+
const start = f.side === (f.vertical ? "bottom" : "left");
|
|
114
|
+
let fix = "Keep marks inside the chart: the values inside the scale (build it with nice()), and room for what is drawn outside the plot (slat({ room: { ... } }, fn)).";
|
|
115
|
+
if (f.axis) fix = "Keep the axis numbers short (a format that drops units, or fewer ticks): the axis has fixed room past each end of the scale.";
|
|
116
|
+
else if (f.label && !along) fix = "Give the slats the thickness their labels need (slat({ thickness: 32 }, fn)), or make the labels smaller.";
|
|
117
|
+
else if (f.label && (f.edge || start)) fix = "Give the slat type room for its names on that side (room: { start: 120 }, or room: \"auto\" to fit the longest), or cap them: max-width with text-overflow: ellipsis on the label's class.";
|
|
118
|
+
else if (f.label) fix = "Give the slat type room at the end for its value labels (room: { end: 60 }), or build the scale past the largest value with nice().";
|
|
119
|
+
return {
|
|
120
|
+
level: "error",
|
|
121
|
+
code: f.code,
|
|
122
|
+
message: `${f.what}${f.text ? ` ${q(f.text)}` : ""} sticks out of its chart by ${f.by}px on the ${f.side}${f.count > 1 ? ` (${f.count} elements stick out)` : ""}.`,
|
|
123
|
+
fix,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case "text-outside-poster":
|
|
127
|
+
return { level: "error", code: f.code, message: `${q(f.text)} sticks out of the poster by ${f.by}px.`, fix: "Let the text wrap inside the poster (no white-space: nowrap, max-width: 100%), or make it smaller at narrow widths." };
|
|
128
|
+
case "text-cut-off": {
|
|
129
|
+
// an ellipsis is drawn on purpose, and the text is still there to read in part: a warning. Text clipped with no
|
|
130
|
+
// sign of it is an error.
|
|
131
|
+
let keep = "give it room (a wider or taller box), let it wrap, or shorten it.";
|
|
132
|
+
if (f.label && f.vertical) keep = "a vertical chart's names are as wide as their columns, so let them wrap (.name:vertical { white-space: normal }), shorten them, or draw the chart horizontal on narrow screens.";
|
|
133
|
+
else if (f.edge) keep = "give the names room (room: { start: 140 } in the slat type, or room: \"auto\" to fit the longest), let them wrap (white-space: normal on the label's class), or shorten them.";
|
|
134
|
+
return f.ellipsis
|
|
135
|
+
? { level: "warning", code: f.code, message: `${q(f.text)} ends in an ellipsis: ${f.lost}px of it is hidden by ${f.by}, so the reader loses the rest of it.`, fix: `To keep it whole, ${keep}` }
|
|
136
|
+
: { level: "error", code: f.code, message: `${q(f.text)} is cut off: ${f.lost}px of it is hidden by ${f.by}, with no sign that it goes on.`, fix: `To show it whole, ${keep}` };
|
|
137
|
+
}
|
|
138
|
+
case "text-overlap":
|
|
139
|
+
return { level: "error", code: f.code, message: `${q(f.a)} overlaps ${q(f.b)} (${f.x} by ${f.y}px).`, fix: "Give each text its own space: more room or thickness, a smaller font, shorter text, or show one of them only where it fits." };
|
|
140
|
+
case "ambiguous-label": {
|
|
141
|
+
const mark = f.other ? `the mark of ${q(f.other)}` : "another slat's mark (one with no name)";
|
|
142
|
+
const more = f.count > 1 ? ` (${f.count} names are placed like it)` : "";
|
|
143
|
+
return {
|
|
144
|
+
level: "warning",
|
|
145
|
+
code: f.code,
|
|
146
|
+
message: f.over
|
|
147
|
+
? `${q(f.text)} is drawn over ${mark}${more}, so a reader may take it for that mark's name.`
|
|
148
|
+
: `${q(f.text)} is ${f.near}px from ${mark}, in line with its text, and ${f.own}px from its own mark${more}, so a reader may take it for that mark's name.`,
|
|
149
|
+
fix: "Put each name beside its own mark, on the side away from its neighbors (a side or an offset per row, and another on narrow screens), or leave a crowded name to the readout.",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
case "page-scrolls-sideways":
|
|
153
|
+
return {
|
|
154
|
+
level: "error",
|
|
155
|
+
code: f.code,
|
|
156
|
+
message: `The page scrolls sideways by ${f.by}px at ${f.viewport}px wide${f.what ? `: ${f.what} is ${f.width}px wide` : ""}.`,
|
|
157
|
+
fix: "Keep everything inside the page's width: max-width: 100% and box-sizing: border-box on the poster, no fixed width wider than the phone, and long words allowed to wrap.",
|
|
158
|
+
};
|
|
159
|
+
case "tiny-text":
|
|
160
|
+
return { level: "error", code: f.code, message: `${q(f.text)} is ${f.size}px${f.count > 1 ? ` (${f.count} texts are under 9px)` : ""}, too small to read.`, fix: "Make text at least 9px, and data labels 11px or more." };
|
|
161
|
+
case "font-not-loaded":
|
|
162
|
+
return {
|
|
163
|
+
level: "info",
|
|
164
|
+
code: f.code,
|
|
165
|
+
message: `The font ${q(f.family)} did not load${f.failed ? " (its file failed to download: offline, or a wrong URL)" : ""}, so its text uses a fallback font.`,
|
|
166
|
+
fix: "Load it with a Google Fonts <link> in the head, or drop it from the font stack.",
|
|
167
|
+
};
|
|
168
|
+
case "unnamed-control":
|
|
169
|
+
return {
|
|
170
|
+
level: "warning",
|
|
171
|
+
code: f.code,
|
|
172
|
+
message: `${f.what} has no accessible name${f.count > 1 ? ` (${f.count} controls have none)` : ""}: a screen reader says only what kind of control it is, not what it does.`,
|
|
173
|
+
fix: "Give it visible text, an aria-label (aria-label=\"Play\" on an icon button), or a <label> around an input or a select.",
|
|
174
|
+
};
|
|
175
|
+
case "layout-jump": {
|
|
176
|
+
const list = f.moved.slice(0, 3).map((m) => `${m.what} moves ${shift(m)}`).join(", ");
|
|
177
|
+
const plot = f.moved.some((m) => m.plot);
|
|
178
|
+
return {
|
|
179
|
+
level: "warning",
|
|
180
|
+
code: f.code,
|
|
181
|
+
message: `The layout jumps when ${DOING[f.kind] ?? f.kind} ${f.target}: ${list}${f.moved.length > 3 ? ` and ${f.moved.length - 3} more` : ""}${f.more ? ` (${f.more} more interaction${f.more > 1 ? "s do" : " does"} the same)` : ""}, so what the reader goes to next is no longer where it was.`,
|
|
182
|
+
fix: plot
|
|
183
|
+
? "Keep the plot still while a slat is lit: a lit name that gets wider (bolder, larger) makes room: \"auto\" measure the names again, so keep the names' weight and size and change only their color, or give the room in px (room: { start: 140 })."
|
|
184
|
+
: "Keep what the interaction changes in a box that keeps its size: a readout or a headline on one line (white-space: nowrap; overflow: hidden; text-overflow: ellipsis) or with a min-height for its longest text, and buttons that keep their width when their label or weight changes.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
case "out-of-view":
|
|
188
|
+
return {
|
|
189
|
+
level: "warning",
|
|
190
|
+
code: f.code,
|
|
191
|
+
message: `Tapping ${f.target} changes text only outside the screen (${f.what}, ${f.by}px ${f.side} it)${f.more ? `, and so does tapping ${f.more} more slat${f.more > 1 ? "s" : ""}` : ""}: on a phone the reader cannot see what the tap says.`,
|
|
192
|
+
fix: "Put the readout next to the slat (in the slat, or in a Label of it), or keep it in view on a phone (position: sticky; top: 0; z-index: 2, above the chart's plot).",
|
|
193
|
+
};
|
|
194
|
+
case "focus-invisible":
|
|
195
|
+
return {
|
|
196
|
+
level: "warning",
|
|
197
|
+
code: f.code,
|
|
198
|
+
message: `Tab reaches ${f.what}${f.more ? ` and ${f.more} more` : ""}, but nothing on screen shows the focus there: a keyboard reader cannot see where it is.`,
|
|
199
|
+
fix: "Show focus: take off outline: none, or put a :focus-visible style that shows in its place (outline: 2px solid in the ink color, outline-offset: 2px; on a slat, in the slat type's css).",
|
|
200
|
+
};
|
|
201
|
+
default:
|
|
202
|
+
return { level: "error", code: f.code, message: JSON.stringify(f), fix: "" };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const STANDALONE = ["render", "html", "createSignal", "createMemo", "createEffect", "createRoot", "onMount", "onCleanup", "batch", "untrack", "For", "Index", "Show", "createStore", "reconcile", "produce", "unwrap", "Plot", "Scale", "Chart", "Theme", "Axis", "at", "useOrientation", "THEME", "series", "slat", "restyle", "linkedCss", "shape", "Bar", "Dot", "Tick", "Label", "Cell", "Place", "Area", "Line", "Poster", "sortBy", "cycle", "every", "extent", "nice", "stackUp", "shares", "running", "summary", "bins", "density", "animated", "curve", "drawing"];
|
|
207
|
+
|
|
208
|
+
// What to do about an error the page threw
|
|
209
|
+
export function fixForError(message, format) {
|
|
210
|
+
|
|
211
|
+
let m = message.match(/(\w+) is not defined/);
|
|
212
|
+
if (m) return STANDALONE.includes(m[1]) ? `Import ${m[1]}: import { ${m[1]}, ... } from "${format === "solid" ? (["render", "createSignal", "createMemo", "createEffect", "Show", "For", "Index", "onMount", "onCleanup", "batch", "untrack", "createRoot"].includes(m[1]) ? "solid-js" : "@bezda/rhp") : "@bezda/rhp/standalone"}".` : `Define ${m[1]} or import it before it is used.`;
|
|
213
|
+
|
|
214
|
+
m = message.match(/Failed to resolve module specifier "([^"]+)"/);
|
|
215
|
+
if (m) return `Map "${m[1]}" in the page's import map: <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>, before the module script.`;
|
|
216
|
+
|
|
217
|
+
m = message.match(/Cannot read properties of (undefined|null) \(reading '([^']+)'\)/);
|
|
218
|
+
if (m) return `Something is ${m[1]} where the code reads .${m[2]}: check the data and the names (a slat reads d.name only when the Plot is given name={...}).`;
|
|
219
|
+
|
|
220
|
+
if (/rhp: a slat must return one element/.test(message)) return "Return one element from the slat, with the blocks inside it: (d) => html`<div><${Bar} to=${() => d.value} /></div>`.";
|
|
221
|
+
if (/rhp: a Plot's child must be a slat function/.test(message)) return "Give the Plot one slat function as its child: <${Plot} value=${values}>${Fruit}<//>, where Fruit is (d) => html`<div>...</div>` or made with slat().";
|
|
222
|
+
if (/rhp: a Scale goes inside a Chart/.test(message)) return "Put the Scale inside the Chart, beside its Plots.";
|
|
223
|
+
if (/Unexpected token 'export'|Cannot use import statement outside a module/.test(message)) return "Load modules with <script type=\"module\">, and rhp through the import map.";
|
|
224
|
+
if (/is not a function/.test(message)) return "Check the name and where it comes from: it is not a function there (an import that doesn't exist, or a value used as a function).";
|
|
225
|
+
if (/Maximum call stack/.test(message)) return "Something calls itself without end: a signal set inside an effect that reads it, or a component that renders itself.";
|
|
226
|
+
|
|
227
|
+
return "Fix the code that throws, at the place the message gives.";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A short "file:line" for the first frame of a stack that is the user's (not rhp's or Solid's)
|
|
231
|
+
export function placeOf(stack, base, map) {
|
|
232
|
+
|
|
233
|
+
for (const line of String(stack ?? "").split("\n").slice(1)) {
|
|
234
|
+
const m = line.match(/\(?((?:file|https?):\/\/[^\s)]+?):(\d+):(\d+)\)?\s*$/);
|
|
235
|
+
if (!m) continue;
|
|
236
|
+
const [, url, row, col] = m;
|
|
237
|
+
if (map && url.includes("/__rhp/bundle.js")) {
|
|
238
|
+
const at = map(+row, +col);
|
|
239
|
+
if (at && !/node_modules|rhp-check|\/dist\/source\/|\/src\/guard\//.test(at.source)) return `${at.source.replace(/^.*\//, "")}:${at.line}:${at.column}`;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (/cdn\.jsdelivr|unpkg\.com|esm\.sh/.test(url)) continue;
|
|
243
|
+
return `${decodeURIComponent(url.replace(/^.*\//, ""))}:${row}:${col}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return "";
|
|
247
|
+
}
|