feat: add Paper Routes and package five design systems

Add the paper direction with thin tracks, outlined hexagons and line arrows, without exterior color markers or piece numbers. Add a unified catalog for five designs and their 25 themes, with direct review links.

Organize each design into an authored design.md, generated tokens.json and preview. Add Agent routing, shared integration guidance, a machine-readable manifest and an executable rendering example. Preserve legacy preview and document links.

Validation: 16 focused design, theme and package tests passed; npm run design and static build passed. Confirmed all eight authored documents survive generation unchanged. Browser checks covered mobile, tablet and desktop layouts, theme links and migrated previews. Native engine integration and visual verification remain pending.
This commit is contained in:
cabinw
2026-09-13 13:24:34 +08:00
parent e32a1616be
commit 10cf93a324
52 changed files with 3365 additions and 149 deletions
+23 -4
View File
@@ -6,8 +6,26 @@ import { drawScene, drawTile, drawExit } from '../src/renderer.js';
import { createStudyLevel } from '../src/study-levels.js';
import { available, trace } from '../src/core.js';
import { layout } from '../src/geometry.js';
test('each of four art directions has five readable themes and independent token colors',()=>{
assert.equal(ART_DIRECTIONS.length,4);assert.equal(new Set(ART_DIRECTIONS.map(d=>d.id)).size,4);
test('paper study preserves the variants reference: pale thin rails and outlined hexagons with line arrows',()=>{
const theme=directionTheme(artDirection('paper'),'lagoon'),commands=[];
const p={polygon:(points,color)=>commands.push({kind:'polygon',points,color}),line:(points,color,width)=>commands.push({kind:'line',points,color,width}),circle:()=>{}};
assert.equal(theme.background,'#EAF0F3');assert.equal(theme.materials.board,'#FAFBF7');assert.equal(theme.materials.track,'#B2C4CE');
assert.deepEqual(theme.pigments.slice(0,2).map(p=>p.color),['#D66C4D','#477FC0']);
drawTile(p,50,50,40,appearanceTile({color:0,dir:0},theme));
assert.equal(commands[0].points.length,6);assert.equal(commands[0].color,'#D66C4D');
assert.ok(Math.abs(commands[0].points[0].x-50-40*.88)<1e-9);
assert.equal(commands[1].color,'#FFFFFFCC');assert.ok(Math.abs(commands[1].width-40*.035)<1e-9);
assert.deepEqual(commands.slice(2).map(c=>[c.kind,c.points.length,c.color]),[['line',2,'#FFFFFF'],['line',3,'#FFFFFF']]);
assert.ok(commands[2].points[1].y<commands[2].points[0].y,'direction zero still points up');
commands.length=0;
const level=createStudyLevel(3);drawScene(p,level,[],{size:40,ox:260,oy:260},{theme});
const rails=commands.filter(c=>c.kind==='line'&&c.color===theme.materials.track);
assert.equal(rails.length,level.cells.length*3);
assert.ok(rails.every(c=>Math.abs(c.width-40*.055)<1e-9));
assert.ok(!commands.some(c=>c.kind==='line'&&c.color===theme.materials.trackHalo),'no thick rail halo');
});
test('each of five art directions has five readable themes and independent token colors',()=>{
assert.equal(ART_DIRECTIONS.length,5);assert.equal(new Set(ART_DIRECTIONS.map(d=>d.id)).size,5);
for(const d of ART_DIRECTIONS){
assert.deepEqual(d.themes.map(t=>t.label),['轻松','进阶','烧脑','挑战','每周挑战']);
for(const t of d.themes){assert.ok(contrast(t.background,t.ink)>=4.5);assert.equal(directionTheme(d,t.id).artStyle,d.style);}
@@ -24,7 +42,7 @@ test('art styles change actual geometry without changing level state, including
drawTile(p,50,50,40,t);signatures.push(JSON.stringify(commands));
drawScene(p,level,level.tiles,view,{theme});drawExit(p,route,view,.4,t);drawExit(p,route,view,.99,t);
}
assert.equal(new Set(signatures).size,4);assert.equal(JSON.stringify(level),copy);
assert.equal(new Set(signatures).size,5);assert.equal(JSON.stringify(level),copy);
});
test('vivid worlds retain strong chroma and each new theme has its own seven-color mix',()=>{
const vivid=artDirection('vivid');
@@ -36,6 +54,7 @@ test('vivid worlds retain strong chroma and each new theme has its own seven-col
for(const d of ART_DIRECTIONS.slice(1)){
assert.equal(new Set(d.themes.map(t=>directionTheme(d,t.id).palette.slice().sort((a,b)=>a-b).join(','))).size,5);
assert.equal(new Set(d.themes.map(t=>t.materials.board)).size,5);
for(const t of d.themes)assert.ok(contrast(t.materials.track,t.materials.board)>=3);
// The paper study deliberately retains the reference's low-contrast rails.
if(d.style!=='paper')for(const t of d.themes)assert.ok(contrast(t.materials.track,t.materials.board)>=3);
}
});
+84
View File
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile, access } from 'node:fs/promises';
import { ART_DIRECTIONS, directionTheme } from '../src/art-directions.js';
import { THEME } from '../src/design-tokens.js';
import { recordDesign } from '../design/examples/render-design.mjs';
const root = new URL('../', import.meta.url);
const read = path => readFile(new URL(path, root), 'utf8');
const manifest = JSON.parse(await read('design/manifest.json'));
test('an agent can discover all authored specs, resolved tokens, previews and implementation sources', async () => {
assert.equal(manifest.schemaVersion, 1);
assert.equal(manifest.pathBase, 'repository-root');
assert.deepEqual(manifest.designs.map(d => d.id), ART_DIRECTIONS.map(d => d.id));
assert.equal(manifest.defaultDesignId, 'calm');
for (const path of [manifest.entry, manifest.agentGuide, manifest.sharedRules, manifest.catalog, manifest.example, ...Object.values(manifest.sources)]) {
await access(new URL(path, root));
}
for (const entry of manifest.designs) {
const spec = await read(entry.designDoc);
assert.match(spec, new RegExp('设计 ID:`' + entry.id + '`'));
assert.ok(spec.includes('../../shared.md'));
assert.ok(spec.includes('tokens.json'));
assert.ok(spec.includes('## 验收与接入'));
const html = await read(entry.preview);
assert.ok(html.includes(`data-design="${entry.id}"`));
assert.ok(html.includes('href="design.md"'));
assert.ok(html.includes('href="tokens.json"'));
assert.ok((await read(entry.legacyPreview)).includes(`url=${entry.id}/index.html`));
assert.ok((await read(`design/directions/${entry.id}.md`)).includes(`${entry.id}/design.md`));
}
});
test('generated design data stays aligned with runtime palettes at every supported color count', async () => {
for (const entry of manifest.designs) {
const direction = ART_DIRECTIONS.find(d => d.id === entry.id);
const data = JSON.parse(await read(entry.tokens));
assert.equal(data.designId, entry.id);
assert.equal(data.artStyle, direction.style);
assert.deepEqual(data.pigments.map(({ index, ...pigment }) => pigment), direction.pigments);
assert.deepEqual(data.themes.map(t => t.id), manifest.themeIds);
for (const theme of data.themes) {
const source = direction.themes.find(t => t.id === theme.id);
assert.equal(theme.background, source.background);
assert.equal(theme.ink, source.ink);
for (const [key, value] of Object.entries(theme.materials)) assert.equal(value, source.materials?.[key] ?? THEME[key]);
for (const count of manifest.paletteCounts) assert.deepEqual(theme.palettes[count], directionTheme(direction, theme.id, count).palette);
}
}
});
test('canonical design documents and previews have no broken local links after the directory move', async () => {
const paths = ['design/README.md', 'design/AGENTS.md', 'design/shared.md', 'design/index.html', ...manifest.designs.flatMap(d => [d.designDoc, d.preview, d.legacyPreview])];
for (const path of paths) {
const source = (await read(path)).replace(/```[\s\S]*?```/g, '');
const links = path.endsWith('.md') ? [...source.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)] : [...source.matchAll(/(?:href|src)="([^"]+)"/g)];
for (const [, href] of links) {
if (/^(?:https?:|data:|#)/.test(href)) continue;
const target = new URL(href, new URL(path, root));
target.search = ''; target.hash = '';
await assert.doesNotReject(access(target), `${path} → ${href}`);
}
}
});
test('the documented integration example renders all design and theme combinations and rejects typos', () => {
for (const direction of ART_DIRECTIONS) {
for (const theme of direction.themes) {
const result = recordDesign(direction.id, theme.id, 7);
assert.equal(result.designId, direction.id);
assert.equal(result.background, theme.background);
assert.ok(result.commands.some(command => command.type === 'line'));
assert.ok(result.commands.some(command => command.type === 'polygon'));
for (const command of result.commands) {
if (command.points) assert.ok(command.points.every(point => Number.isFinite(point.x) && Number.isFinite(point.y)));
if (command.width !== undefined) assert.ok(command.width > 0);
}
}
}
assert.throws(() => recordDesign('paepr'), /Unknown design/);
assert.throws(() => recordDesign('paper', 'night'), /Unknown theme/);
assert.throws(() => recordDesign('paper', 'lagoon', 5), /Unsupported color count/);
});