Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3743177
feat(skills): add @layoutit/polycss-skills npx installer for the Poly…
apresmoi Aug 12, 2026
66ec720
test(skills): add browser-graded skill evaluation harness with agent …
apresmoi Aug 12, 2026
c6e1819
test(skills): grade framing, surface brightness and per-face shading …
apresmoi Aug 13, 2026
1397bd1
fix(shadow): apply the documented 0.05 lift default to receiver shadows
apresmoi Aug 13, 2026
c098587
test(skills): require cast shadows to visibly darken the receiver
apresmoi Aug 13, 2026
1d32041
test(skills): add a no-skill Three.js control track to the eval
apresmoi Aug 13, 2026
3290f80
test(skills): measure framing on the subject hue so an overscaled sha…
apresmoi Aug 13, 2026
50af324
test(skills): treat the shared white page as background on both tracks
apresmoi Aug 13, 2026
1330103
test(skills): isolate workspaces outside the repo and calibrate thres…
apresmoi Aug 13, 2026
86388dd
test(skills): state the shadow-visibility and framing requirements, c…
apresmoi Aug 13, 2026
19ca965
test(skills): erode before counting regions, sample denser, scope wor…
apresmoi Aug 13, 2026
32322c3
docs(agents): document the skill evaluation harness
apresmoi Aug 14, 2026
3f4b961
fix(skills): contain installer paths and stop symlinks escaping the s…
apresmoi Aug 14, 2026
6fa0b86
refactor(shadow): share one DEFAULT_SHADOW_LIFT across the three rend…
apresmoi Aug 14, 2026
b8cfc6f
docs(skills): correct colour defaults, readiness claims, import rule …
apresmoi Aug 14, 2026
c41a1ac
docs(eval): state what the workspace isolation does and does not guar…
apresmoi Aug 14, 2026
91ba2dc
docs(skills): note spherePolygons and ringQuadPolygons are core-only …
apresmoi Aug 14, 2026
24e4f49
fix(skills): refuse symlinked directories inside the install destination
apresmoi Aug 14, 2026
56ae84d
docs(skills): scope renderer-only exports and cross-renderer control …
apresmoi Aug 14, 2026
12ff93a
fix(shadow): reattach the projector JSDoc and mirror DEFAULT_SHADOW_L…
apresmoi Aug 14, 2026
0878f35
fix(eval): make --reuse never invoke an agent and namespace runs by id
apresmoi Aug 14, 2026
9123e21
feat(eval): add a polycss-noskill control track and demote three to a…
apresmoi Aug 14, 2026
9b1e3d2
docs(skills): correct ambient freeze, colour defaults, PolyPointLight…
apresmoi Aug 14, 2026
9d245b4
fix(skills): resolve containment through symlinked ancestors and rand…
apresmoi Aug 14, 2026
7067903
fix(eval): validate run ids and fail a run that grades nothing
apresmoi Aug 14, 2026
502915f
docs(skills): use the exported axesHelperPolygons name and guard the …
apresmoi Aug 14, 2026
73a87e6
fix(skills): confine auto-detected installs to the project root
apresmoi Aug 14, 2026
f188f00
fix(eval): keep reused workspaces, isolate track roots, unique run id…
apresmoi Aug 14, 2026
c04ff65
refactor(shadow): rename the shared lift constant to POLY_DEFAULT_SHA…
apresmoi Aug 14, 2026
aa5992f
docs(skills): scope gizmo behaviour by renderer and state both ambien…
apresmoi Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/scripts/sync-package-readmes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const targets = [
"packages/domformat/README.md",
"packages/fonts/README.md",
"packages/morph/README.md",
"packages/skills/README.md",
];

const checkOnly = process.argv.includes("--check");
Expand Down
140 changes: 140 additions & 0 deletions .github/scripts/sync-skill-docs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Publishes the skill tree owned by `packages/skills` to the website.
*
* `packages/skills/skill/` is the single source of truth — it is what
* `npx @layoutit/polycss-skills` installs. The website serves the same bytes so
* an agent that cannot run npx can fetch them:
*
* website/public/skill/** — the tree verbatim, relative links intact
* website/public/skill.md — the long-standing entry-point URL, with
* `docs/x.md` links rewritten to `/skill/docs/x.md`
* because it is served one level up from the tree
*
* Run with `--check` in CI to fail on drift instead of writing.
*/
import {
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, posix, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const sourceDir = resolve(repoRoot, "packages/skills/skill");
const treeDir = resolve(repoRoot, "website/public/skill");
const entryFile = resolve(repoRoot, "website/public/skill.md");

const checkOnly = process.argv.includes("--check");

const fail = (message) => {
console.error(`[sync-skill-docs] ${message}`);
process.exit(1);
};

export function listFiles(dir, prefix = "") {
const out = [];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
const rel = prefix ? posix.join(prefix, entry.name) : entry.name;
if (entry.isDirectory()) out.push(...listFiles(join(dir, entry.name), rel));
else if (entry.isFile()) out.push(rel);
}
return out;
}

