tempest-react-sdk 0.26.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/bin/lib/css/analyze.mjs +149 -0
- package/bin/lib/css/analyze.test.mjs +136 -0
- package/bin/lib/css/collect.mjs +152 -0
- package/bin/lib/css/findings.mjs +79 -0
- package/bin/lib/css/fix.e2e.test.mjs +99 -0
- package/bin/lib/css/fix.mjs +138 -0
- package/bin/lib/css/fix.test.mjs +108 -0
- package/bin/lib/css/index.mjs +16 -0
- package/bin/lib/css/parse.mjs +398 -0
- package/bin/lib/css/parse.test.mjs +146 -0
- package/bin/lib/css/properties.mjs +399 -0
- package/bin/lib/css/repetition.mjs +213 -0
- package/bin/lib/css/repetition.test.mjs +166 -0
- package/bin/lib/css/semantic.mjs +327 -0
- package/bin/lib/css/semantic.test.mjs +201 -0
- package/bin/lib/css/tokens.mjs +119 -0
- package/bin/lib/doctor/doctor.e2e.test.mjs +246 -0
- package/bin/tempest.mjs +272 -36
- package/dist/charts/scales.cjs +2 -0
- package/dist/charts/scales.cjs.map +1 -0
- package/dist/charts/scales.js +29 -0
- package/dist/charts/scales.js.map +1 -0
- package/dist/charts.cjs +1 -1
- package/dist/charts.d.ts +91 -0
- package/dist/charts.js +9 -8
- package/dist/components/BottomNavigation/BottomNavigation.module.cjs.map +1 -1
- package/dist/components/BottomNavigation/BottomNavigation.module.js.map +1 -1
- package/dist/components/Carousel/Carousel.module.cjs.map +1 -1
- package/dist/components/Carousel/Carousel.module.js.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.cjs +3 -0
- package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.js +53 -0
- package/dist/components/CodeBlock/CodeBlock.js.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.module.cjs +2 -0
- package/dist/components/CodeBlock/CodeBlock.module.cjs.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.module.js +29 -0
- package/dist/components/CodeBlock/CodeBlock.module.js.map +1 -0
- package/dist/components/CodeBlock/tokenize.cjs +3 -0
- package/dist/components/CodeBlock/tokenize.cjs.map +1 -0
- package/dist/components/CodeBlock/tokenize.js +267 -0
- package/dist/components/CodeBlock/tokenize.js.map +1 -0
- package/dist/components/ImageCropper/ImageCropper.cjs +2 -0
- package/dist/components/ImageCropper/ImageCropper.cjs.map +1 -0
- package/dist/components/ImageCropper/ImageCropper.js +240 -0
- package/dist/components/ImageCropper/ImageCropper.js.map +1 -0
- package/dist/components/ImageCropper/ImageCropper.module.cjs +2 -0
- package/dist/components/ImageCropper/ImageCropper.module.cjs.map +1 -0
- package/dist/components/ImageCropper/ImageCropper.module.js +15 -0
- package/dist/components/ImageCropper/ImageCropper.module.js.map +1 -0
- package/dist/components/ImageCropper/crop-geometry.cjs +2 -0
- package/dist/components/ImageCropper/crop-geometry.cjs.map +1 -0
- package/dist/components/ImageCropper/crop-geometry.js +52 -0
- package/dist/components/ImageCropper/crop-geometry.js.map +1 -0
- package/dist/components/Navbar/Navbar.module.cjs.map +1 -1
- package/dist/components/Navbar/Navbar.module.js.map +1 -1
- package/dist/components/QRCode/QRCode.cjs +2 -0
- package/dist/components/QRCode/QRCode.cjs.map +1 -0
- package/dist/components/QRCode/QRCode.js +49 -0
- package/dist/components/QRCode/QRCode.js.map +1 -0
- package/dist/components/QRCode/QRCode.module.cjs +2 -0
- package/dist/components/QRCode/QRCode.module.cjs.map +1 -0
- package/dist/components/QRCode/QRCode.module.js +9 -0
- package/dist/components/QRCode/QRCode.module.js.map +1 -0
- package/dist/components/QRCode/qr-encode.cjs +2 -0
- package/dist/components/QRCode/qr-encode.cjs.map +1 -0
- package/dist/components/QRCode/qr-encode.js +295 -0
- package/dist/components/QRCode/qr-encode.js.map +1 -0
- package/dist/components/QRCode/qr-tables.cjs +2 -0
- package/dist/components/QRCode/qr-tables.cjs.map +1 -0
- package/dist/components/QRCode/qr-tables.js +366 -0
- package/dist/components/QRCode/qr-tables.js.map +1 -0
- package/dist/components/Scheduler/Scheduler.cjs +2 -0
- package/dist/components/Scheduler/Scheduler.cjs.map +1 -0
- package/dist/components/Scheduler/Scheduler.js +134 -0
- package/dist/components/Scheduler/Scheduler.js.map +1 -0
- package/dist/components/Scheduler/Scheduler.module.cjs +2 -0
- package/dist/components/Scheduler/Scheduler.module.cjs.map +1 -0
- package/dist/components/Scheduler/Scheduler.module.js +26 -0
- package/dist/components/Scheduler/Scheduler.module.js.map +1 -0
- package/dist/components/Scheduler/scheduler-layout.cjs +2 -0
- package/dist/components/Scheduler/scheduler-layout.cjs.map +1 -0
- package/dist/components/Scheduler/scheduler-layout.js +97 -0
- package/dist/components/Scheduler/scheduler-layout.js.map +1 -0
- package/dist/components/ScrollArea/ScrollArea.cjs +1 -1
- package/dist/components/ScrollArea/ScrollArea.cjs.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.js +22 -16
- package/dist/components/ScrollArea/ScrollArea.js.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.module.cjs.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.module.js.map +1 -1
- package/dist/components/Sparkline/Sparkline.cjs +2 -0
- package/dist/components/Sparkline/Sparkline.cjs.map +1 -0
- package/dist/components/Sparkline/Sparkline.js +64 -0
- package/dist/components/Sparkline/Sparkline.js.map +1 -0
- package/dist/components/Sparkline/Sparkline.module.cjs +2 -0
- package/dist/components/Sparkline/Sparkline.module.cjs.map +1 -0
- package/dist/components/Sparkline/Sparkline.module.js +10 -0
- package/dist/components/Sparkline/Sparkline.module.js.map +1 -0
- package/dist/components/Sparkline/sparkline-geometry.cjs +2 -0
- package/dist/components/Sparkline/sparkline-geometry.cjs.map +1 -0
- package/dist/components/Sparkline/sparkline-geometry.js +56 -0
- package/dist/components/Sparkline/sparkline-geometry.js.map +1 -0
- package/dist/components/Table/Table.cjs +1 -1
- package/dist/components/Table/Table.cjs.map +1 -1
- package/dist/components/Table/Table.js +37 -30
- package/dist/components/Table/Table.js.map +1 -1
- package/dist/components/Table/Table.module.cjs.map +1 -1
- package/dist/components/Table/Table.module.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.cjs +1 -1
- package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.js +13 -11
- package/dist/components/VirtualList/VirtualList.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.module.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.module.js.map +1 -1
- package/dist/hooks/use-scroll-overflow.cjs +2 -0
- package/dist/hooks/use-scroll-overflow.cjs.map +1 -0
- package/dist/hooks/use-scroll-overflow.js +22 -0
- package/dist/hooks/use-scroll-overflow.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +607 -2
- package/dist/tempest-react-sdk.js +197 -188
- package/dist/theme/create-theme.cjs +3 -3
- package/dist/theme/create-theme.cjs.map +1 -1
- package/dist/theme/create-theme.js +52 -28
- package/dist/theme/create-theme.js.map +1 -1
- package/dist/theme/data-viz-ramps.cjs +2 -0
- package/dist/theme/data-viz-ramps.cjs.map +1 -0
- package/dist/theme/data-viz-ramps.js +58 -0
- package/dist/theme/data-viz-ramps.js.map +1 -0
- package/package.json +2 -1
- package/template/package.json +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "tempest.mjs");
|
|
9
|
+
|
|
10
|
+
let root;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
root = mkdtempSync(join(tmpdir(), "tempest-doctor-"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Write a file under the fixture, creating parent directories. */
|
|
21
|
+
function write(rel, contents) {
|
|
22
|
+
const path = join(root, rel);
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, typeof contents === "string" ? contents : JSON.stringify(contents));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fake an installed package so `installedVersion` finds it. */
|
|
28
|
+
function installed(name, version, extra = {}) {
|
|
29
|
+
write(`node_modules/${name}/package.json`, { name, version, ...extra });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run the real CLI in the fixture directory.
|
|
34
|
+
*
|
|
35
|
+
* A subprocess rather than an imported function on purpose: `doctor` reads
|
|
36
|
+
* `process.cwd()` and reports through the exit code, and the exit code is the part
|
|
37
|
+
* that matters most — it decides whether a project "fails" the audit. Only running
|
|
38
|
+
* it the way a user does tests that.
|
|
39
|
+
*/
|
|
40
|
+
function doctor() {
|
|
41
|
+
const result = spawnSync(process.execPath, [CLI, "doctor"], {
|
|
42
|
+
cwd: root,
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
|
|
45
|
+
});
|
|
46
|
+
return { code: result.status, out: `${result.stdout}${result.stderr}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A plain, healthy React + Vite app that has never heard of the SDK. */
|
|
50
|
+
function thirdPartyApp() {
|
|
51
|
+
write("package.json", {
|
|
52
|
+
name: "app-de-terceiro",
|
|
53
|
+
type: "module",
|
|
54
|
+
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
55
|
+
devDependencies: { vite: "^7.0.0", "@vitejs/plugin-react": "^5.0.0", eslint: "^9.0.0" },
|
|
56
|
+
});
|
|
57
|
+
write("tsconfig.json", {
|
|
58
|
+
compilerOptions: { strict: true, jsx: "react-jsx", moduleResolution: "bundler" },
|
|
59
|
+
});
|
|
60
|
+
write(
|
|
61
|
+
"vite.config.ts",
|
|
62
|
+
'import { defineConfig } from "vite";\nexport default defineConfig({});',
|
|
63
|
+
);
|
|
64
|
+
write("src/App.tsx", "export const App = () => null;");
|
|
65
|
+
write("package-lock.json", "{}");
|
|
66
|
+
write("eslint.config.js", "export default [];");
|
|
67
|
+
installed("react", "19.0.0");
|
|
68
|
+
installed("react-dom", "19.0.0");
|
|
69
|
+
installed("vite", "7.0.0");
|
|
70
|
+
installed("@vitejs/plugin-react", "5.0.0");
|
|
71
|
+
installed("eslint", "9.0.0");
|
|
72
|
+
installed("prettier", "3.0.0");
|
|
73
|
+
installed(".bin/eslint", "0.0.0");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("tempest doctor — a third-party project that does not use the SDK", () => {
|
|
77
|
+
beforeEach(thirdPartyApp);
|
|
78
|
+
|
|
79
|
+
it("does not fail the audit just for not having the SDK", () => {
|
|
80
|
+
const { code, out } = doctor();
|
|
81
|
+
expect(out).toContain("tempest-react-sdk not installed");
|
|
82
|
+
expect(out).toContain("generic React/Vite health only");
|
|
83
|
+
expect(code).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("never reports the missing SDK as a problem", () => {
|
|
87
|
+
const { out } = doctor();
|
|
88
|
+
expect(out).not.toMatch(/✗.*tempest-react-sdk/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("drops the SDK's own conventions from the report", () => {
|
|
92
|
+
const { out } = doctor();
|
|
93
|
+
// The `@/*` alias and `createViteConfig` are preferences, not health.
|
|
94
|
+
expect(out).not.toContain('tsconfig "@/*" alias');
|
|
95
|
+
expect(out).not.toContain("not using createViteConfig");
|
|
96
|
+
// The stylesheet is only *demanded* of an SDK project; the adoption hint at
|
|
97
|
+
// the end still mentions it, which is the point of that section.
|
|
98
|
+
expect(out).not.toContain('add import "tempest-react-sdk/styles.css"');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not demand a src/main.tsx it has no reason to expect", () => {
|
|
102
|
+
const { out } = doctor();
|
|
103
|
+
expect(out).not.toContain("app entry");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("explains moduleResolution without naming SDK subpaths", () => {
|
|
107
|
+
write("tsconfig.json", { compilerOptions: { strict: true, jsx: "react-jsx" } });
|
|
108
|
+
const { out } = doctor();
|
|
109
|
+
expect(out).toContain("moduleResolution");
|
|
110
|
+
expect(out).not.toContain("tempest-react-sdk/br");
|
|
111
|
+
expect(out).toContain("subpath exports");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("closes with how to adopt, so the audit is not a dead end", () => {
|
|
115
|
+
const { out } = doctor();
|
|
116
|
+
expect(out).toContain("Adopting the SDK (optional)");
|
|
117
|
+
expect(out).toContain("npm i tempest-react-sdk");
|
|
118
|
+
expect(out).toContain("not all-or-nothing");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("tempest doctor — generic findings still surface", () => {
|
|
123
|
+
it("flags a missing lockfile", () => {
|
|
124
|
+
thirdPartyApp();
|
|
125
|
+
rmSync(join(root, "package-lock.json"));
|
|
126
|
+
expect(doctor().out).toContain("no lockfile");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("flags a missing @vitejs/plugin-react", () => {
|
|
130
|
+
thirdPartyApp();
|
|
131
|
+
rmSync(join(root, "node_modules", "@vitejs", "plugin-react"), {
|
|
132
|
+
recursive: true,
|
|
133
|
+
force: true,
|
|
134
|
+
});
|
|
135
|
+
expect(doctor().out).toContain("@vitejs/plugin-react");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("flags declared dependencies that are not installed", () => {
|
|
139
|
+
thirdPartyApp();
|
|
140
|
+
rmSync(join(root, "node_modules", "react"), { recursive: true, force: true });
|
|
141
|
+
expect(doctor().out).toMatch(/dependency\(ies\) not installed/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("flags env vars that Vite will not expose", () => {
|
|
145
|
+
thirdPartyApp();
|
|
146
|
+
write("src/api.ts", "export const url = import.meta.env.API_URL;");
|
|
147
|
+
expect(doctor().out).toContain("client env without VITE_ prefix");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("still fails on a genuinely broken project", () => {
|
|
151
|
+
write("package.json", { name: "broken", dependencies: {} });
|
|
152
|
+
const { code, out } = doctor();
|
|
153
|
+
expect(out).toContain("react + react-dom present");
|
|
154
|
+
expect(code).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("tempest doctor — stylesheets", () => {
|
|
159
|
+
beforeEach(thirdPartyApp);
|
|
160
|
+
|
|
161
|
+
it("says nothing about CSS in a project with no stylesheet", () => {
|
|
162
|
+
expect(doctor().out).not.toContain("Stylesheets");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("reports a clean sheet as clean", () => {
|
|
166
|
+
write("src/app.css", ".a {\n color: red;\n}\n");
|
|
167
|
+
const { out, code } = doctor();
|
|
168
|
+
expect(out).toContain("Stylesheets");
|
|
169
|
+
expect(out).toContain("no CSS problems found");
|
|
170
|
+
expect(code).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("fails the audit on CSS the browser cannot parse", () => {
|
|
174
|
+
write("src/app.css", ".a {\n color: red;\n");
|
|
175
|
+
const { out, code } = doctor();
|
|
176
|
+
expect(out).toContain("src/app.css:1");
|
|
177
|
+
expect(out).toContain("never closed");
|
|
178
|
+
expect(code).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("warns without failing on CSS that is valid but wrong", () => {
|
|
182
|
+
write("src/app.css", ".a {\n bacground-color: red;\n}\n");
|
|
183
|
+
const { out, code } = doctor();
|
|
184
|
+
expect(out).toContain("did you mean `background-color`");
|
|
185
|
+
expect(code).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("points at the fix command when something is auto-fixable", () => {
|
|
189
|
+
write("src/app.css", ".a {\n color: red;\n color: red;\n}\n");
|
|
190
|
+
const { out } = doctor();
|
|
191
|
+
expect(out).toMatch(/finding\(s\) are auto-fixable/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("suggests one global class over repeated local copies", () => {
|
|
195
|
+
const row = ".row {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n";
|
|
196
|
+
write("src/A.module.css", row);
|
|
197
|
+
write("src/B.module.css", row);
|
|
198
|
+
write("src/C.module.css", row);
|
|
199
|
+
expect(doctor().out).toContain("one global class beats");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("tempest doctor — a project that does use the SDK", () => {
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
thirdPartyApp();
|
|
206
|
+
write("package.json", {
|
|
207
|
+
name: "sdk-app",
|
|
208
|
+
type: "module",
|
|
209
|
+
dependencies: {
|
|
210
|
+
"tempest-react-sdk": "^0.26.1",
|
|
211
|
+
react: "^19.0.0",
|
|
212
|
+
"react-dom": "^19.0.0",
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
installed("tempest-react-sdk", "0.26.1", { dependencies: { "lucide-react": "^1.26.0" } });
|
|
216
|
+
installed("lucide-react", "1.26.0");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("checks the SDK's conventions again", () => {
|
|
220
|
+
write("tsconfig.json", {
|
|
221
|
+
compilerOptions: { strict: true, jsx: "react-jsx", moduleResolution: "bundler" },
|
|
222
|
+
});
|
|
223
|
+
const { out } = doctor();
|
|
224
|
+
expect(out).toContain('tsconfig "@/*" alias');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("asks for the stylesheet import, which is a real defect without it", () => {
|
|
228
|
+
write("src/main.tsx", "export const main = 1;");
|
|
229
|
+
expect(doctor().out).toContain("styles.css");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("omits the adoption hint — it has already adopted", () => {
|
|
233
|
+
expect(doctor().out).not.toContain("Adopting the SDK (optional)");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("warns when the SDK is installed but undeclared", () => {
|
|
237
|
+
write("package.json", {
|
|
238
|
+
name: "sdk-app",
|
|
239
|
+
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
240
|
+
});
|
|
241
|
+
const { out, code } = doctor();
|
|
242
|
+
expect(out).toContain("tempest-react-sdk not in dependencies");
|
|
243
|
+
// Undeclared-but-present is a warning, not a blocking failure.
|
|
244
|
+
expect(code).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
});
|
package/bin/tempest.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import { aliasImports } from "./lib/alias/index.mjs";
|
|
15
15
|
import { loadTypeScript } from "./lib/alias/typescript.mjs";
|
|
16
16
|
import { readTsconfig } from "./lib/alias/tsconfig.mjs";
|
|
17
|
+
import { analyzeCss, applyCssFixes } from "./lib/css/index.mjs";
|
|
17
18
|
import { checkLucide } from "./lib/doctor/lucide.mjs";
|
|
18
19
|
import { generateRegistry, loadIconTables } from "./lib/icons/generate.mjs";
|
|
19
20
|
import { generate } from "./lib/openapi/generate.mjs";
|
|
@@ -250,6 +251,74 @@ const STATEFUL_DEPS = [
|
|
|
250
251
|
"react-router-dom",
|
|
251
252
|
];
|
|
252
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Doctor rows for the CSS analysis.
|
|
256
|
+
*
|
|
257
|
+
* Findings are capped per severity because a report nobody reads to the end is a
|
|
258
|
+
* report that hides its own first line. The cap is stated, and the pointer to the
|
|
259
|
+
* uncapped list (`tempest fix --dry-run`) goes with it — a silent truncation
|
|
260
|
+
* would read as "that is all of them".
|
|
261
|
+
*
|
|
262
|
+
* @param {number} [limit] - Findings shown per severity.
|
|
263
|
+
* @returns {Array<[status: string, label: string, detail?: string]>} Check rows.
|
|
264
|
+
*/
|
|
265
|
+
function cssChecks(limit = 6) {
|
|
266
|
+
let analysis;
|
|
267
|
+
try {
|
|
268
|
+
analysis = analyzeCss({ root: ROOT, targets: ["."], selfDir: SELF_DIR });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return [["warn", "CSS analysis failed", String(err?.message ?? err)]];
|
|
271
|
+
}
|
|
272
|
+
if (analysis.stats.files === 0) return [];
|
|
273
|
+
|
|
274
|
+
const rows = [["section", "Stylesheets"]];
|
|
275
|
+
rows.push([
|
|
276
|
+
"info",
|
|
277
|
+
`${analysis.stats.files} stylesheet(s) · ${analysis.stats.rules} rules · ${analysis.stats.declarations} declarations`,
|
|
278
|
+
analysis.tokens.source ? "" : "SDK token table not found — token checks skipped",
|
|
279
|
+
]);
|
|
280
|
+
if (analysis.truncated) {
|
|
281
|
+
rows.push([
|
|
282
|
+
"info",
|
|
283
|
+
"file cap reached",
|
|
284
|
+
"only the first 600 stylesheets were analyzed — narrow the run with `tempest fix <path> --dry-run`",
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const status = { error: "fail", warn: "warn", info: "info" };
|
|
289
|
+
let hidden = 0;
|
|
290
|
+
for (const severity of ["error", "warn", "info"]) {
|
|
291
|
+
const group = analysis.findings.filter((f) => f.severity === severity);
|
|
292
|
+
for (const f of group.slice(0, limit)) {
|
|
293
|
+
rows.push([status[severity], `${f.file}:${f.line}`, f.message]);
|
|
294
|
+
}
|
|
295
|
+
hidden += Math.max(0, group.length - limit);
|
|
296
|
+
}
|
|
297
|
+
if (hidden > 0) {
|
|
298
|
+
rows.push([
|
|
299
|
+
"info",
|
|
300
|
+
`${hidden} more CSS finding(s) not shown`,
|
|
301
|
+
"run `tempest fix --dry-run` for the full list",
|
|
302
|
+
]);
|
|
303
|
+
}
|
|
304
|
+
if (analysis.findings.length === 0) {
|
|
305
|
+
rows.push(["ok", "no CSS problems found"]);
|
|
306
|
+
} else {
|
|
307
|
+
const fixable = analysis.findings.filter((f) => f.fixable).length;
|
|
308
|
+
if (fixable > 0) {
|
|
309
|
+
rows.push([
|
|
310
|
+
"info",
|
|
311
|
+
`${fixable} finding(s) are auto-fixable`,
|
|
312
|
+
"run `tempest fix` — it removes only what is provably dead",
|
|
313
|
+
]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const skip of analysis.skipped.slice(0, 3)) {
|
|
317
|
+
rows.push(["info", `skipped ${skip.file}`, skip.reason]);
|
|
318
|
+
}
|
|
319
|
+
return rows;
|
|
320
|
+
}
|
|
321
|
+
|
|
253
322
|
function doctor() {
|
|
254
323
|
const checks = [];
|
|
255
324
|
const pkg = readJSON(join(ROOT, "package.json"));
|
|
@@ -312,16 +381,45 @@ function doctor() {
|
|
|
312
381
|
checks.push(["section", "Project"]);
|
|
313
382
|
checks.push(["ok", "package.json found"]);
|
|
314
383
|
const sdkInstalled = installedVersion("tempest-react-sdk");
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Whether this project has adopted the SDK.
|
|
387
|
+
*
|
|
388
|
+
* When it has not, `doctor` runs in **generic mode**: the checks that only make
|
|
389
|
+
* sense once you use the SDK (its `@/*` alias, `createViteConfig`, the
|
|
390
|
+
* `styles.css` import, its optional peers) drop out, and not having the SDK is
|
|
391
|
+
* reported as information rather than as a defect.
|
|
392
|
+
*
|
|
393
|
+
* Without this the tool is useless on the exact project it should help most —
|
|
394
|
+
* somebody's existing app, being evaluated. It reported two hard failures for the
|
|
395
|
+
* single fact "you have not installed this yet", exited non-zero, and buried the
|
|
396
|
+
* findings that *were* actionable (no lockfile, no plugin-react, no linter) among
|
|
397
|
+
* warnings that were really just the SDK's own conventions.
|
|
398
|
+
*/
|
|
399
|
+
const usesSdk = Boolean(deps["tempest-react-sdk"] || sdkInstalled);
|
|
400
|
+
|
|
401
|
+
if (usesSdk) {
|
|
402
|
+
checks.push(
|
|
403
|
+
deps["tempest-react-sdk"]
|
|
404
|
+
? ["ok", "tempest-react-sdk in dependencies", deps["tempest-react-sdk"]]
|
|
405
|
+
: [
|
|
406
|
+
"warn",
|
|
407
|
+
"tempest-react-sdk not in dependencies",
|
|
408
|
+
`installed as v${sdkInstalled} but undeclared — add it to package.json`,
|
|
409
|
+
],
|
|
410
|
+
);
|
|
411
|
+
checks.push(
|
|
412
|
+
sdkInstalled
|
|
413
|
+
? ["ok", "tempest-react-sdk installed", `v${sdkInstalled}`]
|
|
414
|
+
: ["fail", "tempest-react-sdk installed", "run npm install"],
|
|
415
|
+
);
|
|
416
|
+
} else {
|
|
417
|
+
checks.push([
|
|
418
|
+
"info",
|
|
419
|
+
"tempest-react-sdk not installed",
|
|
420
|
+
"checking generic React/Vite health only — `npm i tempest-react-sdk` to adopt it",
|
|
421
|
+
]);
|
|
422
|
+
}
|
|
325
423
|
const reactV = installedVersion("react");
|
|
326
424
|
checks.push(
|
|
327
425
|
deps.react && deps["react-dom"]
|
|
@@ -438,11 +536,15 @@ function doctor() {
|
|
|
438
536
|
if (tsc) {
|
|
439
537
|
checks.push(["section", "TypeScript"]);
|
|
440
538
|
const co = tsc.compilerOptions;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
539
|
+
// The `@/*` alias is the SDK's convention, not a health property: a project
|
|
540
|
+
// that has not adopted the SDK is not wrong for lacking it.
|
|
541
|
+
if (usesSdk) {
|
|
542
|
+
checks.push(
|
|
543
|
+
co.paths?.["@/*"]
|
|
544
|
+
? ["ok", 'tsconfig "@/*" alias']
|
|
545
|
+
: ["warn", 'tsconfig "@/*" alias', 'add "paths": { "@/*": ["./src/*"] }'],
|
|
546
|
+
);
|
|
547
|
+
}
|
|
446
548
|
const mr = String(co.moduleResolution ?? "").toLowerCase();
|
|
447
549
|
checks.push(
|
|
448
550
|
["bundler", "node16", "nodenext"].includes(mr)
|
|
@@ -450,7 +552,9 @@ function doctor() {
|
|
|
450
552
|
: [
|
|
451
553
|
"warn",
|
|
452
554
|
`moduleResolution: ${co.moduleResolution ?? "(unset)"}`,
|
|
453
|
-
|
|
555
|
+
usesSdk
|
|
556
|
+
? 'use "bundler" — otherwise subpath types (tempest-react-sdk/br, /charts…) won\'t resolve'
|
|
557
|
+
: 'use "bundler" — a bundled app needs it for any package that ships subpath exports',
|
|
454
558
|
],
|
|
455
559
|
);
|
|
456
560
|
checks.push(
|
|
@@ -491,11 +595,21 @@ function doctor() {
|
|
|
491
595
|
if (!viteCfg) {
|
|
492
596
|
checks.push(["warn", "vite config", "no vite.config.* found"]);
|
|
493
597
|
} else {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
598
|
+
// `createViteConfig` is a convenience, not a requirement — only worth
|
|
599
|
+
// mentioning to a project that already uses the SDK, and even then as info.
|
|
600
|
+
if (usesSdk) {
|
|
601
|
+
checks.push(
|
|
602
|
+
fileIncludes(join(ROOT, viteCfg), "createViteConfig")
|
|
603
|
+
? ["ok", `${viteCfg} uses createViteConfig`]
|
|
604
|
+
: [
|
|
605
|
+
"info",
|
|
606
|
+
`${viteCfg}`,
|
|
607
|
+
"not using createViteConfig from tempest-react-sdk/vite — optional",
|
|
608
|
+
],
|
|
609
|
+
);
|
|
610
|
+
} else {
|
|
611
|
+
checks.push(["ok", `${viteCfg} found`]);
|
|
612
|
+
}
|
|
499
613
|
// React plugin is required for JSX/Fast Refresh in a Vite React app.
|
|
500
614
|
checks.push(
|
|
501
615
|
installedVersion("@vitejs/plugin-react")
|
|
@@ -508,14 +622,23 @@ function doctor() {
|
|
|
508
622
|
);
|
|
509
623
|
}
|
|
510
624
|
const entry = firstExisting(["src/main.tsx", "src/main.ts", "src/index.tsx", "src/index.ts"]);
|
|
511
|
-
if (
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
625
|
+
if (usesSdk) {
|
|
626
|
+
// Without the stylesheet every component renders unstyled, so for an SDK
|
|
627
|
+
// project this is a real defect. For anyone else the entry's filename is
|
|
628
|
+
// their business, not ours.
|
|
629
|
+
if (entry) {
|
|
630
|
+
checks.push(
|
|
631
|
+
fileIncludes(join(ROOT, entry), "tempest-react-sdk/styles.css")
|
|
632
|
+
? ["ok", `${entry} imports styles.css`]
|
|
633
|
+
: ["warn", `${entry}`, 'add import "tempest-react-sdk/styles.css"'],
|
|
634
|
+
);
|
|
635
|
+
} else {
|
|
636
|
+
checks.push([
|
|
637
|
+
"warn",
|
|
638
|
+
"app entry",
|
|
639
|
+
"none of src/main.tsx, src/main.ts, src/index.tsx, src/index.ts found — cannot verify the styles.css import",
|
|
640
|
+
]);
|
|
641
|
+
}
|
|
519
642
|
}
|
|
520
643
|
// styles.css should be imported once — duplicates re-inject the whole sheet.
|
|
521
644
|
const stylesImports = (src.match(/tempest-react-sdk\/styles\.css/g) ?? []).length;
|
|
@@ -527,6 +650,9 @@ function doctor() {
|
|
|
527
650
|
]);
|
|
528
651
|
}
|
|
529
652
|
|
|
653
|
+
// ── Stylesheets ───────────────────────────────────────────────────────────
|
|
654
|
+
checks.push(...cssChecks());
|
|
655
|
+
|
|
530
656
|
// ── Tooling ────────────────────────────────────────────────────────────────
|
|
531
657
|
checks.push(["section", "Tooling"]);
|
|
532
658
|
checks.push(
|
|
@@ -600,6 +726,26 @@ function doctor() {
|
|
|
600
726
|
checks.push(["ok", "client env vars use the VITE_ prefix"]);
|
|
601
727
|
}
|
|
602
728
|
|
|
729
|
+
/*
|
|
730
|
+
* In generic mode, close with what adoption would actually take. A tool that
|
|
731
|
+
* audits somebody's project and then says nothing about the next step reads as an
|
|
732
|
+
* ad for a product they cannot find the door to.
|
|
733
|
+
*/
|
|
734
|
+
if (!usesSdk) {
|
|
735
|
+
checks.push(["section", "Adopting the SDK (optional)"]);
|
|
736
|
+
checks.push(["info", "install", "npm i tempest-react-sdk"]);
|
|
737
|
+
checks.push([
|
|
738
|
+
"info",
|
|
739
|
+
"import the stylesheet once, in your entry",
|
|
740
|
+
'import "tempest-react-sdk/styles.css"',
|
|
741
|
+
]);
|
|
742
|
+
checks.push([
|
|
743
|
+
"info",
|
|
744
|
+
"not all-or-nothing",
|
|
745
|
+
"one component at a time works — the @/* alias, createViteConfig and the scaffold are all optional",
|
|
746
|
+
]);
|
|
747
|
+
}
|
|
748
|
+
|
|
603
749
|
return report(checks);
|
|
604
750
|
}
|
|
605
751
|
|
|
@@ -668,7 +814,7 @@ function lint(args) {
|
|
|
668
814
|
}
|
|
669
815
|
|
|
670
816
|
/** Flags `fix` owns itself; anything else is rejected rather than forwarded. */
|
|
671
|
-
const FIX_FLAGS = new Set(["--no-alias", "--dry-run"]);
|
|
817
|
+
const FIX_FLAGS = new Set(["--no-alias", "--no-css", "--dry-run"]);
|
|
672
818
|
|
|
673
819
|
/**
|
|
674
820
|
* Print the alias pass result.
|
|
@@ -711,6 +857,91 @@ function reportAlias(result, dryRun) {
|
|
|
711
857
|
);
|
|
712
858
|
}
|
|
713
859
|
|
|
860
|
+
/** Prefix for a finding line: red for a browser-visible defect, yellow otherwise. */
|
|
861
|
+
function severityMark(severity) {
|
|
862
|
+
if (severity === "error") return `${c.red}✗${c.reset}`;
|
|
863
|
+
return severity === "warn" ? `${c.yellow}!${c.reset}` : `${c.cyan}i${c.reset}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The CSS pass of `fix`: report what the analysis found, then remove the subset
|
|
868
|
+
* that is provably dead.
|
|
869
|
+
*
|
|
870
|
+
* Findings that need a human are printed in full here rather than capped, because
|
|
871
|
+
* `--dry-run` is the review surface — `doctor` is the summary, this is the list
|
|
872
|
+
* you read before letting the tool write. Only the advisory (`info`) tail is
|
|
873
|
+
* capped, since "this could be a token" scales with the size of the project.
|
|
874
|
+
*
|
|
875
|
+
* @param {object} params
|
|
876
|
+
* @param {string[]} params.targets - Positional paths from the command line.
|
|
877
|
+
* @param {boolean} params.dryRun
|
|
878
|
+
* @returns {number} Exit status contribution.
|
|
879
|
+
*/
|
|
880
|
+
function cssPass({ targets, dryRun }) {
|
|
881
|
+
console.log(
|
|
882
|
+
`${c.dim}→ css (dedupe declarations · drop dead rules)${dryRun ? " [dry-run]" : ""}${c.reset}`,
|
|
883
|
+
);
|
|
884
|
+
let analysis;
|
|
885
|
+
try {
|
|
886
|
+
analysis = analyzeCss({ root: ROOT, targets, selfDir: SELF_DIR });
|
|
887
|
+
} catch (err) {
|
|
888
|
+
console.log(
|
|
889
|
+
` ${c.red}✗ analysis failed${c.reset} ${c.dim}${err?.message ?? err}${c.reset}`,
|
|
890
|
+
);
|
|
891
|
+
return 1;
|
|
892
|
+
}
|
|
893
|
+
if (analysis.stats.files === 0) {
|
|
894
|
+
console.log(` ${c.dim}no stylesheets found${c.reset}`);
|
|
895
|
+
return 0;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const manual = analysis.findings.filter((f) => !f.fixable);
|
|
899
|
+
const infoLimit = 10;
|
|
900
|
+
let shownInfo = 0;
|
|
901
|
+
let hiddenInfo = 0;
|
|
902
|
+
for (const f of manual) {
|
|
903
|
+
if (f.severity === "info") {
|
|
904
|
+
if (shownInfo >= infoLimit) {
|
|
905
|
+
hiddenInfo += 1;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
shownInfo += 1;
|
|
909
|
+
}
|
|
910
|
+
console.log(
|
|
911
|
+
` [${severityMark(f.severity)}] ${f.file}:${f.line} ${c.dim}${f.message}${c.reset}`,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
if (hiddenInfo > 0) {
|
|
915
|
+
console.log(` ${c.dim}…+${hiddenInfo} more suggestion(s)${c.reset}`);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const result = applyCssFixes({ analysis, dryRun });
|
|
919
|
+
for (const file of result.files) {
|
|
920
|
+
console.log(` ${file.file} ${c.dim}${file.changes.length}${c.reset}`);
|
|
921
|
+
for (const change of file.changes) {
|
|
922
|
+
console.log(` ${c.dim}${change.line}:${c.reset} ${change.message}`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
for (const err of result.errors) {
|
|
926
|
+
console.log(` ${c.red}✗ ${err.file}${c.reset} ${c.dim}${err.message}${c.reset}`);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (result.total === 0) {
|
|
930
|
+
console.log(` ${c.dim}nothing to remove${c.reset}`);
|
|
931
|
+
} else {
|
|
932
|
+
const verb = dryRun ? "would remove" : "removed";
|
|
933
|
+
console.log(
|
|
934
|
+
` ${c.green}✓${c.reset} ${verb} ${result.total} dead declaration(s)/rule(s) in ${result.files.length} file(s)`,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
if (analysis.counts.error > 0) {
|
|
938
|
+
console.log(
|
|
939
|
+
` ${c.red}✗ ${analysis.counts.error} CSS syntax error(s)${c.reset} ${c.dim}— those files were not touched; fix them and run again${c.reset}`,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
return result.errors.length || analysis.counts.error ? 1 : 0;
|
|
943
|
+
}
|
|
944
|
+
|
|
714
945
|
function fix(args) {
|
|
715
946
|
const { flags, paths } = splitArgs(args);
|
|
716
947
|
const unknown = flags.filter((f) => !FIX_FLAGS.has(f));
|
|
@@ -730,7 +961,10 @@ function fix(args) {
|
|
|
730
961
|
reportAlias(result, dryRun);
|
|
731
962
|
aliasStatus = result.status === "error" ? 1 : 0;
|
|
732
963
|
}
|
|
733
|
-
|
|
964
|
+
|
|
965
|
+
const cssStatus = flags.includes("--no-css") ? 0 : cssPass({ targets, dryRun });
|
|
966
|
+
|
|
967
|
+
if (dryRun) return aliasStatus || cssStatus;
|
|
734
968
|
|
|
735
969
|
console.log(`${c.dim}→ eslint --fix (sort imports · drop unused · tidy whitespace)${c.reset}`);
|
|
736
970
|
const eslintStatus = run(requireBin("eslint"), [...targets, "--fix"]);
|
|
@@ -742,7 +976,7 @@ function fix(args) {
|
|
|
742
976
|
} else {
|
|
743
977
|
console.log(`${c.yellow}! prettier not installed — skipping format pass${c.reset}`);
|
|
744
978
|
}
|
|
745
|
-
return aliasStatus || eslintStatus || prettierStatus;
|
|
979
|
+
return aliasStatus || cssStatus || eslintStatus || prettierStatus;
|
|
746
980
|
}
|
|
747
981
|
|
|
748
982
|
function format(args) {
|
|
@@ -878,8 +1112,8 @@ ${c.bold}Usage${c.reset}
|
|
|
878
1112
|
${c.bold}Commands${c.reset}
|
|
879
1113
|
${c.bold}doctor${c.reset} Health-check the current project
|
|
880
1114
|
${c.bold}lint${c.reset} [paths] Run ESLint (report only)
|
|
881
|
-
${c.bold}fix${c.reset} [paths] Alias imports (../ → @/) + ESLint --fix
|
|
882
|
-
unused, tidy whitespace) + Prettier
|
|
1115
|
+
${c.bold}fix${c.reset} [paths] Alias imports (../ → @/) + CSS dead-code removal + ESLint --fix
|
|
1116
|
+
(sort imports, remove unused, tidy whitespace) + Prettier
|
|
883
1117
|
${c.bold}format${c.reset} [paths] Prettier --write
|
|
884
1118
|
${c.bold}gen api${c.reset} <src> Generate Zod schemas + types + service classes from an OpenAPI spec
|
|
885
1119
|
(e.g. tempest gen api http://127.0.0.1:8000/openapi.json --out src/api)
|
|
@@ -887,8 +1121,10 @@ ${c.bold}Commands${c.reset}
|
|
|
887
1121
|
(e.g. tempest gen icons --out src/icons.generated.ts --dir src)
|
|
888
1122
|
|
|
889
1123
|
${c.bold}fix options${c.reset}
|
|
890
|
-
--dry-run List the imports that would be rewritten;
|
|
891
|
-
|
|
1124
|
+
--dry-run List every CSS finding and the imports that would be rewritten;
|
|
1125
|
+
write nothing
|
|
1126
|
+
--no-alias Skip the alias pass
|
|
1127
|
+
--no-css Skip the CSS pass (analysis and dead-code removal)
|
|
892
1128
|
|
|
893
1129
|
${c.bold}Options${c.reset}
|
|
894
1130
|
-h, --help Show this help
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=7,t=9,n=3;function r(e){return`var(--tempest-chart-${e})`}function i(e,t,n){if(!Number.isFinite(e))return 0;let r=Math.min(t,n),i=Math.max(t,n);return i===r?.5:Math.min(1,Math.max(0,(e-r)/(i-r)))}function a(e,t){return Math.min(t,Math.max(1,Math.round(e*(t-1))+1))}function o(e){let{min:t,max:n,ordinal:o=!1}=e,s=o?3:1,c=7-s+1;return e=>r(`sequential-${s+a(i(e,t,n),c)-1}`)}function s(e){let{min:t,max:n,center:i=0}=e,o=Math.abs(i-Math.min(t,n)),s=Math.abs(Math.max(t,n)-i);return e=>r(!Number.isFinite(e)||e===i?`diverging-5`:e<i?o===0?`diverging-5`:`diverging-${5-a(Math.min(1,(i-e)/o),4)}`:s===0?`diverging-5`:`diverging-${5+a(Math.min(1,(e-i)/s),4)}`)}function c(e){let t=e===`sequential`?7:9;return Array.from({length:t},(t,n)=>r(`${e}-${n+1}`))}exports.DIVERGING_STEP_COUNT=t,exports.ORDINAL_START_STEP=n,exports.SEQUENTIAL_STEP_COUNT=e,exports.divergingScale=s,exports.scaleSteps=c,exports.sequentialScale=o;
|
|
2
|
+
//# sourceMappingURL=scales.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scales.cjs","names":[],"sources":["../../src/charts/scales.ts"],"sourcesContent":["/** How many steps the sequential token scale has. */\nexport const SEQUENTIAL_STEP_COUNT = 7;\n\n/** How many steps the diverging token scale has, midpoint included. */\nexport const DIVERGING_STEP_COUNT = 9;\n\n/**\n * First sequential step that clears 2:1 against the chart surface.\n *\n * A sequential scale may let its near-zero end recede into the surface — on a\n * heatmap that is exactly what \"almost nothing\" should look like. An **ordinal**\n * scale may not: every step is a discrete mark someone has to see. Starting an\n * ordinal ramp here is the difference.\n */\nexport const ORDINAL_START_STEP = 3;\n\n/** A `var(--tempest-chart-…)` reference, so the value follows the active theme. */\nexport type ChartColorToken = string;\n\n/** Reference a token by name rather than interpolating the string at each call site. */\nfunction token(name: string): ChartColorToken {\n return `var(--tempest-chart-${name})`;\n}\n\n/**\n * Clamp `value` into `0…1` against a domain, tolerating a reversed or empty one.\n *\n * A zero-width domain (every datum equal) would otherwise divide by zero and paint\n * the whole chart `NaN`; it resolves to the middle of the scale instead, which is\n * the honest reading of \"no variation\".\n */\nfunction normalize(value: number, min: number, max: number): number {\n if (!Number.isFinite(value)) return 0;\n const lo = Math.min(min, max);\n const hi = Math.max(min, max);\n if (hi === lo) return 0.5;\n return Math.min(1, Math.max(0, (value - lo) / (hi - lo)));\n}\n\n/** Map a `0…1` position onto `1…steps`, inclusive. */\nfunction stepOf(t: number, steps: number): number {\n return Math.min(steps, Math.max(1, Math.round(t * (steps - 1)) + 1));\n}\n\nexport interface SequentialScaleOptions {\n /** Lowest value in the data. */\n min: number;\n /** Highest value in the data. */\n max: number;\n /**\n * Keep every step visible against the surface, for discrete ordered marks.\n *\n * Off by default: a heatmap *wants* its near-zero cells to recede. Turn it on\n * for tiers, funnel stages or anything where each step is its own mark.\n */\n ordinal?: boolean;\n}\n\n/**\n * Build a magnitude scale over the sequential tokens.\n *\n * Returns `var(--tempest-chart-sequential-N)` rather than a hex string, so a\n * heatmap painted once follows the theme — including dark mode, whose steps are\n * chosen for the dark surface rather than flipped.\n *\n * @example\n * const color = sequentialScale({ min: 0, max: 250 });\n * <rect fill={color(value)} />\n *\n * @param options - The data domain, and whether every step must stay visible.\n * @returns A function from value to a CSS colour reference.\n */\nexport function sequentialScale(\n options: SequentialScaleOptions,\n): (value: number) => ChartColorToken {\n const { min, max, ordinal = false } = options;\n const first = ordinal ? ORDINAL_START_STEP : 1;\n const span = SEQUENTIAL_STEP_COUNT - first + 1;\n return (value) => token(`sequential-${first + stepOf(normalize(value, min, max), span) - 1}`);\n}\n\nexport interface DivergingScaleOptions {\n /** Lowest value in the data. */\n min: number;\n /** Highest value in the data. */\n max: number;\n /**\n * The value that means \"no deviation\". Default `0`.\n *\n * It is a parameter because the interesting midpoint is often not zero — a\n * budget variance diverges around the target, not around nothing.\n */\n center?: number;\n}\n\n/**\n * Build a polarity scale over the diverging tokens.\n *\n * Each arm is scaled against its **own** distance from the centre, so an asymmetric\n * domain (say −5…+80) still uses the full cool arm for its small negatives. Scaling\n * both arms by the wider one — the easy mistake — would collapse every negative\n * into the step next to the midpoint and hide the sign entirely.\n *\n * @example\n * const color = divergingScale({ min: -12, max: 40 }); // centre 0\n * const budget = divergingScale({ min: 80, max: 130, center: 100 });\n *\n * @param options - The data domain and the neutral centre.\n * @returns A function from value to a CSS colour reference.\n */\nexport function divergingScale(options: DivergingScaleOptions): (value: number) => ChartColorToken {\n const { min, max, center = 0 } = options;\n const mid = Math.ceil(DIVERGING_STEP_COUNT / 2);\n const armSteps = mid - 1;\n const coolSpan = Math.abs(center - Math.min(min, max));\n const warmSpan = Math.abs(Math.max(min, max) - center);\n\n return (value) => {\n if (!Number.isFinite(value) || value === center) return token(`diverging-${mid}`);\n if (value < center) {\n if (coolSpan === 0) return token(`diverging-${mid}`);\n const t = Math.min(1, (center - value) / coolSpan);\n // Step 1 is the cool extreme, so a bigger deviation walks toward it.\n return token(`diverging-${mid - stepOf(t, armSteps)}`);\n }\n if (warmSpan === 0) return token(`diverging-${mid}`);\n const t = Math.min(1, (value - center) / warmSpan);\n return token(`diverging-${mid + stepOf(t, armSteps)}`);\n };\n}\n\n/**\n * Every step of a token scale, in order — for rendering a legend.\n *\n * A continuous scale needs a legend showing the ramp with its end labels; without\n * one the reader has no way to turn a colour back into a number.\n *\n * @param kind - Which scale.\n * @returns The token references, lightest/coolest first.\n */\nexport function scaleSteps(kind: \"sequential\" | \"diverging\"): ChartColorToken[] {\n const count = kind === \"sequential\" ? SEQUENTIAL_STEP_COUNT : DIVERGING_STEP_COUNT;\n return Array.from({ length: count }, (_, i) => token(`${kind}-${i + 1}`));\n}\n"],"mappings":"AACA,IAAa,EAAwB,EAGxB,EAAuB,EAUvB,EAAqB,EAMlC,SAAS,EAAM,EAA+B,CAC1C,MAAO,uBAAuB,EAAK,EACvC,CASA,SAAS,EAAU,EAAe,EAAa,EAAqB,CAChE,GAAI,CAAC,OAAO,SAAS,CAAK,EAAG,MAAO,GACpC,IAAM,EAAK,KAAK,IAAI,EAAK,CAAG,EACtB,EAAK,KAAK,IAAI,EAAK,CAAG,EAE5B,OADI,IAAO,EAAW,GACf,KAAK,IAAI,EAAG,KAAK,IAAI,GAAI,EAAQ,IAAO,EAAK,EAAG,CAAC,CAC5D,CAGA,SAAS,EAAO,EAAW,EAAuB,CAC9C,OAAO,KAAK,IAAI,EAAO,KAAK,IAAI,EAAG,KAAK,MAAM,GAAK,EAAQ,EAAE,EAAI,CAAC,CAAC,CACvE,CA8BA,SAAgB,EACZ,EACkC,CAClC,GAAM,CAAE,MAAK,MAAK,UAAU,IAAU,EAChC,EAAQ,EAAA,EAA+B,EACvC,EAAA,EAA+B,EAAQ,EAC7C,MAAQ,IAAU,EAAM,cAAc,EAAQ,EAAO,EAAU,EAAO,EAAK,CAAG,EAAG,CAAI,EAAI,GAAG,CAChG,CA+BA,SAAgB,EAAe,EAAoE,CAC/F,GAAM,CAAE,MAAK,MAAK,SAAS,GAAM,EAG3B,EAAW,KAAK,IAAI,EAAS,KAAK,IAAI,EAAK,CAAG,CAAC,EAC/C,EAAW,KAAK,IAAI,KAAK,IAAI,EAAK,CAAG,EAAI,CAAM,EAErD,MAAQ,IACoD,EAApD,CAAC,OAAO,SAAS,CAAK,GAAK,IAAU,EAAqB,cAC1D,EAAQ,EACJ,IAAa,EAAgB,cAGpB,aAAa,EAAM,EAFtB,KAAK,IAAI,GAAI,EAAS,GAAS,CAEF,EAAG,CAAQ,IAElD,IAAa,EAAgB,cAEpB,aAAa,EAAM,EADtB,KAAK,IAAI,GAAI,EAAQ,GAAU,CACF,EAAG,CAAQ,GAT8B,CAWxF,CAWA,SAAgB,EAAW,EAAqD,CAC5E,IAAM,EAAQ,IAAS,aAAA,EAAA,EACvB,OAAO,MAAM,KAAK,CAAE,OAAQ,CAAM,GAAI,EAAG,IAAM,EAAM,GAAG,EAAK,GAAG,EAAI,GAAG,CAAC,CAC5E"}
|