diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dc207f0..44f6835 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,7 +54,35 @@ jobs:
cache: true
- name: Run tests
- run: vp run --filter @camp.dev/bones test -- --run
+ run: vp run --filter @camp.dev/bones test -- --run --project unit
+
+ bones-visual:
+ name: "Bones: Visual Regression"
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Vite+
+ uses: voidzero-dev/setup-vp@v1
+ with:
+ cache: true
+
+ - name: Install Playwright Chromium
+ working-directory: packages/bones
+ run: vp exec playwright install --with-deps chromium
+
+ - name: Run browser tests
+ run: vp run --filter @camp.dev/bones test -- --run --project browser
+
+ - name: Upload screenshots
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: bones-visual-output
+ path: |
+ packages/bones/tests/browser/__screenshots__/
+ packages/bones/.vitest-attachments/
# ── Demo app ───────────────────────────────────────────────
demo-check:
diff --git a/.gitignore b/.gitignore
index 05346ea..02d12ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,5 @@ test-results
.fallow
docs/superpowers/
.superpowers
-.worktrees
\ No newline at end of file
+.worktrees
+packages/bones/tests/browser/__screenshots__/**/*-darwin.png
\ No newline at end of file
diff --git a/packages/bones/package.json b/packages/bones/package.json
index cd8485d..b787eec 100644
--- a/packages/bones/package.json
+++ b/packages/bones/package.json
@@ -43,7 +43,7 @@
"dev": "vp pack --watch",
"test": "vp test",
"check": "vp check",
- "health": "vp test --coverage && fallow health --root . --coverage coverage/coverage-final.json --format json --quiet",
+ "health": "vp test --project unit --coverage && fallow health --root . --coverage coverage/coverage-final.json --format json --quiet",
"prepublishOnly": "vp run build"
},
"devDependencies": {
@@ -55,6 +55,7 @@
"@typescript/native-preview": "7.0.0-dev.20260328.1",
"@vitest/coverage-v8": "^4.1.5",
"jsdom": "^29.0.2",
+ "playwright": "^1.62.1",
"react": "^19.2.5",
"typescript": "^6.0.2",
"vite-plus": "^0.1.14"
diff --git a/packages/bones/tests/browser/__screenshots__/visual.test.tsx/bare-busy-chromium-linux.png b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/bare-busy-chromium-linux.png
new file mode 100644
index 0000000..cec0144
Binary files /dev/null and b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/bare-busy-chromium-linux.png differ
diff --git a/packages/bones/tests/browser/__screenshots__/visual.test.tsx/element-force-chromium-linux.png b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/element-force-chromium-linux.png
new file mode 100644
index 0000000..cec0144
Binary files /dev/null and b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/element-force-chromium-linux.png differ
diff --git a/packages/bones/tests/browser/__screenshots__/visual.test.tsx/react-force-chromium-linux.png b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/react-force-chromium-linux.png
new file mode 100644
index 0000000..f7cb7bd
Binary files /dev/null and b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/react-force-chromium-linux.png differ
diff --git a/packages/bones/tests/browser/smoke.test.ts b/packages/bones/tests/browser/smoke.test.ts
new file mode 100644
index 0000000..445bd6e
--- /dev/null
+++ b/packages/bones/tests/browser/smoke.test.ts
@@ -0,0 +1,14 @@
+import { expect, test } from "vite-plus/test";
+
+// Proves the browser project runs in a real layout engine: jsdom returns
+// all-zero rects, Chromium cannot.
+test("layout engine is real", () => {
+ const el = document.createElement("div");
+ el.textContent = "measure me";
+ el.style.font = "16px/1.5 monospace";
+ document.body.append(el);
+ const rect = el.getBoundingClientRect();
+ expect(rect.height).toBeGreaterThan(0);
+ expect(rect.width).toBeGreaterThan(0);
+ el.remove();
+});
diff --git a/packages/bones/tests/browser/visual.test.tsx b/packages/bones/tests/browser/visual.test.tsx
new file mode 100644
index 0000000..d9bbb16
--- /dev/null
+++ b/packages/bones/tests/browser/visual.test.tsx
@@ -0,0 +1,90 @@
+///
+import type { ReactNode } from "react";
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, expect, test } from "vite-plus/test";
+import { page } from "vite-plus/test/browser";
+import "../../src/element/index.ts";
+import "../../src/css/auto.css";
+import { createBones, forceBones } from "../../src/react/index.ts";
+
+// ---------------------------------------------------------------------------
+// One card, three renderers. auto.css restates the text-bar geometry from
+// bones.css and only a selector-text test pins the copies; these screenshots
+// pin what a user actually sees. data-bone-animate="none" everywhere so the
+// shimmer cannot make an image non-deterministic.
+// ---------------------------------------------------------------------------
+
+const CARD_STYLE = "width: 320px; padding: 16px; font: 16px/1.5 sans-serif; background: #fff;";
+
+const roots: Root[] = [];
+
+afterEach(() => {
+ for (const root of roots.splice(0)) root.unmount();
+ document.body.innerHTML = "";
+});
+
+function mountHtml(html: string): HTMLElement {
+ document.body.insertAdjacentHTML("beforeend", html);
+ return document.body.lastElementChild as HTMLElement;
+}
+
+function renderReact(node: ReactNode): HTMLElement {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ roots.push(root);
+ flushSync(() => root.render(node));
+ return host.firstElementChild as HTMLElement;
+}
+
+function ReactCard() {
+ const { bone } = createBones<{ title: string }>(forceBones);
+ return (
+
+
+
Placeholder title
+
Placeholder body copy that runs a little longer.
+
+ );
+}
+
+const CONTENT = `
+
+
Real title
+
Real body copy that runs a little longer than one line at this width.
+
+`;
+
+// Each `toMatchScreenshot` call below needs a suppression for the same
+// upstream typing gap documented on the `toMatchScreenshot` config key in
+// vite.config.ts: vite-plus-test's `declare module "vitest/node"`
+// augmentation targets the real `vitest` package, not vite-plus's vendored
+// internal copy, so `Assertion` here has no merged type even
+// though the matcher exists and passes at runtime (proven by this file).
+
+test("react renderer with forceBones", async () => {
+ renderReact();
+ // @ts-expect-error — see the file-level comment above.
+ await expect(page.getByTestId("react-card")).toMatchScreenshot("react-force");
+});
+
+test("element renderer with auto.css", async () => {
+ mountHtml(
+ `${CONTENT}`,
+ );
+ // @ts-expect-error — see the file-level comment above.
+ await expect(page.getByTestId("element-card")).toMatchScreenshot("element-force");
+});
+
+test("bare aria-busy region with auto.css", async () => {
+ mountHtml(
+ `