mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(core): add dataviz bundled skill (#6198)
* feat(core): add dataviz bundled skill * fix(core): harden dataviz palette validator * fix(core): harden dataviz validator review gaps * test(core): declare node global in dataviz test * fix(core): harden dataviz validator cli * fix(core): tighten dataviz validator packaging * fix(core): tighten dataviz skill packaging
This commit is contained in:
parent
b16baf1ffc
commit
62e11a5732
9 changed files with 832 additions and 5 deletions
|
|
@ -45,4 +45,22 @@ describe('bundled SKILL.md files', () => {
|
|||
expect(Array.isArray(cfg.allowedTools)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('ships dataviz validator and references with the bundled skill', () => {
|
||||
const datavizDir = path.join(bundledDir, 'dataviz');
|
||||
|
||||
expect(fs.existsSync(path.join(datavizDir, 'SKILL.md'))).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(datavizDir, 'scripts', 'validate_palette.js')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(datavizDir, 'references', 'palette.md')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(datavizDir, 'references', 'choosing-a-form.md')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(datavizDir, 'references', 'anti-patterns.md')),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
50
packages/core/src/skills/bundled/dataviz/SKILL.md
Normal file
50
packages/core/src/skills/bundled/dataviz/SKILL.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
name: dataviz
|
||||
description: Design guidance for charts, graphs, dashboards, maps, and data visualizations, including a local palette validator.
|
||||
when_to_use: When creating or revising charts, graphs, dashboards, maps, plots, inline SVG, D3, Plotly, Recharts, matplotlib, or any Artifact page that visualizes data.
|
||||
allowedTools:
|
||||
- read_file
|
||||
---
|
||||
|
||||
# Dataviz
|
||||
|
||||
Use this skill before producing a chart, dashboard, map, or data visualization.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the analytic task: comparison, trend, distribution, relationship,
|
||||
ranking, part-to-whole, geography, or status monitoring.
|
||||
2. Choose the simplest chart form that answers that task. Read
|
||||
`references/choosing-a-form.md` when the form is not obvious.
|
||||
3. Write the finding into the title, subtitle, axis label, or direct annotation.
|
||||
A viewer should know what changed, what is high or low, or what decision the
|
||||
chart supports.
|
||||
4. Pick colors from `references/palette.md`, or validate any custom palette with
|
||||
the script below.
|
||||
5. Check `references/anti-patterns.md` before finalizing the design.
|
||||
|
||||
## Palette Validation
|
||||
|
||||
Resolve paths relative to the skill base directory shown above this skill body.
|
||||
Do not assume `$QWEN_SKILL_ROOT` is set for normal shell commands.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node <skill-base-directory>/scripts/validate_palette.js '#1d4ed8,#b45309,#166534' --mode light
|
||||
```
|
||||
|
||||
Treat `FAIL` as a required palette change. Treat `WARN` as acceptable only when
|
||||
the chart also uses labels, shape, texture, ordering, or another secondary
|
||||
encoding.
|
||||
|
||||
## Mark Rules
|
||||
|
||||
- Use categorical palettes for unordered groups; use sequential or diverging
|
||||
ramps only for ordered values.
|
||||
- Prefer direct labels over legends when the chart has a small number of series.
|
||||
- Keep gridlines subtle and fewer than the data marks.
|
||||
- Avoid dual axes unless both series share a clearly explained transformation.
|
||||
- Do not rely on color alone for critical distinctions.
|
||||
- Keep dashboards scan-friendly: align cards, use consistent number formats,
|
||||
and reserve saturated color for important state changes.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Dataviz Anti-Patterns
|
||||
|
||||
- Do not use rainbow palettes for ordered data.
|
||||
- Do not use sequential ramps for unrelated categories.
|
||||
- Do not encode critical differences with color only.
|
||||
- Do not start a bar chart above zero unless the truncation is explicit and
|
||||
justified.
|
||||
- Do not use dual axes when the visual comparison implies a relationship that
|
||||
is not in the data.
|
||||
- Do not hide units, denominators, sample sizes, or time windows.
|
||||
- Do not let chart decoration compete with marks, labels, and annotations.
|
||||
- Do not use a dashboard card if a compact table is clearer.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Choosing A Form
|
||||
|
||||
| Task | Prefer | Avoid |
|
||||
| --------------------- | --------------------------------- | --------------------------------------- |
|
||||
| Compare categories | sorted bar chart | pie with many slices |
|
||||
| Show change over time | line chart | disconnected bars for dense time series |
|
||||
| Show ranking | horizontal bar chart | unsorted columns |
|
||||
| Show distribution | histogram, box plot, violin plot | averages without spread |
|
||||
| Show relationship | scatter plot | dual-axis line chart |
|
||||
| Show part-to-whole | stacked bar for few parts | nested donuts |
|
||||
| Show geography | choropleth or symbol map | map when location is irrelevant |
|
||||
| Monitor status | compact dashboard with thresholds | decorative KPI cards |
|
||||
|
||||
Prefer the chart that makes the comparison visible without interaction. Add
|
||||
interaction only when it reveals detail, filters clutter, or supports a real
|
||||
workflow.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Palette Reference
|
||||
|
||||
## Categorical
|
||||
|
||||
Use these first for independent categories:
|
||||
|
||||
```text
|
||||
#1d4ed8 blue
|
||||
#b45309 orange
|
||||
#166534 green
|
||||
#7c3aed purple
|
||||
#0891b2 cyan
|
||||
#be123c rose
|
||||
```
|
||||
|
||||
Start with three to five colors. If the chart needs more categories than that,
|
||||
prefer direct labels, grouping, faceting, or filtering over adding more hues.
|
||||
|
||||
## Sequential
|
||||
|
||||
Use one hue ramp for ordered magnitude:
|
||||
|
||||
```text
|
||||
#eff6ff #bfdbfe #60a5fa #2563eb #1e3a8a
|
||||
```
|
||||
|
||||
The lightest and darkest ramp endpoints are for backgrounds, area fills, and
|
||||
labels. When choosing exact line, point, or bar mark colors, validate the marks
|
||||
you actually use rather than the full ramp.
|
||||
|
||||
## Diverging
|
||||
|
||||
Use a diverging ramp only when there is a meaningful center such as zero,
|
||||
target, or baseline:
|
||||
|
||||
```text
|
||||
#b91c1c #fca5a5 #f8fafc #93c5fd #1d4ed8
|
||||
```
|
||||
|
||||
The center and endpoint colors provide range context. For exact chart marks,
|
||||
validate the selected mark colors and add direct labels or another encoding when
|
||||
the validator reports `WARN`.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the validator whenever exact mark colors are chosen:
|
||||
|
||||
```bash
|
||||
node <skill-base-directory>/scripts/validate_palette.js '#1d4ed8,#b45309,#166534' --mode light
|
||||
```
|
||||
|
||||
The validator's 2.5:1 contrast floor is a practical chart-mark heuristic, not a
|
||||
WCAG AA guarantee. Use 3:1 or higher when chart marks must satisfy WCAG 2.1
|
||||
non-text contrast without relying on labels or secondary encodings.
|
||||
|
||||
The validator also enforces OKLCH lightness bands so marks are neither too pale
|
||||
nor too dark for the selected surface, even when contrast alone looks adequate.
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MODE_CONFIG = {
|
||||
light: {
|
||||
surface: [255, 255, 255],
|
||||
minLightness: 0.25,
|
||||
maxLightness: 0.88,
|
||||
minContrast: 2.5,
|
||||
},
|
||||
dark: {
|
||||
surface: [17, 24, 39],
|
||||
minLightness: 0.45,
|
||||
maxLightness: 0.92,
|
||||
minContrast: 2.5,
|
||||
},
|
||||
};
|
||||
|
||||
const CVD_MATRICES = {
|
||||
protanopia: [
|
||||
[0.56667, 0.43333, 0],
|
||||
[0.55833, 0.44167, 0],
|
||||
[0, 0, 1],
|
||||
],
|
||||
deuteranopia: [
|
||||
[0.625, 0.375, 0],
|
||||
[0.7, 0.3, 0],
|
||||
[0, 0.3, 0.7],
|
||||
],
|
||||
tritanopia: [
|
||||
[0.95, 0.05, 0],
|
||||
[0, 0.43333, 0.56667],
|
||||
[0, 0.475, 0.525],
|
||||
],
|
||||
};
|
||||
|
||||
const MAX_PALETTE_COLORS = 50;
|
||||
|
||||
export function validatePalette(input, options = {}) {
|
||||
const mode = options.mode ?? 'light';
|
||||
const config = modeConfig(mode);
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
status: 'FAIL',
|
||||
failures: [`Unsupported mode "${mode}". Use "light" or "dark".`],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.length === 0) {
|
||||
return { status: 'FAIL', failures: ['No colors provided.'], warnings };
|
||||
}
|
||||
|
||||
if (input.length > MAX_PALETTE_COLORS) {
|
||||
return {
|
||||
status: 'FAIL',
|
||||
failures: [
|
||||
`Palette has ${input.length} colors; maximum supported is ${MAX_PALETTE_COLORS}.`,
|
||||
],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const colors = input.map((value, index) => {
|
||||
const rgb = parseHexColor(value);
|
||||
if (!rgb) {
|
||||
failures.push(
|
||||
`Color ${index + 1} has invalid hex color syntax: ${value}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const oklch = rgbToOklch(rgb);
|
||||
return { value: normalizeHex(rgb), rgb, oklch };
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
return { status: 'FAIL', failures, warnings };
|
||||
}
|
||||
|
||||
for (const color of colors) {
|
||||
if (
|
||||
color.oklch.l < config.minLightness ||
|
||||
color.oklch.l > config.maxLightness
|
||||
) {
|
||||
failures.push(
|
||||
`${color.value} has OKLCH lightness ${format(color.oklch.l)}, outside the ${mode} mark band ${config.minLightness}-${config.maxLightness}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (color.oklch.c < 0.04) {
|
||||
warnings.push(
|
||||
`${color.value} has low OKLCH chroma ${format(color.oklch.c)} and may read as gray.`,
|
||||
);
|
||||
}
|
||||
|
||||
const contrast = contrastRatio(color.rgb, config.surface);
|
||||
if (contrast < config.minContrast) {
|
||||
failures.push(
|
||||
`${color.value} contrast ${format(contrast)} is below ${config.minContrast}:1 against the ${mode} chart surface.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [kind, matrix] of Object.entries(CVD_MATRICES)) {
|
||||
const simulated = colors.map((color) => {
|
||||
const rgb = applyMatrix(color.rgb, matrix);
|
||||
return { value: color.value, rgb, lab: rgbToLab(rgb) };
|
||||
});
|
||||
|
||||
for (const color of simulated) {
|
||||
const contrast = contrastRatio(color.rgb, config.surface);
|
||||
if (contrast < config.minContrast) {
|
||||
warnings.push(
|
||||
`${color.value} contrast ${format(contrast)} is below ${config.minContrast}:1 against the ${mode} chart surface under colorblind ${kind} simulation.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < simulated.length; i++) {
|
||||
for (let j = i + 1; j < simulated.length; j++) {
|
||||
const distance = labDistance(simulated[i].lab, simulated[j].lab);
|
||||
if (distance < 40) {
|
||||
warnings.push(
|
||||
`${simulated[i].value} and ${simulated[j].value} are close under colorblind ${kind} simulation (DeltaE ${format(distance)}); add secondary encoding such as labels, shape, or texture.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueFailures = [...new Set(failures)];
|
||||
const uniqueWarnings = [...new Set(warnings)];
|
||||
return {
|
||||
status:
|
||||
uniqueFailures.length > 0
|
||||
? 'FAIL'
|
||||
: uniqueWarnings.length > 0
|
||||
? 'WARN'
|
||||
: 'PASS',
|
||||
failures: uniqueFailures,
|
||||
warnings: uniqueWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHexColor(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const hex = value.trim();
|
||||
const short = /^#([0-9a-f]{3})$/i.exec(hex);
|
||||
if (short) {
|
||||
return short[1].split('').map((part) => parseInt(part + part, 16));
|
||||
}
|
||||
const long = /^#([0-9a-f]{6})$/i.exec(hex);
|
||||
if (!long) return null;
|
||||
return [0, 2, 4].map((offset) =>
|
||||
parseInt(long[1].slice(offset, offset + 2), 16),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeHex(rgb) {
|
||||
return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function applyMatrix(rgb, matrix) {
|
||||
const linear = rgb.map(srgbChannelToLinear);
|
||||
return matrix
|
||||
.map((row) => row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2])
|
||||
.map(linearChannelToSrgb);
|
||||
}
|
||||
|
||||
function contrastRatio(left, right) {
|
||||
const l1 = relativeLuminance(left);
|
||||
const l2 = relativeLuminance(right);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function relativeLuminance(rgb) {
|
||||
const [r, g, b] = rgb.map(srgbChannelToLinear);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function rgbToLab(rgb) {
|
||||
const [x, y, z] = rgbToXyz(rgb);
|
||||
const xn = 0.95047;
|
||||
const yn = 1;
|
||||
const zn = 1.08883;
|
||||
const fx = labPivot(x / xn);
|
||||
const fy = labPivot(y / yn);
|
||||
const fz = labPivot(z / zn);
|
||||
return {
|
||||
l: 116 * fy - 16,
|
||||
a: 500 * (fx - fy),
|
||||
b: 200 * (fy - fz),
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToXyz(rgb) {
|
||||
const [r, g, b] = rgb.map(srgbChannelToLinear);
|
||||
return [
|
||||
r * 0.4124564 + g * 0.3575761 + b * 0.1804375,
|
||||
r * 0.2126729 + g * 0.7151522 + b * 0.072175,
|
||||
r * 0.0193339 + g * 0.119192 + b * 0.9503041,
|
||||
];
|
||||
}
|
||||
|
||||
function labPivot(value) {
|
||||
return value > 0.008856 ? Math.cbrt(value) : 7.787 * value + 16 / 116;
|
||||
}
|
||||
|
||||
function rgbToOklch(rgb) {
|
||||
const [r, g, b] = rgb.map(srgbChannelToLinear);
|
||||
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
|
||||
const okL = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
||||
const okA = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
||||
const okB = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
||||
|
||||
return {
|
||||
l: okL,
|
||||
c: Math.sqrt(okA * okA + okB * okB),
|
||||
};
|
||||
}
|
||||
|
||||
function srgbChannelToLinear(channel) {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function linearChannelToSrgb(channel) {
|
||||
const value = Math.max(0, Math.min(1, channel));
|
||||
const encoded =
|
||||
value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
|
||||
return Math.round(encoded * 255);
|
||||
}
|
||||
|
||||
function labDistance(left, right) {
|
||||
return Math.sqrt(
|
||||
(left.l - right.l) ** 2 + (left.a - right.a) ** 2 + (left.b - right.b) ** 2,
|
||||
);
|
||||
}
|
||||
|
||||
function format(value) {
|
||||
return Number(value.toFixed(3)).toString();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let mode = 'light';
|
||||
let palette;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--mode') {
|
||||
if (!argv[i + 1] || argv[i + 1].startsWith('--')) {
|
||||
process.stderr.write(
|
||||
'Error: --mode requires a value (light or dark)\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
mode = argv[++i];
|
||||
} else if (argv[i].startsWith('--mode=')) {
|
||||
mode = argv[i].slice('--mode='.length);
|
||||
if (!mode) {
|
||||
process.stderr.write(
|
||||
'Error: --mode requires a value (light or dark)\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
} else if (!palette) {
|
||||
palette = argv[i];
|
||||
}
|
||||
}
|
||||
return { palette, mode };
|
||||
}
|
||||
|
||||
function printResult(result) {
|
||||
process.stdout.write(`${result.status}\n`);
|
||||
for (const failure of result.failures) {
|
||||
process.stderr.write(`FAIL: ${failure}\n`);
|
||||
}
|
||||
for (const warning of result.warnings) {
|
||||
process.stdout.write(`WARN: ${warning}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function modeConfig(mode) {
|
||||
return Object.hasOwn(MODE_CONFIG, mode) ? MODE_CONFIG[mode] : undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
||||
) {
|
||||
const { palette, mode } = parseArgs(process.argv.slice(2));
|
||||
if (!palette) {
|
||||
process.stderr.write(
|
||||
"Usage: node validate_palette.js '#1d4ed8,#b45309,#166534' --mode light\n",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (!modeConfig(mode)) {
|
||||
process.stderr.write(
|
||||
`Error: Unsupported mode "${mode}". Use "light" or "dark".\n`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const result = validatePalette(
|
||||
palette
|
||||
.split(',')
|
||||
.map((color) => color.trim())
|
||||
.filter(Boolean),
|
||||
{ mode },
|
||||
);
|
||||
printResult(result);
|
||||
process.exit(result.status === 'FAIL' ? 1 : 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
/* global process */
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validatePalette } from './validate_palette.js';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url).replace(/\.test\.js$/, '.js');
|
||||
|
||||
describe('validate_palette', () => {
|
||||
it('passes a varied categorical palette on a light chart surface', () => {
|
||||
const result = validatePalette(['#1d4ed8', '#b45309', '#166534'], {
|
||||
mode: 'light',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('PASS');
|
||||
expect(result.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes a varied categorical palette on a dark chart surface', () => {
|
||||
const result = validatePalette(['#60a5fa', '#fbbf24'], {
|
||||
mode: 'dark',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('PASS');
|
||||
expect(result.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails invalid hex colors', () => {
|
||||
const result = validatePalette(['#2563eb', 'blue'], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('FAIL');
|
||||
expect(result.failures.join('\n')).toMatch(/invalid hex/i);
|
||||
});
|
||||
|
||||
it('warns when colors are too gray to read as categorical marks', () => {
|
||||
const result = validatePalette(['#777777', '#999999'], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('WARN');
|
||||
expect(result.warnings.join('\n')).toMatch(/chroma/i);
|
||||
});
|
||||
|
||||
it('fails low-contrast light marks on a light chart surface', () => {
|
||||
const result = validatePalette(['#eeeeee', '#f7f7f7'], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('FAIL');
|
||||
expect(result.failures.join('\n')).toMatch(/contrast/i);
|
||||
});
|
||||
|
||||
it('fails low-contrast dark marks on a dark chart surface', () => {
|
||||
const result = validatePalette(['#111827', '#1f2937'], { mode: 'dark' });
|
||||
|
||||
expect(result.status).toBe('FAIL');
|
||||
expect(result.failures.join('\n')).toMatch(/contrast/i);
|
||||
});
|
||||
|
||||
it('fails marks outside the OKLCH lightness band', () => {
|
||||
const result = validatePalette(['#1a1a2e'], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('FAIL');
|
||||
expect(result.failures.join('\n')).toMatch(/lightness/i);
|
||||
});
|
||||
|
||||
it('fails when no colors are provided', () => {
|
||||
const result = validatePalette([], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('FAIL');
|
||||
expect(result.failures).toEqual(['No colors provided.']);
|
||||
});
|
||||
|
||||
it('fails unsupported modes without reading object prototypes', () => {
|
||||
const result = validatePalette(['#2563eb'], { mode: '__proto__' });
|
||||
|
||||
expect(result.status).toBe('FAIL');
|
||||
expect(result.failures.join('\n')).toMatch(/unsupported mode/i);
|
||||
});
|
||||
|
||||
it('normalizes 3-digit hex shorthand in messages', () => {
|
||||
const result = validatePalette(['#777', '#999'], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('WARN');
|
||||
expect(result.warnings.join('\n')).toContain('#777777');
|
||||
expect(result.warnings.join('\n')).toContain('#999999');
|
||||
});
|
||||
|
||||
it('deduplicates repeated validation messages', () => {
|
||||
const result = validatePalette(['#777777', '#777777'], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('WARN');
|
||||
expect(
|
||||
result.warnings.filter((warning) =>
|
||||
warning.includes('#777777 has low OKLCH chroma'),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects palettes that are too large for pairwise checks', () => {
|
||||
const result = validatePalette(Array(51).fill('#2563eb'), {
|
||||
mode: 'light',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('FAIL');
|
||||
expect(result.failures).toEqual([
|
||||
'Palette has 51 colors; maximum supported is 50.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns when colorblind simulation makes colors too close', () => {
|
||||
const result = validatePalette(['#2563eb', '#7c3aed'], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('WARN');
|
||||
expect(result.warnings.join('\n')).toMatch(/colorblind/i);
|
||||
});
|
||||
|
||||
it('warns when simulated colors lose chart-surface contrast', () => {
|
||||
const result = validatePalette(['#d97706'], { mode: 'light' });
|
||||
|
||||
expect(result.status).toBe('WARN');
|
||||
expect(result.warnings.join('\n')).toMatch(/contrast/i);
|
||||
});
|
||||
|
||||
it('accepts --mode before the palette in CLI usage', () => {
|
||||
const output = execFileSync(process.execPath, [
|
||||
scriptPath,
|
||||
'--mode',
|
||||
'dark',
|
||||
'#60a5fa,#fbbf24',
|
||||
]).toString();
|
||||
|
||||
expect(output).toMatch(/^PASS$/m);
|
||||
});
|
||||
|
||||
it('accepts --mode=value in CLI usage', () => {
|
||||
const output = execFileSync(process.execPath, [
|
||||
scriptPath,
|
||||
'--mode=dark',
|
||||
'#60a5fa,#fbbf24',
|
||||
]).toString();
|
||||
|
||||
expect(output).toMatch(/^PASS$/m);
|
||||
});
|
||||
|
||||
it('rejects --mode without a value in CLI usage', () => {
|
||||
expect(() =>
|
||||
execFileSync(process.execPath, [scriptPath, '#2563eb', '--mode'], {
|
||||
stdio: 'pipe',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('treats invalid CLI modes as usage errors', () => {
|
||||
try {
|
||||
execFileSync(process.execPath, [
|
||||
scriptPath,
|
||||
'--mode',
|
||||
'midnight',
|
||||
'#2563eb',
|
||||
]);
|
||||
throw new Error('expected command to fail');
|
||||
} catch (err) {
|
||||
expect(err.status).toBe(2);
|
||||
expect(err.stderr.toString()).toMatch(/unsupported mode/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('prints validation failure details to stderr', () => {
|
||||
try {
|
||||
execFileSync(process.execPath, [scriptPath, '#eeeeee'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
throw new Error('expected command to fail');
|
||||
} catch (err) {
|
||||
expect(err.status).toBe(1);
|
||||
expect(err.stdout.toString()).toMatch(/^FAIL$/m);
|
||||
expect(err.stderr.toString()).toMatch(/FAIL:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -25,6 +25,8 @@ import fs from 'node:fs';
|
|||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const defaultRoot = join(__dirname, '..');
|
||||
const BUNDLED_SKILL_TEST_FILE_RE =
|
||||
/\.(?:test|spec)\.(?:d\.)?[cm]?[jt]sx?(?:\.map)?$/;
|
||||
|
||||
export function copyBundleAssets({ root = defaultRoot } = {}) {
|
||||
const distDir = join(root, 'dist');
|
||||
|
|
@ -66,7 +68,10 @@ export function copyBundleAssets({ root = defaultRoot } = {}) {
|
|||
);
|
||||
if (existsSync(bundledSkillsDir)) {
|
||||
const destBundledDir = join(distDir, 'bundled');
|
||||
copyRecursiveSync(bundledSkillsDir, destBundledDir);
|
||||
fs.rmSync(destBundledDir, { recursive: true, force: true });
|
||||
copyRecursiveSync(bundledSkillsDir, destBundledDir, {
|
||||
skipEntry: isBundledSkillTestFile,
|
||||
});
|
||||
console.log('Copied bundled skills to dist/bundled/');
|
||||
} else {
|
||||
console.warn(
|
||||
|
|
@ -159,7 +164,7 @@ function isDirectRun() {
|
|||
/**
|
||||
* Recursively copy directory
|
||||
*/
|
||||
function copyRecursiveSync(src, dest) {
|
||||
function copyRecursiveSync(src, dest, options = {}) {
|
||||
if (!existsSync(src)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -173,14 +178,13 @@ function copyRecursiveSync(src, dest) {
|
|||
|
||||
const entries = fs.readdirSync(src);
|
||||
for (const entry of entries) {
|
||||
// Skip .DS_Store files
|
||||
if (entry === '.DS_Store') {
|
||||
if (entry === '.DS_Store' || options.skipEntry?.(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const srcPath = join(src, entry);
|
||||
const destPath = join(dest, entry);
|
||||
copyRecursiveSync(srcPath, destPath);
|
||||
copyRecursiveSync(srcPath, destPath, options);
|
||||
}
|
||||
} else {
|
||||
copyFileSync(src, dest);
|
||||
|
|
@ -191,3 +195,7 @@ function copyRecursiveSync(src, dest) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBundledSkillTestFile(fileName) {
|
||||
return BUNDLED_SKILL_TEST_FILE_RE.test(fileName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ describe('package asset scripts', () => {
|
|||
|
||||
it('copies extension examples into the bundled runtime dist', () => {
|
||||
const rootDir = createFixtureRoot();
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/cli/src/commands/extensions/examples/mcp-server/keep.test.js',
|
||||
'console.log("example test fixture");\n',
|
||||
);
|
||||
stubConsole();
|
||||
|
||||
copyBundleAssets({ root: rootDir });
|
||||
|
|
@ -47,6 +52,156 @@ describe('package asset scripts', () => {
|
|||
path.join(rootDir, 'dist', 'examples', 'mcp-server', 'package.json'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(rootDir, 'dist', 'examples', 'mcp-server', 'keep.test.js'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('copies bundled skill scripts and references into the runtime dist', () => {
|
||||
const rootDir = createFixtureRoot();
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/core/src/skills/bundled/dataviz/SKILL.md',
|
||||
'---\nname: dataviz\ndescription: Chart guidance\n---\nBody\n',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/core/src/skills/bundled/dataviz/scripts/validate_palette.js',
|
||||
'console.log("ok");\n',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/core/src/skills/bundled/dataviz/scripts/validate_palette.test.js',
|
||||
'import { it } from "vitest";\n',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/core/src/skills/bundled/dataviz/scripts/validate_palette.test.d.ts',
|
||||
'export {};\n',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/core/src/skills/bundled/dataviz/scripts/validate_palette.test.js.map',
|
||||
'{}\n',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/core/src/skills/bundled/dataviz/scripts/chart.spec.tsx',
|
||||
'export {};\n',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/core/src/skills/bundled/dataviz/scripts/font-test-regular.woff2',
|
||||
'font\n',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/core/src/skills/bundled/dataviz/references/palette.md',
|
||||
'# Palette\n',
|
||||
);
|
||||
writeFile(rootDir, 'dist/bundled/dataviz/scripts/stale.test.js', 'stale\n');
|
||||
stubConsole();
|
||||
|
||||
copyBundleAssets({ root: rootDir });
|
||||
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(
|
||||
rootDir,
|
||||
'dist',
|
||||
'bundled',
|
||||
'dataviz',
|
||||
'scripts',
|
||||
'validate_palette.js',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(
|
||||
rootDir,
|
||||
'dist',
|
||||
'bundled',
|
||||
'dataviz',
|
||||
'scripts',
|
||||
'validate_palette.test.js',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(
|
||||
rootDir,
|
||||
'dist',
|
||||
'bundled',
|
||||
'dataviz',
|
||||
'scripts',
|
||||
'validate_palette.test.d.ts',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(
|
||||
rootDir,
|
||||
'dist',
|
||||
'bundled',
|
||||
'dataviz',
|
||||
'scripts',
|
||||
'validate_palette.test.js.map',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(
|
||||
rootDir,
|
||||
'dist',
|
||||
'bundled',
|
||||
'dataviz',
|
||||
'scripts',
|
||||
'chart.spec.tsx',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(
|
||||
rootDir,
|
||||
'dist',
|
||||
'bundled',
|
||||
'dataviz',
|
||||
'scripts',
|
||||
'stale.test.js',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(
|
||||
rootDir,
|
||||
'dist',
|
||||
'bundled',
|
||||
'dataviz',
|
||||
'scripts',
|
||||
'font-test-regular.woff2',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(
|
||||
rootDir,
|
||||
'dist',
|
||||
'bundled',
|
||||
'dataviz',
|
||||
'references',
|
||||
'palette.md',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('includes extension examples in the prepared dist package', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue