Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8 changes: 7 additions & 1 deletion .github/scripts/bump-versions.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Bump the version of every package under `packages/*` in lockstep.
* Bump every publishable package under `packages/*` in lockstep.
*
* Usage:
* node .github/scripts/bump-versions.mjs <patch|minor|major>
Expand Down Expand Up @@ -28,8 +28,14 @@ const packages = readdirSync(packagesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => resolve(packagesRoot, entry.name, "package.json"))
.filter(existsSync)
.filter((file) => JSON.parse(readFileSync(file, "utf8")).private !== true)
.sort();

if (packages.length === 0) {
console.error("could not find any publishable packages");
process.exit(1);
}

const bump = process.argv[2];
if (!["patch", "minor", "major"].includes(bump)) {
console.error(`usage: ${process.argv[1]} <patch|minor|major>`);
Expand Down
5 changes: 3 additions & 2 deletions .github/scripts/sync-package-readmes.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { copyFileSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
Expand All @@ -11,11 +11,12 @@ const targets = [
"packages/vue/README.md",
];
const packageSpecificTargets = [
"packages/domformat/README.md",
"packages/fonts/README.md",
"packages/morph/README.md",
];

const invokedFrom = relative(repoRoot, process.cwd());
const invokedFrom = relative(repoRoot, process.cwd()).split(sep).join("/");
const invokedFromPackageReadme = invokedFrom.startsWith("packages/")
? `${invokedFrom}/README.md`
: undefined;
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ jobs:
- name: Enforce Morph coverage
run: pnpm --filter @layoutit/polycss-morph test:coverage

- name: Enforce domformat coverage
run: pnpm --filter @layoutit/polycss-domformat test:coverage

- name: Certify domformat package
run: pnpm --filter @layoutit/polycss-domformat pack:check

- name: Build packages (DTS + JS)
run: pnpm build:packages

Expand All @@ -49,5 +55,72 @@ jobs:
- name: Certify Morph with registry dependencies
run: pnpm --filter @layoutit/polycss-morph test:package:registry

- name: Certify Gallery domformat corpus
run: pnpm --filter @layoutit/polycss-website test:domformat-tools && pnpm --filter @layoutit/polycss-website verify:domformat

- name: Build website
run: pnpm build:website

certify-domformat-browser:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5

- name: Set up pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda
with:
version: 10.32.1

- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install pinned Playwright Chromium
run: pnpm exec playwright install --with-deps chromium

- name: Expose pinned Playwright Chromium executable
run: >-
node --input-type=module --eval 'import { chromium } from "playwright"; process.stdout.write("DOMFORMAT_BROWSER=" + chromium.executablePath() + "\n");' >> "$GITHUB_ENV"

- name: Certify domformat in real Chromium
env:
DOMFORMAT_BROWSER_NO_SANDBOX: "1"
run: pnpm --filter @layoutit/polycss-domformat test:browser

certify-gallery-domformat-corpus:
# Gallery assets are byte-bound to this Chromium/sharp runner; changing it requires corpus regeneration.
runs-on: macos-15
timeout-minutes: 30
steps:
- name: Check out
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5

- name: Set up pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda
with:
version: 10.32.1

- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install pinned Playwright Chromium
run: pnpm exec playwright install chromium

- name: Build domformat
run: pnpm --filter @layoutit/polycss-domformat build

- name: Regenerate and compare the complete Gallery corpus
run: pnpm --filter @layoutit/polycss-website verify:domformat:fresh
10 changes: 6 additions & 4 deletions .github/workflows/publish-packages.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: Publish packages

# Manual-only. Bumps every package under `packages/*` in lockstep (using
# .github/scripts/bump-versions.mjs), builds them, publishes to npm, then commits
# the version change and pushes a `v<X.Y.Z>` tag back to main.
# Manual-only. Bumps every publishable package under `packages/*` in lockstep
# (using .github/scripts/bump-versions.mjs), builds the workspace, publishes the
# public packages to npm, then commits the version change and pushes a
# `v<X.Y.Z>` tag back to main. Packages marked `private` are never versioned or
# published by this workflow.
#
# Trigger from the Actions tab ("Run workflow") or:
# gh workflow run publish-packages.yml -f bump=patch
Expand Down Expand Up @@ -64,7 +66,7 @@ jobs:
run: pnpm build:packages

- name: Publish to npm
run: pnpm --filter "./packages/*" -r publish --access public --no-git-checks
run: pnpm --filter "./packages/*" --filter "!@layoutit/polycss-domformat" -r publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Expand Down
59 changes: 55 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Monorepo layout (pnpm workspaces):
| `packages/vue` | `@layoutit/polycss-vue` | Vue 3 mirror of the React package. Owns its own copy of atlas rasterisation. Depends on `core` only. |
| `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. |
| `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 @@ -93,7 +94,9 @@ All solid and atlas-backed tags work in both modes. Direct image `<s>` leaves ar

This is the load-bearing constraint behind the whole engine. **JavaScript should not run per-frame to paint polygons when the motion can be expressed as a scene, mesh, camera, or light update.** Once the scene is built and the atlas is rasterised, the browser drives most rendering through CSS — `matrix3d` transforms, `calc()`-driven custom properties, `background-blend-mode`, `border-shape`, etc.

The current exception is imported skeletal animation. glTF/GLB skinning changes each polygon independently, so the vanilla stable-DOM animation path samples the active clip in JS, keeps the leaf set mounted, caches baked stable-triangle transform frames, and pins each mounted triangle's baked color while transforms animate. Recomputing Lambert from every deformed low-poly face normal creates visible color pumping, so color refresh is internal opt-in rather than the default animation behavior. On WebKit/Safari, where stable CSS triangles fall through to solid atlas `<s>` leaves, same-topology animation updates keep the existing atlas elements and bitmap URLs mounted, cache transform frames once warmed, and hide briefly degenerate atlas triangles only until the next valid frame. That optimized path is the default; do not add a user-facing "baseline vs optimized" toggle or maintain a legacy slow path in product UI.
The renderer exception is imported skeletal animation. glTF/GLB skinning changes each polygon independently, so the vanilla stable-DOM animation path samples the active clip in JS, keeps the leaf set mounted, caches baked stable-triangle transform frames, and pins each mounted triangle's baked color while transforms animate. Recomputing Lambert from every deformed low-poly face normal creates visible color pumping, so color refresh is internal opt-in rather than the default animation behavior. On WebKit/Safari, where stable CSS triangles fall through to solid atlas `<s>` leaves, same-topology animation updates keep the existing atlas elements and bitmap URLs mounted, cache transform frames once warmed, and hide briefly degenerate atlas triangles only until the next valid frame. That optimized path is the default; do not add a user-facing "baseline vs optimized" toggle or maintain a legacy slow path in product UI.

The domformat reference mount has one separate, closed exception: it may schedule its validated fixed-rate prepared playback and interaction tables. That scheduler may write only declared sinks on the retained targets, never reconstruct topology or evaluate producer code, expressions, renderer internals, or network resources, and is disabled by `animate: false`. Normal catch-up is bounded to eight due ticks: every due logical animation tick and distinct prepared-effect transition in that window is evaluated in order, but one browser callback may publish only their final retained-DOM state; interaction publishes each such tick separately because input, cursor, grab, and spring state are observable. A larger gap is treated as suspension, discards the stale backlog, advances one tick, and resets the deadline. The one-tick path and public operations remain synchronous. This is a reference implementation of an already-lowered wire profile, not a PolyCSS renderer loop.

| Where JS runs | Where JS does NOT run |
|---|---|
Expand All @@ -102,9 +105,9 @@ The current exception is imported skeletal animation. glTF/GLB skinning changes
| Atlas planning + rasterisation (one-shot to `<canvas>`, then `toBlob`) | Per-frame atlas redraw (only on baked-mode light changes) |
| Control input handling (`PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`) | Per-frame transform recomputation of every polygon for camera/mesh motion — only the scene-root or mesh-root transform changes |
| Camera math (matrix4 product → scene-root `transform` CSS var) | Per-polygon JS in any hot path |
| Hover/selection raycasting (only on pointer events, not per frame) | Continuous re-rendering "ticks" |
| Hover/selection raycasting (only on pointer events, not per frame) | Continuous renderer re-rendering "ticks" |

If you find yourself wanting a `requestAnimationFrame` loop to update many DOM nodes outside skeletal animation, stop. Find the CSS variable that should be carrying the change, and update that single variable on a single ancestor. Cascading + `@property`-registered custom properties do the rest.
If you find yourself wanting a `requestAnimationFrame` loop to update many renderer DOM nodes outside skeletal animation or the closed domformat prepared-runtime exception, stop. Find the CSS variable that should be carrying the change, and update that single variable on a single ancestor. Cascading + `@property`-registered custom properties do the rest.

### PolyCSS Morph boundary

Expand Down Expand Up @@ -156,10 +159,58 @@ React or Vue wrappers.
- Product-specific source cadence, schemas, preparation provenance, mounting
paths, product behavior, and oracle evidence stay in the consuming product.

### domformat boundary

`@layoutit/polycss-domformat` is the private, producer-neutral reference package
for the experimental `domformat@0` wire contract. It is not a serialization
alias for Morph packages and does not depend on Morph or renderer internals.

- Node exposes only `buildDom`, `readDom`, `readDomFile`, `validateDocument`,
and `DomFormatError`; the CLI exposes only `encode`, `decode`, `inspect`, and
`validate`; the browser subpath exposes only `readDomBrowser`,
`readDomBrowserUrl`, and `mountDom`.
- Producers emit the closed writer manifest natively. Source parsing,
preparation, lowering, and product adapters remain in producer packages.
- The only physical form is canonical `.json` plus digest-bound external
sibling resource files. There is no `.dom` packet, gzip transport, embedded
payload, archive, or alternate packaging mode.
- Mounting follows `validate → construct → bind → initialize → publish →
destroy`, with rollback on partial failure and idempotent teardown.
- The package is authored in strict TypeScript, built as unbundled ESM plus
declarations with tsup, `private`, and MIT-licensed. Workspace test/build
commands include it; public version-bump and npm-publish automation must not.
Public Node and browser signatures describe the closed document, resource,
options, lifecycle, and controller contracts.
- Domformat's repository-side tests intentionally remain one certification
suite under `test/` using `node:test`. In-process contract tests share the
same corpora and helpers as the raw-ESM, CLI-subprocess, independent Python,
and real-browser harnesses, with one package-level coverage gate. Tests
execute the authored TypeScript through `tsx`; the release gate separately
exercises the clean-installed compiled package. This is the package's
explicit exception to sibling Vitest/co-location conventions, not an
exception to strict typing, declarations, coverage, or the mandatory build
gate.
- Install tarballs contain only package metadata, README, CLI, compiled runtime,
and declarations. Specifications, independent readers/producers, fixtures, the
alternate mount shell, scripts, and tests remain repository-side
certification material. The alternate shell shares the single reference
lifecycle, input adapter, and profile interpreters rather than copying them.
- The first concrete producer is website-owned:
`website/scripts/generate-gallery-domformat.mjs` lowers all Gallery presets
through shared Gallery preset/loader/presentation/animation behavior into
canonical documents under
`website/gallery-domformat-corpus/`. The generated 304-model corpus and its
digest-bound CSS/image siblings are website assets, never package payload or
runtime code. Its catalog pins the exact Chromium strategy environment and
per-model strategy counts; the corpus does not claim browser-neutral leaf
topology. Static documents are presentation-only; animated documents add
one Gallery-selected preferred clip at 30 Hz. Adding this producer does not
move source parsing or renderer internals into domformat.

## Naming (three.js parity)

- Brand text is **PolyCSS**. Keep lowercase `polycss` only for literal package names, import paths, CSS classes, domains, and other code identifiers.
- Every public export gets a `Poly` prefix. Exceptions are generic math types (`Vec2`, `Vec3`, `Polygon`, `PolyMaterial`) and the explicit `*/three` compatibility subpaths, where Three-compatible names are the point of the API. React/Vue components in those subpaths still use the `PolyThree` prefix.
- Every public export gets a `Poly` prefix. Exceptions are generic math types (`Vec2`, `Vec3`, `Polygon`, `PolyMaterial`), the closed versioned domformat API listed above, and the explicit `*/three` compatibility subpaths, where Three-compatible names are the point of the API. React/Vue components in those subpaths still use the `PolyThree` prefix.
- **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`.
- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyIframe`, `PolyThreePerspectiveCamera`, `PolyThreeOrthographicCamera`, `PolyThreeMesh`.
- **Types:** `PolyDirectionalLight`, `PolyPointLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, `PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`, `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`, `PolyMeshTransformInput`, `PolySceneTransformInput`, `PolyAnimationMixer`, `PolyRenderStats`.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,24 @@ 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-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. |

The website-owned producer also carries a deterministic canonical JSON snapshot
of every Gallery model at `website/gallery-domformat-corpus/`, with digest-bound
CSS and image siblings. Its catalog pins the 640×640 Playwright Chromium
strategy environment, including engine version, device scale, media queries,
CSS feature branches, and per-model leaf-strategy counts; it does not claim
cross-engine strategy topology. Static models are presentation-only. Animated
models add the Gallery-selected preferred clip sampled at a fixed 30 Hz. The
corpus is a website asset, not package payload.
Regenerate it with `pnpm gallery:domformat`, verify exact Gallery inventory and
sibling-resource closure with `pnpm gallery:domformat:verify`, and run an
independent byte-for-byte regeneration check with
`pnpm gallery:domformat:verify:fresh`. Produce an
external direct-versus-canonical animated proof with
`pnpm gallery:domformat:prove --output /absolute/path`; it requires exact
retained DOM and computed paint semantics and reports bounded subpixel
Chromium compositor differences.

## Made with PolyCSS

Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@
"test": "pnpm --filter './packages/*' -r --if-present test",
"test:coverage": "pnpm --filter './packages/*' -r --if-present test:coverage",
"sync:readmes": "node .github/scripts/sync-package-readmes.mjs",
"publish:all": "pnpm sync:readmes && pnpm --filter './packages/*' -r publish --access public",
"publish:all": "pnpm sync:readmes && pnpm --filter './packages/*' --filter '!@layoutit/polycss-domformat' -r publish --access public",
"dev:website": "pnpm --filter @layoutit/polycss-website dev",
"gallery:domformat": "pnpm --filter @layoutit/polycss-website generate:domformat",
"gallery:domformat:prove": "pnpm --filter @layoutit/polycss-website prove:domformat",
"gallery:domformat:verify": "pnpm --filter @layoutit/polycss-website verify:domformat",
"gallery:domformat:verify:fresh": "pnpm --filter @layoutit/polycss-website verify:domformat:fresh",
"build:website": "pnpm --filter @layoutit/polycss-website build",
"bench:build": "node bench/build.mjs",
"bench:serve": "node bench/perf-serve.mjs --port 4400",
Expand Down Expand Up @@ -54,7 +58,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"esbuild": "^0.28.0",
"playwright": "^1.58.2",
"playwright": "1.58.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"three": "^0.185.1",
Expand Down
Loading
Loading