/**
* `website/public/skill.md` sits one directory above `website/public/skill/`,
* so its relative doc links would 404. Rewrite them to site-absolute paths.
*/
export function rewriteEntryLinks(text) {
return text.replace(/\]\(docs\//g, "](/skill/docs/");
}

const toNative = (rel) => rel.split("/").join(sep);
const readOrNull = (path) => {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
};

function main() {
const files = listFiles(sourceDir);
if (!files.includes("SKILL.md")) {
fail(`${relative(repoRoot, sourceDir)} has no SKILL.md — refusing to run`);
}

const drifted = [];
let written = 0;

const write = (path, next) => {
if (readOrNull(path) === next) return;
if (checkOnly) {
drifted.push(relative(repoRoot, path));
return;
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, next);
written += 1;
};

for (const rel of files) {
write(join(treeDir, toNative(rel)), readFileSync(join(sourceDir, toNative(rel)), "utf8"));
}

write(entryFile, rewriteEntryLinks(readFileSync(join(sourceDir, "SKILL.md"), "utf8")));

// Files the skill no longer ships must not linger on the website, or an agent
// fetching by URL keeps reading a doc the package dropped.
const shipped = new Set(files);
for (const rel of listFiles(treeDir)) {
if (shipped.has(rel)) continue;
const path = join(treeDir, toNative(rel));
if (checkOnly) {
drifted.push(`${relative(repoRoot, path)} (stale)`);
continue;
}
rmSync(path, { force: true });
written += 1;
}

if (checkOnly) {
if (drifted.length > 0) {
console.error(
`[sync-skill-docs] website skill copy is stale:\n ${drifted.join("\n ")}\n` +
"Edit packages/skills/skill/, then run `pnpm sync:skill`.",
);
process.exit(1);
}
console.log("[sync-skill-docs] website skill copy is up to date");
return;
}

console.log(
`[sync-skill-docs] ${written} file${written === 1 ? "" : "s"} updated from ${files.length} skill file${files.length === 1 ? "" : "s"}`,
);
}

// `import.meta.url` is realpathed by Node but `argv[1]` is not, so a symlink
// anywhere in the invocation path would make this compare unequal — main()
// would silently never run and `--check` would exit 0 on stale content.
const invokedDirectly = () => {
if (!process.argv[1]) return false;
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
};

if (invokedDirectly()) main();
83 changes: 83 additions & 0 deletions .github/scripts/sync-skill-docs.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { after, test } from "node:test";
import { fileURLToPath } from "node:url";

import { listFiles, rewriteEntryLinks } from "./sync-skill-docs.mjs";

const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..");
const script = join(here, "sync-skill-docs.mjs");

const temps = [];
after(() => {
for (const dir of temps) rmSync(dir, { recursive: true, force: true });
});
const tmp = () => {
const dir = mkdtempSync(join(tmpdir(), "skill-docs-"));
temps.push(dir);
return dir;
};

test("listFiles returns sorted POSIX paths and skips directories", () => {
const dir = tmp();
mkdirSync(join(dir, "docs"));
writeFileSync(join(dir, "SKILL.md"), "a");
writeFileSync(join(dir, "docs", "b.md"), "b");
writeFileSync(join(dir, "docs", "a.md"), "a");

assert.deepEqual(listFiles(dir), ["SKILL.md", "docs/a.md", "docs/b.md"]);
});

test("listFiles tolerates a missing directory", () => {
assert.deepEqual(listFiles(join(tmp(), "nope")), []);
});

test("rewriteEntryLinks makes doc links site-absolute", () => {
assert.equal(
rewriteEntryLinks("see [x](docs/lighting.md) and [y](docs/shadows.md)"),
"see [x](/skill/docs/lighting.md) and [y](/skill/docs/shadows.md)",
);
});

test("rewriteEntryLinks leaves other links alone", () => {
const input = "[a](https://polycss.com) [b](/skill/docs/x.md) [c](./docs/x.md) `docs/x.md`";
assert.equal(rewriteEntryLinks(input), input);
});

test("--check exits 0 on a synced tree", () => {
// The committed website copy must already match the package source.
execFileSync(process.execPath, [script, "--check"], { cwd: repoRoot, stdio: "pipe" });
});

test("--check fails and writes nothing when the website copy drifts", () => {
const target = join(repoRoot, "website/public/skill/SKILL.md");
const original = readFileSync(target);
writeFileSync(target, `${original}\ndrift\n`);
try {
assert.throws(
() => execFileSync(process.execPath, [script, "--check"], { cwd: repoRoot, stdio: "pipe" }),
(error) => error.status === 1,
);
// --check must never repair what it reports.
assert.equal(readFileSync(target).toString(), `${original}\ndrift\n`);
} finally {
writeFileSync(target, original);
}
});

test("--check reports a file the skill no longer ships", () => {
const stale = join(repoRoot, "website/public/skill/docs/__stale__.md");
writeFileSync(stale, "gone");
try {
assert.throws(
() => execFileSync(process.execPath, [script, "--check"], { cwd: repoRoot, stdio: "pipe" }),
(error) => error.status === 1 && /stale/.test(String(error.stderr)),
);
} finally {
rmSync(stale, { force: true });
}
});
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Check README shared blocks
run: pnpm check:readmes

- name: Check website skill copy
run: pnpm check:skill

- name: Run tests
run: pnpm test

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ website/.baseline-shots/
# chrome-trace skill raw output
chrome-trace.json
/tmp/*.trace.json

# skill evaluation scratch
eval/skill/.work/
eval/skill/.generated/
58 changes: 58 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Monorepo layout (pnpm workspaces):
| `packages/fonts` | `@layoutit/polycss-fonts` | Fonts + text → extruded 3D `Polygon[]`. Hand-written TrueType (`glyf`) reader + extruder (flat/round/bevel profiles) + Google Fonts loader. Framework-agnostic (returns `Polygon[]`, no React/Vue mirror needed). Depends on `core` + `earcut`. |
| `packages/morph` | `@layoutit/polycss-morph` | Framework-agnostic prepared-model contracts, deterministic Node preparation, browser loading, retained DOM mounting, sparse deformation, controls, springs, animation, joint skinning, and prepared playback. The browser entry uses public `@layoutit/polycss` APIs; Node-only preparation lives at `@layoutit/polycss-morph/prepare`. No React/Vue mirrors. |
| `packages/domformat` | `@layoutit/polycss-domformat` | Private strict-TypeScript `domformat@0` writer, reader, validator, CLI, and browser mount with repository-side conformance. Owns the producer-neutral wire contract; producer lowering stays in producer packages. Runtime installs contain unbundled ESM and declarations but exclude certification material. Not published. |
| `packages/skills` | `@layoutit/polycss-skills` | Zero-dependency `npx` installer for the PolyCSS agent skill. Owns `skill/SKILL.md` + `skill/docs/*.md` — the source of truth for what agents are told about PolyCSS. No renderer code, no runtime dependency on any other package. |
| `website` | `@layoutit/polycss-website` | Astro + Starlight docs site. Not published. |
| `examples/{html,vanilla,react,vue,fontcss}` | private | Per-framework Vite apps demonstrating the minimal usage for each renderer (`fontcss` demos `@layoutit/polycss-fonts`). Workspace members so they resolve to local `workspace:^` packages. Not published. |

Expand Down Expand Up @@ -240,6 +241,7 @@ Before opening a PR:
- [ ] If I touched the canvas atlas pipeline (`rasterise.ts` / `buildAtlasPages.ts`), browser-feature detection, or direct voxel renderer in ONE renderer, the same fix lands in the other two renderers (`polycss` + react + vue) in this PR.
- [ ] If I touched any of the three `styles.ts` (`packages/polycss/src/styles/styles.ts`, `packages/react/src/styles/styles.ts`, `packages/vue/src/styles/styles.ts`), the other two are consistent — CSS rules cover every emitted tag for both lighting modes, and shared properties like `will-change: transform` on `.polycss-scene` exist in all three.
- [ ] Website docs (`website/src/content/docs/**`) and READMEs reflect any user-visible change.
- [ ] If a user-visible change contradicts the agent skill, `packages/skills/skill/**` is updated and `pnpm sync:skill` has been run (see "The agent skill" below).
- [ ] If I edited a `<!-- polycss:shared:* -->` block, I edited it in the ROOT `README.md` and ran `pnpm sync:readmes` (see "Package READMEs" below).
- [ ] If I changed a render strategy, lighting mode, naming convention, or the JS-in-render-loop rules, `AGENTS.md` reflects the new state in this same PR.

Expand Down Expand Up @@ -275,6 +277,62 @@ matching markers. Current blocks are `links`, `packages`, `showcase`, and
- A package opts in per block simply by containing the markers. `fonts` and
`morph` carry none today and are left entirely alone.

## The agent skill

`packages/skills/skill/` is the **single source of truth** for what coding
agents are told about PolyCSS: `SKILL.md` is the entry point (conventions, the
silent-failure invariants, minimal scenes, and the docs index) and
`skill/docs/*.md` holds the per-topic reference. `@layoutit/polycss-skills`
publishes that tree with a zero-dependency `npx` installer.

- **Edit `packages/skills/skill/`, never the website copy.** Then run
`pnpm sync:skill`. CI runs `pnpm check:skill`, which fails on drift.
- `.github/scripts/sync-skill-docs.mjs` mirrors the tree to
`website/public/skill/` verbatim and to `website/public/skill.md` with doc
links rewritten site-absolute (that file is served one level above the tree).
It also deletes website copies of docs the skill has dropped.
- Adding or removing a doc means updating the index table in `SKILL.md`; the
package's tests fail on an unindexed doc, a link to a file that does not
ship, and a broken cross-doc relative link.
- The skill documents the *current* behaviour, including the renderer
divergences recorded in this file (vanilla's missing ground-shadow fallback,
the baked-light rebake asymmetry, `seamBleed`). When one of those is
reconciled, the skill changes in the same PR.
- The package is plain ESM with no build step. Its `bin` runs under `npx` in
someone else's project, so it must stay dependency-free.

### Measuring the skill

`eval/skill/` answers whether an agent holding only the skill actually writes
correct PolyCSS. It hands a real coding-agent CLI a throwaway workspace
containing the installed skill and one task, then grades **what the scene
paints in Chromium** — never the agent's prose.

- `pnpm eval:skill --agent oracle --track all` — reference solutions, must be
100%. Anything less is a harness bug, not an agent result.
- `pnpm eval:selftest` — mutates each reference solution with one mistake the
skill warns about and asserts the matching check catches it. A grader that
cannot fail measures nothing.
- `pnpm eval:skill --agent claude,codex,grok --track all` — the real matrix.
Costs real agent invocations; `--reuse` re-grades existing workspaces free.

Two design rules that are load-bearing:

- **Workspaces live outside the repository** (`$TMPDIR`). Inside it, an agent
walks up out of its workspace and reads this monorepo — measured, on the
no-skill control track.
- **The `three` track is a control, never a reference.** Same model, same task,
no skill, separate workspace. The two tracks are graded independently against
the same visual criteria; PolyCSS output is never diffed against a Three.js
render. It exists to separate "the skill is inadequate" from "this task is
hard for this model", and it has repeatedly proved the grader wrong rather
than the agent.

Grade on painted pixels, not DOM boxes: every leaf except `<b>` is
`backface-visibility: hidden`, so a reversed face keeps its bounding rect while
painting nothing. Prefer tolerance bands over exact values — models frame
scenes differently and that is not a defect.

## Backward compatibility

- **No BC shims.** Clean breaks only. No re-export aliases for renamed symbols. No `@deprecated` wrappers. If the API changes, callers update.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le
| `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. |
| `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. |
| `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. |
| `@layoutit/polycss-skills` | `npx @layoutit/polycss-skills` — installs the PolyCSS agent skill into `.claude/skills` or `.agents/skills`. |
| `@layoutit/polycss-domformat` | Private MIT-licensed producer-neutral `domformat@0` runtime for canonical JSON plus digest-bound sibling resources; conformance and specifications stay repository-side. Not published. |
<!-- polycss:shared:packages:end -->

Expand All @@ -265,6 +266,24 @@ external direct-versus-canonical animated proof with
retained DOM and computed paint semantics and reports bounded subpixel
Chromium compositor differences.

## Coding Agents

PolyCSS ships an agent skill: an entry-point `SKILL.md` plus a folder of
reference docs covering authoring invariants, scene setup, lighting, shadows,
textures, animation, performance, Three.js parity, and troubleshooting.

```bash
npx @layoutit/polycss-skills
```

It installs into `.claude/skills/polycss/` and `.agents/skills/polycss/`,
detecting whichever your project already uses. Upgrades are content-hashed, so
re-running it refreshes the shipped docs and leaves your edits alone.

The same content is served at [polycss.com/skill.md](https://polycss.com/skill.md),
with the reference docs under `polycss.com/skill/docs/`, for agents that can
fetch a URL but not run a command.

<!-- polycss:shared:showcase:start -->
## Made with PolyCSS

Expand Down
Loading
Loading