dashtaste 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +124 -0
  5. package/dist/color.d.ts +4 -0
  6. package/dist/color.js +43 -0
  7. package/dist/engine.d.ts +11 -0
  8. package/dist/engine.js +39 -0
  9. package/dist/extract-in-page.d.ts +3 -0
  10. package/dist/extract-in-page.js +149 -0
  11. package/dist/extract.d.ts +3 -0
  12. package/dist/extract.js +39 -0
  13. package/dist/format.d.ts +18 -0
  14. package/dist/format.js +65 -0
  15. package/dist/index.d.ts +5 -0
  16. package/dist/index.js +5 -0
  17. package/dist/packs/cn.d.ts +2 -0
  18. package/dist/packs/cn.js +56 -0
  19. package/dist/packs/global.d.ts +2 -0
  20. package/dist/packs/global.js +45 -0
  21. package/dist/packs/index.d.ts +53 -0
  22. package/dist/packs/index.js +13 -0
  23. package/dist/rules/dt001-default-palette.d.ts +3 -0
  24. package/dist/rules/dt001-default-palette.js +11 -0
  25. package/dist/rules/dt002-rainbow-palette.d.ts +3 -0
  26. package/dist/rules/dt002-rainbow-palette.js +11 -0
  27. package/dist/rules/dt003-smooth-line.d.ts +3 -0
  28. package/dist/rules/dt003-smooth-line.js +9 -0
  29. package/dist/rules/dt004-symbols-everywhere.d.ts +3 -0
  30. package/dist/rules/dt004-symbols-everywhere.js +21 -0
  31. package/dist/rules/dt005-pie-slices.d.ts +3 -0
  32. package/dist/rules/dt005-pie-slices.js +13 -0
  33. package/dist/rules/dt006-pie-overuse.d.ts +3 -0
  34. package/dist/rules/dt006-pie-overuse.js +14 -0
  35. package/dist/rules/dt007-three-d.d.ts +3 -0
  36. package/dist/rules/dt007-three-d.js +16 -0
  37. package/dist/rules/dt008-dual-axis.d.ts +3 -0
  38. package/dist/rules/dt008-dual-axis.js +10 -0
  39. package/dist/rules/dt009-missing-unit.d.ts +3 -0
  40. package/dist/rules/dt009-missing-unit.js +8 -0
  41. package/dist/rules/dt010-bar-baseline.d.ts +3 -0
  42. package/dist/rules/dt010-bar-baseline.js +11 -0
  43. package/dist/rules/dt011-raw-large-numbers.d.ts +3 -0
  44. package/dist/rules/dt011-raw-large-numbers.js +21 -0
  45. package/dist/rules/dt012-unit-system.d.ts +3 -0
  46. package/dist/rules/dt012-unit-system.js +12 -0
  47. package/dist/rules/dt013-delta-convention.d.ts +3 -0
  48. package/dist/rules/dt013-delta-convention.js +21 -0
  49. package/dist/rules/dt014-delta-colour-only.d.ts +3 -0
  50. package/dist/rules/dt014-delta-colour-only.js +16 -0
  51. package/dist/rules/dt015-low-contrast-text.d.ts +3 -0
  52. package/dist/rules/dt015-low-contrast-text.js +28 -0
  53. package/dist/rules/dt016-thin-line-contrast.d.ts +3 -0
  54. package/dist/rules/dt016-thin-line-contrast.js +12 -0
  55. package/dist/rules/dt017-gradient-decoration.d.ts +3 -0
  56. package/dist/rules/dt017-gradient-decoration.js +15 -0
  57. package/dist/rules/dt018-glow.d.ts +3 -0
  58. package/dist/rules/dt018-glow.js +27 -0
  59. package/dist/rules/dt019-kpi-overload.d.ts +3 -0
  60. package/dist/rules/dt019-kpi-overload.js +7 -0
  61. package/dist/rules/dt020-kpi-rainbow.d.ts +3 -0
  62. package/dist/rules/dt020-kpi-rainbow.js +27 -0
  63. package/dist/rules/dt021-emoji-icons.d.ts +3 -0
  64. package/dist/rules/dt021-emoji-icons.js +10 -0
  65. package/dist/rules/dt022-cjk-min-size.d.ts +3 -0
  66. package/dist/rules/dt022-cjk-min-size.js +9 -0
  67. package/dist/rules/dt023-legend-single-series.d.ts +3 -0
  68. package/dist/rules/dt023-legend-single-series.js +8 -0
  69. package/dist/rules/dt024-bar-width.d.ts +1 -0
  70. package/dist/rules/dt024-bar-width.js +14 -0
  71. package/dist/rules/dt025-legend-not-direct-label.d.ts +1 -0
  72. package/dist/rules/dt025-legend-not-direct-label.js +11 -0
  73. package/dist/rules/dt026-line-weight.d.ts +1 -0
  74. package/dist/rules/dt026-line-weight.js +8 -0
  75. package/dist/rules/dt027-tabular-numerals.d.ts +1 -0
  76. package/dist/rules/dt027-tabular-numerals.js +16 -0
  77. package/dist/rules/dt028-hero-metric.d.ts +1 -0
  78. package/dist/rules/dt028-hero-metric.js +14 -0
  79. package/dist/rules/helpers.d.ts +32 -0
  80. package/dist/rules/helpers.js +161 -0
  81. package/dist/rules/index.d.ts +2 -0
  82. package/dist/rules/index.js +58 -0
  83. package/dist/types.d.ts +122 -0
  84. package/dist/types.js +1 -0
  85. package/docs/rules.md +735 -0
  86. package/docs/usage.md +126 -0
  87. package/package.json +74 -4
  88. package/skills/dashtaste-cn/SKILL.md +51 -0
  89. package/skills/dashtaste-cn/references/avoid.md +135 -0
  90. package/skills/dashtaste-cn/references/charts.md +122 -0
  91. package/skills/dashtaste-cn/references/layout.md +78 -0
  92. package/skills/dashtaste-cn/references/numbers.md +93 -0
  93. package/skills/dashtaste-cn/references/tokens.md +72 -0
  94. package/skills/dashtaste-global/SKILL.md +43 -0
  95. package/skills/dashtaste-global/references/avoid.md +135 -0
  96. package/skills/dashtaste-global/references/charts.md +122 -0
  97. package/skills/dashtaste-global/references/layout.md +78 -0
  98. package/skills/dashtaste-global/references/numbers.md +83 -0
  99. package/skills/dashtaste-global/references/tokens.md +65 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felix Fang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # dashtaste
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Two style-pack skills for coding agents, plus a `dashtaste check` CLI for chart configs and rendered dashboards.
4
+
5
+ | Before | After |
6
+ | ----------------------------------- | --------------------------------- |
7
+ | ![Before](gallery/store-before.png) | ![After](gallery/store-after.png) |
8
+
9
+ ## Install the skill
10
+
11
+ Choose a pack for the dashboard's audience:
12
+
13
+ ```sh
14
+ npx skills add FelixFangX/dashtaste --skill dashtaste-global
15
+ npx skills add FelixFangX/dashtaste --skill dashtaste-cn
16
+ ```
17
+
18
+ For manual installation, copy the folder from [skills/](skills/) into your agent's skills directory. Ask the agent to follow the selected skill when building or reviewing a dashboard.
19
+
20
+ ## Check
21
+
22
+ ```sh
23
+ npx dashtaste check dashboard.json --pack cn
24
+ npx dashtaste check http://localhost:5173 --pack global
25
+ npx dashtaste check dashboard.json --pack cn --format json
26
+ npx dashtaste check dashboard.json --pack cn --disable DT003,DT005
27
+ ```
28
+
29
+ Requires Node 20.19 or newer. Checking a URL or HTML file also needs a local Chromium (set `CHROME_PATH`) and the optional `playwright-core` dependency; JSON configs need neither.
30
+
31
+ To run from a checkout instead, use pnpm:
32
+
33
+ ```sh
34
+ pnpm install
35
+ pnpm build
36
+ node dist/cli.js check gallery/pages/store/before.html --pack cn --only DT003,DT005,DT010
37
+ ```
38
+
39
+ Actual output from that three-rule gallery check:
40
+
41
+ ```text
42
+ gallery/pages/store/before.html
43
+ warn DT003 charts[0].series[1] smooth line → use straight segments: smooth: false
44
+ warn DT005 charts[1].series[0] pie slices → use horizontal bars or combine small parts into Other
45
+ error DT010 charts[0].yAxis[0] bar baseline → set min: 0 and scale: false
46
+ 1 error, 2 warnings · score 84/100
47
+ ```
48
+
49
+ Exit codes: `0` passes, `1` means errors or warnings above the limit, and `2` means invalid input or unavailable extraction. Warnings have no limit by default; use `--max-warnings 0` to require none. Use `--mode dark` for dark pages. JSON output includes findings, paths, fixes, and scores. Every rule can be disabled with `--disable` and comma-separated IDs.
50
+
51
+ Scores start at 100, subtract 10 per error, 3 per warning, and 1 per informational finding, with a floor of zero. See [usage](docs/usage.md) for all flags and input shapes.
52
+
53
+ ## Packs
54
+
55
+ | Choice | global | cn |
56
+ | ------------- | -------------------------------- | --------------------------------------------------------- |
57
+ | Deltas | Green up, red down; arrows | Red up, green down; arrows |
58
+ | Units | K / M / B | wan / yi (10⁴ / 10⁸) |
59
+ | KPI layout | One strip with dividers | Flat cards; period-over-period and year-over-year rows |
60
+ | Legend | Omit for one series | Omit for one series; direct category labels work for pies |
61
+ | Pie | At most five slices; prefer bars | At most five slices; rose charts allow eight |
62
+ | Dark / screen | Paper and ink dark tokens | Navy panels; 1920×1080 screen composition |
63
+
64
+ Both packs specify spacing, typography, chart defaults, and number formatting. Layout still needs human review.
65
+
66
+ ## Rules
67
+
68
+ [Reasons, triggers, and fixes](docs/rules.md) for every rule:
69
+
70
+ | ID | Name |
71
+ | ----- | -------------------- |
72
+ | DT001 | default-palette |
73
+ | DT002 | rainbow-palette |
74
+ | DT003 | smooth-line |
75
+ | DT004 | symbols-everywhere |
76
+ | DT005 | pie-slices |
77
+ | DT006 | pie-overuse |
78
+ | DT007 | three-d |
79
+ | DT008 | dual-axis |
80
+ | DT009 | missing-unit |
81
+ | DT010 | bar-baseline |
82
+ | DT011 | raw-large-numbers |
83
+ | DT012 | unit-system |
84
+ | DT013 | delta-convention |
85
+ | DT014 | delta-colour-only |
86
+ | DT015 | low-contrast-text |
87
+ | DT016 | thin-line-contrast |
88
+ | DT017 | gradient-decoration |
89
+ | DT018 | glow |
90
+ | DT019 | kpi-overload |
91
+ | DT020 | kpi-rainbow |
92
+ | DT021 | emoji-icons |
93
+ | DT022 | cjk-min-size |
94
+ | DT023 | legend-single-series |
95
+
96
+ ## Gallery
97
+
98
+ All data is fictional and seeded in [gallery/data.mjs](gallery/data.mjs). Each pair shares the same data. Before pages use common unguided defaults; after pages apply their pack. These are style checks, not a usability benchmark.
99
+
100
+ | Dashboard | Pack, mode | Before | After |
101
+ | --------- | ------------------ | --------------------------------------------------- | ---------------------------------- |
102
+ | saas | global, light | [32 errors, 50 warnings](gallery/saas-before.png) | [clean](gallery/saas-after.png) |
103
+ | finance | global, dark | [7 errors, 40 warnings](gallery/finance-before.png) | [clean](gallery/finance-after.png) |
104
+ | store | cn, light | [41 errors, 61 warnings](gallery/store-before.png) | [clean](gallery/store-after.png) |
105
+ | live | cn, dark 1920×1080 | [32 errors, 76 warnings](gallery/live-before.png) | [clean](gallery/live-after.png) |
106
+
107
+ The live page renders at 1920×1080; the others at 1440×900.
108
+
109
+ ```sh
110
+ pnpm gallery
111
+ ```
112
+
113
+ This builds the CLI, copies local ECharts into the ignored `gallery/vendor/` folder, renders eight PNGs, and writes [results.json](gallery/results.json). No CDN or network fonts are used. Set `CHROME_PATH` to your local Chromium executable; the default is `/usr/bin/google-chrome`. The browser test extracts each live page again instead of trusting saved scores.
114
+
115
+ Comparison captions: “Default” / “Dashtaste” (global and cn; the cn caption for “Default” is written in Chinese). Wide images preserve full board height and use measured column boundaries.
116
+
117
+ - saas: [wide](gallery/compare/saas-wide.png) · [tall](gallery/compare/saas-tall.png)
118
+ - finance: [wide](gallery/compare/finance-wide.png) · [tall](gallery/compare/finance-tall.png)
119
+ - store: [wide](gallery/compare/store-wide.png) · [tall](gallery/compare/store-tall.png)
120
+ - live: [wide](gallery/compare/live-wide.png) · [tall](gallery/compare/live-tall.png)
121
+
122
+ ## Status
123
+
124
+ Early (0.x). Expect rule changes between minor versions.
125
+
126
+ ECharts only for now. Vega-Lite and Recharts are planned. HTML mode needs a local Chromium. Rules are opinions with reasons; every rule can be disabled.
127
+
128
+ ## License
129
+
130
+ [MIT](LICENSE).
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import { check, summarize, score } from './engine.js';
4
+ import { rules } from './rules/index.js';
5
+ import { getTokens } from './packs/index.js';
6
+ import { extractPage } from './extract.js';
7
+ const version = '0.0.1';
8
+ const help = {
9
+ main: 'Usage: dashtaste <check|rules|tokens> --help',
10
+ check: 'Usage: dashtaste check <file|url...> [--pack global|cn] [--mode light|dark] [--format text|json] [--max-warnings N] [--disable DT001,...] [--only DT001,...] [--background #hex]\nCheck options, option arrays, or page facts. Exit: 0 pass, 1 findings exceed limit, 2 invalid input. Set NO_COLOR to disable ANSI colour; CHROME_PATH selects the browser.',
11
+ rules: 'Usage: dashtaste rules [--pack global|cn] [--format text|json]\nList rule metadata in rule order.',
12
+ tokens: 'Usage: dashtaste tokens --pack global|cn [--mode light|dark] [--format json|css]\nPrint exact pack tokens.',
13
+ };
14
+ function parseArgs(command, args) {
15
+ const flags = {}, inputs = [];
16
+ const allowed = {
17
+ check: ['pack', 'mode', 'format', 'max-warnings', 'disable', 'only', 'background'],
18
+ rules: ['pack', 'format'],
19
+ tokens: ['pack', 'mode', 'format'],
20
+ };
21
+ if (!command || !allowed[command])
22
+ throw new Error('usage: dashtaste check <file|url...> | rules | tokens --pack global|cn');
23
+ for (let i = 0; i < args.length; i++) {
24
+ if (args[i].startsWith('--')) {
25
+ const key = args[i].slice(2);
26
+ if (!allowed[command].includes(key) || !args[i + 1] || args[i + 1].startsWith('--'))
27
+ throw new Error(`invalid option: ${args[i]}`);
28
+ flags[key] = args[++i];
29
+ }
30
+ else
31
+ inputs.push(args[i]);
32
+ }
33
+ return { flags, inputs };
34
+ }
35
+ async function main() {
36
+ const [command, ...args] = process.argv.slice(2);
37
+ if (command === '--help' || (command && args.includes('--help'))) {
38
+ console.log(help[command] ?? help.main);
39
+ return;
40
+ }
41
+ const { flags, inputs } = parseArgs(command, args);
42
+ const pack = (flags.pack ?? 'global'), mode = (flags.mode ?? 'light'), format = flags.format ?? (command === 'tokens' ? 'json' : 'text');
43
+ if (!['global', 'cn'].includes(pack))
44
+ throw new Error('pack must be global or cn');
45
+ if (!['light', 'dark'].includes(mode))
46
+ throw new Error('mode must be light or dark');
47
+ if (!(command === 'tokens' ? ['json', 'css'] : ['json', 'text']).includes(format))
48
+ throw new Error('invalid output format');
49
+ if (command !== 'check' && inputs.length)
50
+ throw new Error('unexpected input');
51
+ if (command === 'tokens') {
52
+ if (!flags.pack)
53
+ throw new Error('tokens requires --pack');
54
+ const t = getTokens(pack, mode);
55
+ console.log(format === 'json'
56
+ ? JSON.stringify(t, null, 2)
57
+ : `:root {\n${Object.entries(t)
58
+ .flatMap(([k, v]) => Array.isArray(v)
59
+ ? v.map((x, i) => ` --dt-${k}-${i + 1}: ${x};`)
60
+ : [` --dt-${k}: ${v};`])
61
+ .join('\n')}\n}`);
62
+ return;
63
+ }
64
+ if (command === 'rules') {
65
+ const selected = rules.filter((r) => !flags.pack || r.packs.includes(pack));
66
+ console.log(format === 'json'
67
+ ? JSON.stringify(selected.map(({ check, ...r }) => r), null, 2)
68
+ : selected.map((r) => `${r.id} ${r.severity} ${r.name} ${r.description}`).join('\n'));
69
+ return;
70
+ }
71
+ if (!inputs.length)
72
+ throw new Error('check requires at least one input');
73
+ const max = flags['max-warnings'] === undefined ? Infinity : Number(flags['max-warnings']);
74
+ if (max < 0 || (!Number.isInteger(max) && max !== Infinity) || flags['max-warnings'] === '')
75
+ throw new Error('max-warnings must be a non-negative integer');
76
+ const ids = (key) => {
77
+ if (!flags[key])
78
+ return undefined;
79
+ const values = flags[key].split(',');
80
+ if (values.some((id) => !rules.some((r) => r.id === id)))
81
+ throw new Error(`unknown rule in --${key}`);
82
+ return values;
83
+ };
84
+ const disable = ids('disable'), only = ids('only');
85
+ if (flags.background && !/^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(flags.background))
86
+ throw new Error('background must be a hex colour');
87
+ const results = [];
88
+ for (const input of inputs) {
89
+ let data;
90
+ if (/^https?:\/\//i.test(input) || /\.html?$/i.test(input))
91
+ data = await extractPage(input);
92
+ else {
93
+ try {
94
+ data = JSON.parse(await readFile(input, 'utf8'));
95
+ }
96
+ catch (error) {
97
+ throw new Error(`cannot read JSON input ${input}: ${error.message}`);
98
+ }
99
+ }
100
+ const findings = check(data, { pack, mode, disable, only, background: flags.background });
101
+ results.push({ input, findings, score: score(findings) });
102
+ }
103
+ const summary = summarize(results.flatMap((r) => r.findings));
104
+ if (format === 'json')
105
+ console.log(JSON.stringify({ version, pack, inputs: results, summary }, null, 2));
106
+ else {
107
+ const tty = process.stdout.isTTY && process.env.NO_COLOR === undefined;
108
+ for (const result of results) {
109
+ console.log(result.input);
110
+ for (const f of result.findings) {
111
+ const label = tty
112
+ ? `\x1b[${f.severity === 'error' ? 31 : f.severity === 'warn' ? 33 : 36}m${f.severity}\x1b[0m`
113
+ : f.severity;
114
+ console.log(` ${label} ${f.ruleId} ${f.path} ${f.message} → ${f.fix.replace('<pack>', pack)}`);
115
+ }
116
+ }
117
+ console.log(`${summary.errors} error${summary.errors === 1 ? '' : 's'}, ${summary.warnings} warning${summary.warnings === 1 ? '' : 's'} · score ${summary.score}/100`);
118
+ }
119
+ process.exitCode = summary.errors > 0 || summary.warnings > max ? 1 : 0;
120
+ }
121
+ main().catch((error) => {
122
+ console.error(`dashtaste: ${error.message}`);
123
+ process.exitCode = 2;
124
+ });
@@ -0,0 +1,4 @@
1
+ export declare function rgb(color: string): number[] | undefined;
2
+ export declare const linear: (v: number) => number;
3
+ export declare function contrast(a: string, b: string): number;
4
+ export declare function hueBucket(c: string): number | undefined;
package/dist/color.js ADDED
@@ -0,0 +1,43 @@
1
+ const named = {
2
+ black: '#000000',
3
+ white: '#FFFFFF',
4
+ red: '#FF0000',
5
+ green: '#008000',
6
+ blue: '#0000FF',
7
+ gray: '#808080',
8
+ grey: '#808080',
9
+ yellow: '#FFFF00',
10
+ orange: '#FFA500',
11
+ purple: '#800080',
12
+ cyan: '#00FFFF',
13
+ magenta: '#FF00FF',
14
+ };
15
+ export function rgb(color) {
16
+ const normalized = color.trim().toLowerCase();
17
+ const h = (named[normalized] ?? normalized).replace(/^#/, '');
18
+ if (/^[\da-f]{3}$/i.test(h))
19
+ return [...h].map((c) => parseInt(c + c, 16) / 255);
20
+ if (/^[\da-f]{6}$/i.test(h))
21
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
22
+ const m = normalized.match(/^rgba?\(\s*([\d.]+)[, ]+([\d.]+)[, ]+([\d.]+)/i);
23
+ return m ? m.slice(1, 4).map((v) => Number(v) / 255) : undefined;
24
+ }
25
+ export const linear = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
26
+ export function contrast(a, b) {
27
+ const lum = (c) => {
28
+ const r = rgb(c);
29
+ return r ? r.map(linear).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0) : NaN;
30
+ };
31
+ const x = lum(a), y = lum(b);
32
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
33
+ }
34
+ export function hueBucket(c) {
35
+ const r = rgb(c);
36
+ if (!r)
37
+ return;
38
+ const max = Math.max(...r), min = Math.min(...r), d = max - min;
39
+ if (max === 0 || d / max < 0.45)
40
+ return;
41
+ const h = max === r[0] ? (r[1] - r[2]) / d : max === r[1] ? 2 + (r[2] - r[0]) / d : 4 + (r[0] - r[1]) / d;
42
+ return Math.floor(((h * 60 + 360) % 360) / 60);
43
+ }
@@ -0,0 +1,11 @@
1
+ import type { CheckOptions, Finding, PageFacts } from './types.js';
2
+ export declare function normalizeInput(input: unknown): PageFacts;
3
+ export declare function check(input: unknown, options?: CheckOptions): Finding[];
4
+ /** Score = 100 - 10 * errors - 3 * warnings - info, floored at zero. */
5
+ export declare function score(findings: Finding[]): number;
6
+ export declare function summarize(findings: Finding[]): {
7
+ errors: number;
8
+ warnings: number;
9
+ info: number;
10
+ score: number;
11
+ };
package/dist/engine.js ADDED
@@ -0,0 +1,39 @@
1
+ import { rules } from './rules/index.js';
2
+ export function normalizeInput(input) {
3
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
4
+ if (Array.isArray(input)) {
5
+ if (!input.every(isObject))
6
+ throw new Error('expected option objects');
7
+ return { charts: input.map((option) => ({ option })) };
8
+ }
9
+ if (!isObject(input))
10
+ throw new Error('expected an option object, option array, or charts envelope');
11
+ if ('charts' in input) {
12
+ if (!Array.isArray(input.charts) ||
13
+ !input.charts.every((c) => isObject(c) && isObject(c.option)))
14
+ throw new Error('charts must contain option objects');
15
+ return input;
16
+ }
17
+ return { charts: [{ option: input }] };
18
+ }
19
+ export function check(input, options = {}) {
20
+ const facts = normalizeInput(input);
21
+ const settings = { ...options, pack: options.pack ?? 'global', mode: options.mode ?? 'light' };
22
+ return rules
23
+ .filter((r) => r.packs.includes(settings.pack) &&
24
+ !options.disable?.includes(r.id) &&
25
+ (!options.only || options.only.includes(r.id)))
26
+ .flatMap((r) => r.check(facts, settings));
27
+ }
28
+ /** Score = 100 - 10 * errors - 3 * warnings - info, floored at zero. */
29
+ export function score(findings) {
30
+ return Math.max(0, 100 - findings.reduce((sum, f) => sum + { error: 10, warn: 3, info: 1 }[f.severity], 0));
31
+ }
32
+ export function summarize(findings) {
33
+ return {
34
+ errors: findings.filter((f) => f.severity === 'error').length,
35
+ warnings: findings.filter((f) => f.severity === 'warn').length,
36
+ info: findings.filter((f) => f.severity === 'info').length,
37
+ score: score(findings),
38
+ };
39
+ }
@@ -0,0 +1,3 @@
1
+ import type { PageFacts } from './types.js';
2
+ /** Runs in the page without closures over the host process. */
3
+ export declare function extractInPage(): PageFacts;
@@ -0,0 +1,149 @@
1
+ /** Runs in the page without closures over the host process. */
2
+ export function extractInPage() {
3
+ const facts = {
4
+ charts: [],
5
+ texts: [],
6
+ gradients: [],
7
+ shadows: [],
8
+ kpis: [],
9
+ deltas: [],
10
+ emojis: [],
11
+ };
12
+ const selector = (el) => {
13
+ if (el.id)
14
+ return `#${CSS.escape(el.id)}`;
15
+ const parts = [];
16
+ let node = el;
17
+ while (node && node !== document.documentElement) {
18
+ const parent = node.parentElement;
19
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${parent ? [...parent.children].indexOf(node) + 1 : 1})`);
20
+ node = parent;
21
+ }
22
+ return parts.join(' > ');
23
+ };
24
+ // Composite transparent ancestor backgrounds over the browser canvas.
25
+ const compositeBackground = (el) => {
26
+ const layers = [];
27
+ let node = el;
28
+ while (node) {
29
+ const c = getComputedStyle(node)
30
+ .backgroundColor.match(/[\d.]+/g)
31
+ ?.map(Number);
32
+ if (c) {
33
+ layers.push([c[0], c[1], c[2], c[3] ?? 1]);
34
+ if ((c[3] ?? 1) >= 1)
35
+ break;
36
+ }
37
+ node = node.parentElement;
38
+ }
39
+ let out = [255, 255, 255];
40
+ for (const l of layers.reverse())
41
+ out = out.map((v, i) => l[i] * l[3] + v * (1 - l[3]));
42
+ return `rgb(${out.map(Math.round).join(', ')})`;
43
+ };
44
+ const ownText = (el) => [...el.childNodes]
45
+ .filter((n) => n.nodeType === Node.TEXT_NODE)
46
+ .map((n) => n.textContent ?? '')
47
+ .join(' ')
48
+ .trim();
49
+ for (const el of document.querySelectorAll('*')) {
50
+ const style = getComputedStyle(el);
51
+ if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].includes(el.tagName) ||
52
+ style.display === 'none' ||
53
+ style.visibility === 'hidden' ||
54
+ Number(style.opacity) === 0 ||
55
+ !el.getClientRects().length)
56
+ continue;
57
+ const path = selector(el), text = ownText(el), fontSize = parseFloat(style.fontSize);
58
+ // Text facts carry computed foreground, composited background, and size.
59
+ if (text)
60
+ facts.texts.push({
61
+ selector: path,
62
+ text,
63
+ color: style.color,
64
+ background: compositeBackground(el),
65
+ fontSize,
66
+ fontWeight: style.fontWeight === 'bold' ? 700 : parseInt(style.fontWeight) || 400,
67
+ fontVariantNumeric: style.fontVariantNumeric,
68
+ fontFeatureSettings: style.fontFeatureSettings,
69
+ });
70
+ // Decoration facts include CSS gradients and shadows.
71
+ if (/gradient\(/.test(style.backgroundImage))
72
+ facts.gradients.push({ selector: path, background: style.backgroundImage });
73
+ if (style.boxShadow !== 'none' || style.textShadow !== 'none')
74
+ facts.shadows.push({
75
+ selector: path,
76
+ boxShadow: style.boxShadow,
77
+ textShadow: style.textShadow,
78
+ });
79
+ // KPI and delta facts support explicit data attributes and visible-value heuristics.
80
+ if (el.hasAttribute('data-kpi') ||
81
+ (fontSize >= 22 &&
82
+ !el.closest('[data-ranking]') &&
83
+ /^[\s+−\-\d,.%KMB万亿¥$€]+$/.test(text) &&
84
+ /\d/.test(text)))
85
+ facts.kpis.push({
86
+ selector: path,
87
+ text: text || el.textContent?.trim() || '',
88
+ color: style.color,
89
+ background: compositeBackground(el),
90
+ fontSize,
91
+ fontVariantNumeric: style.fontVariantNumeric,
92
+ fontFeatureSettings: style.fontFeatureSettings,
93
+ });
94
+ if (el.hasAttribute('data-delta') || /[▲▼↑↓]|[+\-]\s*\d+(?:\.\d+)?%/.test(text)) {
95
+ const direction = el.getAttribute('data-direction');
96
+ const polarity = el.closest('[data-polarity]')?.getAttribute('data-polarity');
97
+ facts.deltas.push({
98
+ selector: path,
99
+ text: text || el.textContent?.trim() || '',
100
+ color: style.color,
101
+ ...(polarity === 'higher-is-better' || polarity === 'lower-is-better' ? { polarity } : {}),
102
+ ...(['up', 'down', 'flat'].includes(direction ?? '')
103
+ ? { direction: direction }
104
+ : {}),
105
+ });
106
+ }
107
+ // Emoji facts are limited to headings, labels, KPIs, and rankings.
108
+ if (/\p{Extended_Pictographic}/u.test(text) &&
109
+ el.closest('h1,h2,h3,h4,h5,h6,label,[data-kpi-label],[data-ranking],[data-kpi]'))
110
+ facts.emojis.push({ selector: path, text });
111
+ }
112
+ // Chart facts preserve the live ECharts option for each mounted chart.
113
+ const echarts = window.echarts;
114
+ if (echarts)
115
+ for (const el of document.querySelectorAll('[_echarts_instance_]')) {
116
+ const instance = echarts.getInstanceByDom(el);
117
+ if (instance) {
118
+ // Browser transports discard functions. Preserve formatter presence without executing
119
+ // user callbacks or sending executable code across the extraction boundary.
120
+ const option = JSON.parse(JSON.stringify(instance.getOption(), (_key, value) => typeof value === 'function' ? '[function]' : value));
121
+ for (const type of ['xAxis', 'yAxis']) {
122
+ const axes = option[type];
123
+ if (Array.isArray(axes))
124
+ axes.forEach((axis, index) => {
125
+ axis.renderedTickLabels =
126
+ instance
127
+ .getModel?.()
128
+ .getComponent(type, index)
129
+ ?.axis?.getViewLabels()
130
+ .map((label) => label.formattedLabel) ?? [];
131
+ });
132
+ }
133
+ // ECharts 6 returns singleton components as arrays, including tooltip.
134
+ if (Array.isArray(option.tooltip))
135
+ option.tooltip = option.tooltip[0];
136
+ facts.charts.push({ id: el.id || selector(el), option });
137
+ }
138
+ }
139
+ // Page facts provide the background and optional delta convention.
140
+ facts.page = {
141
+ background: compositeBackground(document.body),
142
+ ...(document.documentElement.dataset.deltaColors === 'red-up' ||
143
+ document.documentElement.dataset.deltaColors === 'green-up' ||
144
+ document.documentElement.dataset.deltaColors === 'by-polarity'
145
+ ? { deltaColors: document.documentElement.dataset.deltaColors }
146
+ : {}),
147
+ };
148
+ return facts;
149
+ }
@@ -0,0 +1,3 @@
1
+ import type { PageFacts } from './types.js';
2
+ export { extractInPage } from './extract-in-page.js';
3
+ export declare function extractPage(input: string): Promise<PageFacts>;
@@ -0,0 +1,39 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { extractInPage } from './extract-in-page.js';
5
+ export { extractInPage } from './extract-in-page.js';
6
+ export async function extractPage(input) {
7
+ let chromium;
8
+ try {
9
+ ({ chromium } = await import('playwright-core'));
10
+ }
11
+ catch {
12
+ throw new Error('HTML mode requires playwright-core and a browser; set CHROME_PATH to Chromium');
13
+ }
14
+ const executablePath = process.env.CHROME_PATH ||
15
+ (existsSync('/usr/bin/google-chrome') ? '/usr/bin/google-chrome' : undefined);
16
+ let browser;
17
+ try {
18
+ browser = await chromium.launch({
19
+ headless: true,
20
+ executablePath,
21
+ args: /^https?:\/\//i.test(input) ? [] : ['--allow-file-access-from-files'],
22
+ });
23
+ }
24
+ catch {
25
+ throw new Error('browser unavailable; set CHROME_PATH to a Chromium executable');
26
+ }
27
+ try {
28
+ const page = await browser.newPage();
29
+ await page.goto(/^https?:\/\//i.test(input) ? input : pathToFileURL(resolve(input)).href, {
30
+ waitUntil: 'networkidle',
31
+ timeout: 30000,
32
+ });
33
+ await page.waitForTimeout(500);
34
+ return await page.evaluate(extractInPage);
35
+ }
36
+ finally {
37
+ await browser.close();
38
+ }
39
+ }
@@ -0,0 +1,18 @@
1
+ import { type Polarity, type PackName, type DeltaColors, type Mode } from './packs/index.js';
2
+ export declare function formatCompact(value: number, pack: PackName, options?: {
3
+ minimumFractionDigits?: number;
4
+ compactFrom?: number;
5
+ }): string;
6
+ export declare function formatPercent(value: number, digits?: number): string;
7
+ export declare function formatDelta(current: number, previous: number, pack: PackName, options?: {
8
+ label?: string;
9
+ polarity?: Polarity;
10
+ deltaColors?: DeltaColors;
11
+ mode?: Mode;
12
+ }): {
13
+ readonly text: string;
14
+ readonly direction: "flat" | "up" | "down" | "invalid";
15
+ readonly color: string;
16
+ };
17
+ /** Uses local calendar fields; no locale-dependent date ordering. */
18
+ export declare function formatDate(value: Date | string | number, pack?: PackName): string;