diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e1ea23b..35faa089 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,6 +234,8 @@ jobs: package-manager-cache: false - run: bun install --frozen-lockfile --ignore-scripts - name: Verify the site with pinned Node and Chromium + env: + SLOPCAMERA_SITE_ARTIFACTS: ${{ github.workspace }}/site-browser-artifacts run: | set -euo pipefail NODE_EXECUTABLE_PATH="$(command -v node)" @@ -247,6 +249,14 @@ jobs: bun run check:web - name: Check generated site standalone boundary run: bun run check:standalone + - name: Upload site browser evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: slopcamera-site-browser-${{ github.sha }} + path: site-browser-artifacts/ + if-no-files-found: error + retention-days: 7 package: name: Slopcamera packed consumer diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md new file mode 100644 index 00000000..5997bc9c --- /dev/null +++ b/apps/web/DESIGN.md @@ -0,0 +1,27 @@ +# Slopcamera website + +The public site introduces a media tool to coding-agent users. Its first proof +is an actual rendered example with editable source, followed by a small +gallery, workflow, interfaces, installation, and limits. Documentation keeps +its existing tutorial, how-to, reference, and explanation organization. + +## Visual system + +Use the shared Catppuccin light/dark palette and Nebula Sans headings, with +monospace reserved for code. Design-kit v0.23.0 and its exact marketing, paper, +and Lantern snapshots supply flat backgrounds and thin rules. The gallery +keeps real media, with no decorative card chrome or floating hero artwork. +Interface examples use ruled rows; technique links remain plain and touch-sized. +The shared v0.20.0 footer follows the document and aligns with the 72rem column. + +## Intentional differences for native review + +The homepage loses its hero light field and contour pattern. Headings become +sans, backgrounds opaque, and shadows disappear. The existing media players, +copy controls, appearance control, keyboard focus, docs navigation, preview, +privacy restrictions, and release-derived install commands retain their behavior. +The ordinary header is explicitly in scope: phone navigation exposes all links +in a scrollable second row, with 44px targets, instead of hiding destinations. +Review 360px, 390px, and 1440px layouts in light/dark plus the existing +JavaScript-disabled and forced-color cases. Historical migration comparisons +retain their original inputs; this work claims current-design acceptance. diff --git a/apps/web/bun.lock b/apps/web/bun.lock index d53be42c..f6993fc3 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -5,10 +5,10 @@ "": { "name": "@hraness/slopcamera-web", "dependencies": { - "@hraness/design-kit": "github:hraness/design-kit#v0.22.0", + "@hraness/design-kit": "github:hraness/design-kit#v0.23.0", "@hraness/design-kit-articles": "github:hraness/design-kit#v0.21.0", "@hraness/design-kit-status": "github:hraness/design-kit#v0.21.0", - "@hraness/site-footer": "github:hraness/site-footer#v0.19.3", + "@hraness/site-footer": "github:hraness/site-footer#v0.20.0", "@hraness/ui": "github:hraness/ui#v0.5.16", "@hraness/web-discovery": "github:hraness/web-discovery#v0.9.0", "@resvg/resvg-js": "2.6.2", @@ -71,7 +71,7 @@ "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@hraness/design-kit": ["@hraness/design-kit@github:hraness/design-kit#985ead3", { "dependencies": { "@hugeicons/core-free-icons": "4.2.3", "@stylexjs/stylex": "0.19.0", "motion": "12.42.0", "next-themes": "0.4.6", "react-aria-components": "1.19.0", "recharts": "3.8.0", "sugar-high": "^1.2.1", "web-haptics": "0.0.6" }, "peerDependencies": { "@hraness/ui": ">=0.5.16 <0.6.0", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@hraness/ui"] }, "hraness-design-kit-985ead3", "sha512-Na5TTU+iDxDR2GwhNwGhAYn0j8d/Bz3Shhhvp/Jqy/hdxRXShvHlnoh7uYqPpqBIVWtGxUwht9unQaSAZ4NtGQ=="], + "@hraness/design-kit": ["@hraness/design-kit@github:hraness/design-kit#585fba2", { "dependencies": { "@hugeicons/core-free-icons": "4.2.3", "@stylexjs/stylex": "0.19.0", "motion": "12.42.0", "next-themes": "0.4.6", "react-aria-components": "1.19.0", "recharts": "3.8.0", "sugar-high": "^1.2.1", "web-haptics": "0.0.6" }, "peerDependencies": { "@hraness/ui": ">=0.5.16 <0.6.0", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@hraness/ui"] }, "hraness-design-kit-585fba2", "sha512-VlxOg8iAQ59LzbkBnt1EB1ikiu8jEuxku9yK+skfuozYtFFxEq9/JHE1xiyYHzSR0EmNCMKt0AGJjpuTIief9w=="], "@hraness/design-kit-articles": ["@hraness/design-kit@github:hraness/design-kit#4c3f7c9", { "dependencies": { "@hugeicons/core-free-icons": "4.2.3", "@stylexjs/stylex": "0.19.0", "motion": "12.42.0", "next-themes": "0.4.6", "react-aria-components": "1.19.0", "recharts": "3.8.0", "sugar-high": "^1.2.1", "web-haptics": "0.0.6" }, "peerDependencies": { "@hraness/ui": ">=0.5.16 <0.6.0", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@hraness/ui"] }, "hraness-design-kit-4c3f7c9", "sha512-7SfYsa39IMWkb67vXWBK5xJ5l7nrotVERD/KnhogvJObxLBfvxLLl6qBY9Q6QK0uxBE5LTzRT/huciboQf91nw=="], @@ -79,7 +79,7 @@ "@hraness/direct": ["@hraness/direct@github:hraness/direct#f19d0fd", { "peerDependencies": { "@antithesishq/bombadil": "0.7.2", "agent-browser": "0.32.3", "effect": "3.22.1", "react": ">=18 <20" }, "optionalPeers": ["@antithesishq/bombadil", "agent-browser", "effect", "react"] }, "hraness-direct-f19d0fd", "sha512-rRzriJYSuIobCJFRWNK2Ye4esZYRkfyamsQpIndWf/8Wa0G16f7MQ/bmKr5XFhOYx6MLc8xzkRgfqZdZ8mTAxQ=="], - "@hraness/site-footer": ["@hraness/site-footer@github:hraness/site-footer#4f38057", { "peerDependencies": { "react": ">=18 <20" }, "optionalPeers": ["react"] }, "hraness-site-footer-4f38057", "sha512-bFOLkhPmTpkESiDhmJhAPVIjLZHoaCSbD9BEW2D1h676n2J2H123wATpI5nVUUvZw7/mCVSy6hpUN6jq0N6lQQ=="], + "@hraness/site-footer": ["@hraness/site-footer@github:hraness/site-footer#4244dc5", { "peerDependencies": { "react": ">=18 <20" }, "optionalPeers": ["react"] }, "hraness-site-footer-4244dc5", "sha512-5F4KwQ0ZrjwQjHNDAkBuhI5U7d7+gPMSluyuP0ZHkKdpahbomcxr4UtmQVp7ZJcTHuodHyaRKkIz9SK5y82mOQ=="], "@hraness/ui": ["@hraness/ui@github:hraness/ui#fa6df70", { "dependencies": { "@hugeicons/core-free-icons": "4.2.3", "@hugeicons/react": "^1.1.9", "@stylexjs/stylex": "0.19.0", "clsx": "^2.1.1", "parse5": "8.0.1", "react-aria": "3.50.0", "react-aria-components": "1.19.0" }, "peerDependencies": { "@babel/core": "7.29.7", "@stylexjs/babel-plugin": "0.19.0", "@types/babel__core": "7.20.5", "@types/bun": "1.3.14", "@types/node": "^20.19.0 || >=22.12.0", "lightningcss": "1.33.0", "next": "16.2.12 || 16.3.3", "react": ">=18 <20", "react-dom": ">=18 <20", "typescript": "6.0.3", "vite": "7.3.6 || 8.2.1" }, "optionalPeers": ["@babel/core", "@stylexjs/babel-plugin", "@types/babel__core", "@types/bun", "@types/node", "lightningcss", "next", "typescript", "vite"] }, "hraness-ui-fa6df70", "sha512-EW2h6MeyImS8ESP2eIaT/29WdnpY3I60XRTqObi/0bbSDW2TLbBCl3GmpdbpA72ob7InsJ54bwNZeFisQmjKqg=="], diff --git a/apps/web/package.json b/apps/web/package.json index acea4aa9..96af3716 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,10 +6,10 @@ "type": "module", "packageManager": "bun@1.3.14", "dependencies": { - "@hraness/design-kit": "github:hraness/design-kit#v0.22.0", + "@hraness/design-kit": "github:hraness/design-kit#v0.23.0", "@hraness/design-kit-articles": "github:hraness/design-kit#v0.21.0", "@hraness/design-kit-status": "github:hraness/design-kit#v0.21.0", - "@hraness/site-footer": "github:hraness/site-footer#v0.19.3", + "@hraness/site-footer": "github:hraness/site-footer#v0.20.0", "@hraness/ui": "github:hraness/ui#v0.5.16", "@hraness/web-discovery": "github:hraness/web-discovery#v0.9.0", "@resvg/resvg-js": "2.6.2", diff --git a/apps/web/scripts/build-site.ts b/apps/web/scripts/build-site.ts index 24eae744..30e9e8b1 100644 --- a/apps/web/scripts/build-site.ts +++ b/apps/web/scripts/build-site.ts @@ -23,8 +23,8 @@ import { renderStatusPage } from "../src/status-page-content" import type { BlogPageContent, SiteAssets } from "../src/site-content" const packages = [ - { name: "@hraness/design-kit", version: "0.22.0" }, - { name: "@hraness/site-footer", version: "0.19.3" }, + { name: "@hraness/design-kit", version: "0.23.0" }, + { name: "@hraness/site-footer", version: "0.20.0" }, { name: "@hraness/ui", version: "0.5.16" }, ] as const const fontFiles = [ @@ -112,7 +112,8 @@ export async function buildSite(appDirectory: string, assets: SiteAssets): Promi return { path: below(root, join(presetRoot, path)), sha256: siteSha256(bytes), bytes: bytes.byteLength } }) fonts.push(...presetAssets.filter(item => item.path.endsWith(".woff2"))) - const images = presetAssets.filter(item => item.path.endsWith(".svg")) + // Quiet emits no decorative textures; snapshot bytes remain captured source inputs. + const images: typeof presetAssets = [] // The shared metallic wordmark paints the canonical product mark; its bytes // are admitted once as the foil mask image. const wordmarkMaskPath = join(app, "src/marks/slopcamera.svg") diff --git a/apps/web/scripts/lantern-material.test.ts b/apps/web/scripts/lantern-material.test.ts index 49c58fcc..f5d34a55 100644 --- a/apps/web/scripts/lantern-material.test.ts +++ b/apps/web/scripts/lantern-material.test.ts @@ -20,7 +20,7 @@ describe("Lantern material admission and scope", () => { test("admits only the complete released finite inventory and rejects changed ownership or bytes", async () => { const admitted = await snapshotLanternMaterial(vendor) - expect(admitted.sourceCommit).toBe("d38d13c07d7956d02ddfbca8d32aa2066d88fbd3") + expect(admitted.sourceCommit).toBe("3df4c411c7f5e5cbc02448463571696f0d47cee5") expect([...admitted.files.keys()].sort()).toEqual(["LICENSE", "check.d.mts", "check.mjs", "lantern-material.css"]) const root = await realpath(await mkdtemp(join(tmpdir(), "slopcamera-lantern-"))) try { @@ -50,8 +50,8 @@ describe("Lantern material admission and scope", () => { ]) expect(home.match(/data-hraness-material="lantern"/gu)).toHaveLength(1) expect(home).toContain('class="topbar hraness-material-chrome {{SITE_HEADER_CLASS}}"') - expect(home.match(/hraness-material-wall/gu)).toHaveLength(1) - expect(home).toContain('class="hraness-marketing-hero slopcamera-product-hero hraness-material-wall"') + expect(home).not.toContain("hraness-material-wall") + expect(home).toContain('class="hraness-marketing-hero slopcamera-product-hero"') expect(home).toContain("{{EXAMPLE_HERO}}") expect(home).not.toContain("hraness-material-terminal") expect(home).not.toContain('class="hraness-marketing-field"') diff --git a/apps/web/scripts/lantern-material.ts b/apps/web/scripts/lantern-material.ts index d910d1de..5c015d87 100644 --- a/apps/web/scripts/lantern-material.ts +++ b/apps/web/scripts/lantern-material.ts @@ -4,7 +4,7 @@ import { checkLanternMaterialSnapshot } from "../vendor/lantern-material/check.m import { readPreviewFile } from "./preview-file" import { siteSha256 } from "./site-contract" -export const currentLanternMaterialRevision = "d38d13c07d7956d02ddfbca8d32aa2066d88fbd3" +export const currentLanternMaterialRevision = "3df4c411c7f5e5cbc02448463571696f0d47cee5" export const historicalLanternMaterialRevision = "0e089bc18f9a0409f0e74b1fb7192f468956e386" export type LanternMaterialRevision = typeof currentLanternMaterialRevision | typeof historicalLanternMaterialRevision diff --git a/apps/web/scripts/site-contract.test.ts b/apps/web/scripts/site-contract.test.ts index 660708e5..509e1be1 100644 --- a/apps/web/scripts/site-contract.test.ts +++ b/apps/web/scripts/site-contract.test.ts @@ -37,8 +37,8 @@ function completeFixture() { const finalCss = artifact(`assets/site-${digest}.css`, ".fixture{display:grid}") // Deliberately make canonical identity order differ from package name order. const packages = [ - { manifestSha256: "8".repeat(64), name: "@hraness/design-kit", version: "0.22.0" }, - { manifestSha256: "e".repeat(64), name: "@hraness/site-footer", version: "0.19.3" }, + { manifestSha256: "8".repeat(64), name: "@hraness/design-kit", version: "0.23.0" }, + { manifestSha256: "e".repeat(64), name: "@hraness/site-footer", version: "0.20.0" }, { manifestSha256: "a".repeat(64), name: "@hraness/ui", version: "0.5.16" }, ] const complete = { @@ -351,3 +351,19 @@ describe("site shell artifact publication (pure synthetic controls)", () => { } }) }) + + +test("Quiet admits no decorative textures and rejects reintroduced texture bytes", () => { + const quiet = foundationOutput(); + quiet.output = quiet.output.filter(item => !item.fileName?.endsWith(".svg")); + const css = quiet.output.find(item => item.fileName?.endsWith(".css"))!; + css.source = String(css.source).replace(/\.texture\d+\{[^}]+\}/gu, ""); + const inspect = (source: string) => [...source.matchAll(/url\(([^)]+)\)/gu)].map(match => match[1]!); + expect(snapshotWithInspection(quiet, fontHashes, entrypoint, inspect, []).artifacts).toHaveLength(16); + expect(() => snapshotWithInspection(foundationOutput(), fontHashes, entrypoint, inspect, [])).toThrow(); + const unapproved = structuredClone(quiet); + unapproved.output.push({ type: "asset", fileName: "assets/unapproved.svg", source: imageSources[0]! }); + expect(() => snapshotWithInspection(unapproved, fontHashes, entrypoint, inspect, [])).toThrow(); + css.source += ".unexpected{background:url(./texture-0.svg)}"; + expect(() => snapshotWithInspection(quiet, fontHashes, entrypoint, inspect, [])).toThrow("multiplicity"); +}); diff --git a/apps/web/scripts/site-contract.ts b/apps/web/scripts/site-contract.ts index d22c5fe0..971ac15b 100644 --- a/apps/web/scripts/site-contract.ts +++ b/apps/web/scripts/site-contract.ts @@ -15,8 +15,8 @@ export const siteSha256 = (value: string | Uint8Array): string => createHash("sh const foundationRoot = "graphs/site-foundation/" const maxArtifactBytes = 16 * 1024 * 1024 const releases = [ - { name: "@hraness/design-kit", version: "0.22.0" }, - { name: "@hraness/site-footer", version: "0.19.3" }, + { name: "@hraness/design-kit", version: "0.23.0" }, + { name: "@hraness/site-footer", version: "0.20.0" }, { name: "@hraness/ui", version: "0.5.16" }, ] as const @@ -80,7 +80,6 @@ function capturedFoundation(value: SiteFoundation): SiteArtifact[] { assert.equal(scripts.length, 1, "Site foundation requires one captured private entry") assert.equal(css.length, 1, "Site foundation requires one captured stylesheet") assert.equal(fonts.length, 14, "Site foundation requires fourteen captured fonts") - assert.ok(images.length >= 2, "Site foundation requires two captured textures plus one mask per foil mark") assert.equal(input.artifacts.length, 16 + images.length, "Site foundation artifact inventory must account for every foil mask") assert.equal(input.privateScriptPath, `${foundationRoot}${scripts[0]!.path}`) assert.equal(input.cssPath, `${foundationRoot}${css[0]!.path}`) @@ -94,9 +93,9 @@ export function snapshotSiteFoundation(value: unknown, fontHashes: readonly stri assert.equal(fontHashes.length, 14) fontHashes.forEach(digest) assert.equal(new Set(fontHashes).size, 14, "Site foundation font inputs must be distinct") - assert.equal(imageHashes.length, 2) + assert.ok(imageHashes.length === 0 || imageHashes.length === 2, "Site textures must match either Quiet or the historical paired texture profile") imageHashes.forEach(digest) - assert.equal(new Set(imageHashes).size, 2, "Site foundation texture inputs must be distinct") + assert.equal(new Set(imageHashes).size, imageHashes.length, "Site foundation texture inputs must be distinct") maskHashes.forEach(digest) assert.equal(new Set(maskHashes).size, maskHashes.length, "Site foundation mask inputs must be distinct") for (const maskHash of maskHashes) { @@ -105,7 +104,7 @@ export function snapshotSiteFoundation(value: unknown, fontHashes: readonly stri const results = Array.isArray(value) ? value : [value] assert.equal(results.length, 1, "Site foundation requires one Vite output") const output = record(results[0]).output - assert.ok(Array.isArray(output) && output.length === 18 + maskHashes.length, "Site foundation must emit one private entry, one CSS asset, fourteen WOFF2 assets and two SVG textures plus one mask per foil mark") + assert.ok(Array.isArray(output) && output.length === 16 + imageHashes.length + maskHashes.length, "Site foundation must emit one private entry, one CSS asset, fourteen WOFF2 assets and the exact approved SVG textures and foil masks") const chunks = output.map(record).filter(item => item.type === "chunk") assert.equal(chunks.length, 1, "Site foundation requires exactly one captured private entry") const chunk = chunks[0]! @@ -217,8 +216,8 @@ export function projectSiteArtifacts(value: unknown, expected: Readonly<{ return false }) assert.equal(rendererEntries, 1, "Site generation requires one captured renderer entry") - const maskCount = captured.filter(item => item.path.endsWith(".svg")).length - 2 - assert.equal(publicArtifacts.length, 18 + maskCount + expected.documents.length) + const imageCount = captured.filter(item => item.path.endsWith(".svg")).length + assert.equal(publicArtifacts.length, 16 + imageCount + expected.documents.length) for (const path of allowed) assert.ok(publicArtifacts.some(item => item.path === path), `Missing public site artifact: ${path}`) return sorted(publicArtifacts) } diff --git a/apps/web/scripts/site-copy-content.test.ts b/apps/web/scripts/site-copy-content.test.ts index 8a0299c1..0342bcbe 100644 --- a/apps/web/scripts/site-copy-content.test.ts +++ b/apps/web/scripts/site-copy-content.test.ts @@ -80,7 +80,7 @@ describe("media studio public copy (pure, process-free)", () => { const commands = ["slopcamera diagram init first.diagram.json", "slopcamera diagram check first.diagram.json --strict", "slopcamera diagram render first.diagram.json"] expect(html).toContain("{{EXAMPLE_HERO}}") expect(html).toContain("{{EXAMPLE_GALLERY}}") - expect(html).toContain("docs/tutorials/first-diagram.md") + expect(html).toContain('href="/docs/tutorials/first-diagram"') for (const source of [readme, diagramSession, homeMarkdown]) { const positions = commands.map(command => source.indexOf(command)) expect(positions.every(position => position >= 0)).toBe(true) @@ -126,7 +126,10 @@ describe("media studio public copy (pure, process-free)", () => { const routes = JSON.parse(config).redirects.filter((route: { has?: unknown }) => route.has === undefined) expect(routes).toEqual([]) const links = [...html.matchAll(/href="(https:\/\/github.com\/hraness\/slopcamera\/blob\/main\/docs\/[^"#]+)(?:#[^"]*)?"/gu)] - expect(links.length).toBeGreaterThanOrEqual(5) + expect(links.length).toBeGreaterThanOrEqual(1) + const firstPartyLinks = [...html.matchAll(/href="(\/docs\/[^"#]+)"/gu)] + expect(firstPartyLinks.length).toBeGreaterThanOrEqual(10) + for (const [, route] of firstPartyLinks) expect(await Bun.file(new URL(`../src${route}.md`, import.meta.url)).exists()).toBe(true) const allowed = new Set(["README.md", "tutorials/first-diagram.md", "tutorials/first-native-film.md", "spatial-scenes.md", "studio.md", "directing-video.md", "how-to/edit-video.md", "how-to/generate-media.md", "how-to/educational-video.md", "how-to/run-workflows.md", "reference/capabilities.md", "architecture.md", "how-to/use-current-source.md"]) for (const [, link] of links) expect(allowed.has(link!.split("/docs/")[1]!)).toBe(true) expect(html).not.toMatch(/ { test("current hero and trust literals match authored content before reduced-motion enhancement", async () => { const source = await readFile(new URL("../src/index.html", import.meta.url), "utf8") expect(source.split("{{EXAMPLE_HERO}}")).toHaveLength(2) - // The shared inert hero artwork is a build-time slot that site-content.ts fills; it is not - // authored hero copy, and portfolio-surfaces-v3 checks the rendered artwork separately. - expect(source.split("{{HERO_BACKDROP}}")).toHaveLength(2) - const tree = parseFragment(source.replace("{{EXAMPLE_HERO}}", () => renderExampleHero()).replace(/^[ \t]*\{\{HERO_BACKDROP\}\}\r?\n/mu, "")) + // The quiet hero keeps its real media proof without the retired decorative backdrop. + expect(source).not.toContain("{{HERO_BACKDROP}}") + const tree = parseFragment(source.replace("{{EXAMPLE_HERO}}", () => renderExampleHero())) type Node = DefaultTreeAdapterMap["node"] type Element = DefaultTreeAdapterMap["element"] const descendants = (node: Node): Element[] => "childNodes" in node diff --git a/apps/web/scripts/site-examples-profile.ts b/apps/web/scripts/site-examples-profile.ts index e475fbdf..94a7f0c5 100644 --- a/apps/web/scripts/site-examples-profile.ts +++ b/apps/web/scripts/site-examples-profile.ts @@ -18,7 +18,7 @@ export const examplesIslands: readonly ExamplesIsland[] = [ { "selector": ".hraness-marketing-hero", "baseline": "
\n
\n

Slopcamera

\n

Direct scenes and films with your coding agent

\n

\n Slopcamera is a local visual studio for coding agents. Author scenes, combine\n generated and recorded media, and export images, diagrams, animation,\n and video from retained sources.\n

\n \n

Free and open source under the MIT license · Verified release · Bun 1.3.14 or newer · Recording needs macOS

\n
\n
\n
\n
\n \n slopcamera · your first local diagram\n
\n
\n
#    __\n#  _|__|_\n# |  (o) |  slopcamera\n# |______|\n\n$ slopcamera diagram init first.diagram.json\n# An editable starter, ready for your labels.\n$ slopcamera diagram check first.diagram.json --strict\nValid diagram.\n$ slopcamera diagram render first.diagram.json\n# example-flow.tldr\n# example-flow.light.svg + example-flow.dark.svg\n# example-flow.light.png + example-flow.dark.png
\n
\n
\n An included input, five local outputs, no model account. Follow the complete tutorial; rendering again replaces the derived files.\n Illustrative session · included starter\n
\n
\n
\n
\n
Outputs
Four familiesImages, diagrams, animated loops, and video
\n
Interfaces
Start with your agentAgent Skill and CLI; SDK and a fixed MCP subset
\n
Delivery
16:9, 9:16, 1:1, 4:5Clean and captioned cuts from one edit
\n
Cost
FreeMIT license, no account
\n
\n
", - "current": "
\n
\n

Slopcamera

\n

Visual work your agent can keep revising.

\n

\n Images, diagrams, animation, 3D scenes, and edited video, built from source files\n so a change is a re-render, not a re-roll.\n

\n \n

Free and open source under the MIT license · Bun 1.3.14 or newer · Blender, CadQuery, and Manim install separately

\n
\n
\n
An optical instrument, in motion

A camera move around original brass and glass; the Blender scene stays editable.

6s · Silent

Follow the guideView sourceOpen video

\n
\n
\n
Outputs
Four familiesImages, diagrams, animated loops, and video
\n
Interfaces
Through your agentSkill and CLI; SDK and MCP too
\n
Delivery
16:9, 9:16, 1:1, 4:5Clean and captioned cuts from one edit
\n
Cost
FreeMIT license; model usage is paid
\n
\n
" + "current": "
\n
\n

Media studio for coding agents

\n

Visual work your agent can keep revising.

\n

\n Images, diagrams, animation, 3D scenes, and edited video, built from source files\n so your agent can revise a scene and render it again.\n

\n \n

Free and open source under the MIT license · Bun 1.3.14 or newer on macOS, Linux, or Windows

\n
\n
\n
An optical instrument, in motion

A camera move around original brass and glass; the Blender scene stays editable.

6s · Silent

Follow the guideView sourceOpen video

\n
\n
" }, { "selector": "#examples", @@ -55,7 +55,7 @@ export const examplesIslands: readonly ExamplesIsland[] = [ { "selector": "#design", "baseline": "
\n
\n

Retain the sources behind the result.

\n

Keep sources, edits, and outputs in your own storage. Rendered derivatives connect native and portable assets without erasing their differences.

\n
\n
\n
Source media stays unchanged
Original recordings and imported media stay unchanged. Cuts, framing, overlays, and effects remain project decisions.
\n
Project state stays on your machine
There is no Slopcamera account or hosted project database. You control source, project state, previews, and final files.
\n
Gateway credentials stay local
Local processes use your Vercel AI Gateway credential without storing or printing it. Media uploads require acknowledgement.
\n
Operations leave receipts
Preview and final renders share the timeline and composition. Important operations retain secret-free input and output receipts.
\n
\n

Native Python execution requires separate authorization. Loading a custom Bun workflow evaluates its module, including during check and plan; review the source before invoking it. Both run as your current user, without an operating-system sandbox. Runtime hashes identify observed tools; they do not make arbitrary source hermetic. Read the security policy and privacy guide.

\n

Find a path through the documentation index: learn with a first diagram, run a workflow, look up capabilities, or understand the architecture.

\n
", - "current": "
\n
\n

What stays on your machine

\n

Ask the agent for the same video twice; the second take differs. Slopcamera keeps the source beside every render; a revision changes what you named, nothing else. Sources, edits, and renders stay on your own storage.

\n
\n
\n
Source media stays unchanged
Original recordings and imported media stay unchanged. Cuts, framing, overlays, and effects remain project decisions.
\n
Project state stays on your machine
There is no Slopcamera account or hosted project database. You control source, project state, previews, and final files.
\n
Gateway credentials stay local
Local processes use your Vercel AI Gateway credential without storing or printing it. Media uploads require acknowledgement.
\n
Operations leave a record
Preview and final renders share the timeline and composition. Important operations save a record of their inputs and outputs, with no secrets in it.
\n
\n

Native engine jobs run only with --allow-trusted-code. They and custom Bun workflows run as your current user, without an operating-system sandbox, so review their source first. Read the security policy and privacy guide.

\n

Find your way in the docs: a first diagram, a workflow, capabilities, or the architecture.

\n
" + "current": "
\n
\n

Your files

\n

What stays on your machine

\n

Slopcamera keeps editable source beside each render. Sources, edits, and local renders stay on your own storage; generation and selected cloud analysis use the services described below.

\n
\n
\n
Source media stays unchanged
Original recordings and imported media stay unchanged. Cuts, framing, overlays, and effects remain project decisions.
\n
Project state stays on your machine
There is no Slopcamera account or hosted project database. You control source, project state, previews, and final files.
\n
Gateway credentials stay local
Local processes use your Vercel AI Gateway credential without storing or printing it. Media uploads require acknowledgement.
\n
Operations leave a record
Important operations save a record of their inputs and outputs, with no secrets in it.
\n
\n

Native engine jobs run only with --allow-trusted-code. They and custom Bun workflows run as your current user, without an operating-system sandbox, so review their source first. Read the security policy and privacy guide.

\n
" }, ] export const examplesHomeIds = ["native-product", "premiere-wall", "island-pulse", "interference-field", "native-fluid", "compute-temple"] as const diff --git a/apps/web/scripts/site-template.test.ts b/apps/web/scripts/site-template.test.ts index 23a1ff9f..02b75e42 100644 --- a/apps/web/scripts/site-template.test.ts +++ b/apps/web/scripts/site-template.test.ts @@ -144,7 +144,8 @@ describe("ordinary shell authored contract (pure, process-free)", () => { } expect(recipes).toContain('const tablet = "@media (max-width: 48rem)"') expect(recipes).toContain('const phone = "@media (max-width: 34rem)"') - expect(recipes).toContain('display: { default: null, [phone]: "none" }') + expect(recipes).toContain('overflowX: "auto"') + expect(recipes).not.toContain('display: { default: null, [phone]: "none" }') expect(recipes).toContain('stylex.props(shell.primaryAction, shell.navigationAction)') expect(recipes).not.toMatch(/recovery[A-Z]?\w*:/u) expect(recipes).toContain('[forcedColors]: {\n default: "CanvasText",') diff --git a/apps/web/scripts/verify-site-current.mjs b/apps/web/scripts/verify-site-current.mjs index d4a4c335..f33a350c 100644 --- a/apps/web/scripts/verify-site-current.mjs +++ b/apps/web/scripts/verify-site-current.mjs @@ -4,7 +4,7 @@ // JavaScript, and under forced colors. Run with Node 24 after `bun run build`. import assert from "node:assert/strict" import { existsSync } from "node:fs" -import { readFile } from "node:fs/promises" +import { mkdir, readFile, writeFile } from "node:fs/promises" import { createServer } from "node:http" import { createRequire } from "node:module" import { dirname, extname, join, normalize, resolve } from "node:path" @@ -16,7 +16,7 @@ const { chromium } = createRequire(join(appDirectory, "package.json"))("playwrig export const currentRoutes = ["/", "/docs", "/docs/tutorials/first-diagram", "/docs/reference/sdk", "/blog", "/blog/introducing-slopcamera"] export const missingRoute = "/not-a-page" -export const widths = [390, 1440] +export const widths = [360, 390, 1440] export const schemes = ["light", "dark"] const types = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".png": "image/png", ".webp": "image/webp", ".mp4": "video/mp4", ".json": "application/json" } @@ -105,6 +105,10 @@ async function review(browser, origin, path, options) { check(state.appearanceLast, "appearance menu is not the last header action") check(state.skipLink !== null, "missing skip link") if (options.forcedColors) check(state.backdropHidden, "hero backdrop visible under forced colors") + if (options.artifacts && widths.includes(options.width)) { + const route = path === "/" ? "home" : path.replace(/[^a-z0-9-]+/giu, "_") + await page.screenshot({ path: join(options.artifacts, `${options.width}-${options.scheme}-${route}.png`), fullPage: true }) + } return { label, problems } } finally { await context.close() @@ -113,19 +117,23 @@ async function review(browser, origin, path, options) { export async function main() { assert.ok(existsSync(join(dist, "index.html")), "Build the site before running verify:current") + const artifacts = process.env.SLOPCAMERA_SITE_ARTIFACTS + if (artifacts) await mkdir(artifacts, { recursive: true }) const { server, origin } = await serve() - const browser = await chromium.launch({ executablePath: findChrome(), headless: true }) + let browser const results = [] try { + browser = await chromium.launch({ executablePath: findChrome(), headless: true }) for (const path of [...currentRoutes, missingRoute]) for (const width of widths) for (const scheme of schemes) { - results.push(await review(browser, origin, path, { width, scheme })) + results.push(await review(browser, origin, path, { width, scheme, artifacts })) } for (const path of ["/", "/docs"]) results.push(await review(browser, origin, path, { width: 390, javaScript: false })) for (const path of ["/", "/blog"]) results.push(await review(browser, origin, path, { width: 1440, forcedColors: true })) } finally { - await browser.close() - server.close() + try { await browser?.close() } + finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) } } + if (artifacts) await writeFile(join(artifacts, "browser-evidence.json"), JSON.stringify({ cleanup: "browser and server closed", results }, null, 2) + "\n") const failed = results.filter(result => result.problems.length > 0) for (const result of failed) console.error(`FAIL ${result.label}\n ${result.problems.join("\n ")}`) console.log(`verify:current ${results.length - failed.length}/${results.length} cases passed`) diff --git a/apps/web/site.test.ts b/apps/web/site.test.ts index 28c92f51..39304049 100644 --- a/apps/web/site.test.ts +++ b/apps/web/site.test.ts @@ -191,52 +191,6 @@ function assertCombinedSiteCssBudget(styles: string, foundation: string): number return bytes } -/** Evaluate one CSS length expression (calc, min, max, clamp, env fallback, - * px, rem at 16px, vw) at a viewport width. Only the closed grammar the shared - * footer and the host scroll padding use is accepted; nothing is evaluated as - * source text. */ -function evaluateCssLength(expression: string, viewportWidth: number): number { - const tokens = expression.match(/[a-z][a-z-]*\(|[a-z][a-z-]*|\(|\)|,|[+*/-]|\d*\.?\d+(?:px|rem|vw)?/gu) ?? [] - let index = 0 - const peek = () => tokens[index] - const take = (expected?: string): string => { - const token = tokens[index++] - if (token === undefined || (expected !== undefined && token !== expected)) throw new Error(`Unexpected CSS token ${String(token)} in ${expression}`) - return token - } - const sum = (): number => { - let value = product() - while (peek() === "+" || peek() === "-") value = take() === "+" ? value + product() : value - product() - return value - } - const product = (): number => { - let value = primary() - while (peek() === "*" || peek() === "/") value = take() === "*" ? value * primary() : value / primary() - return value - } - const list = (): number[] => { - const values = [sum()] - while (peek() === ",") { take(","); values.push(sum()) } - take(")") - return values - } - const primary = (): number => { - const token = take() - if (token === "(" || token === "calc(") { const value = sum(); take(")"); return value } - if (token === "max(") return Math.max(...list()) - if (token === "min(") return Math.min(...list()) - if (token === "clamp(") { const [low, preferred, high] = list() as [number, number, number]; return Math.min(Math.max(preferred, low), high) } - if (token === "env(") { take("safe-area-inset-bottom"); take(","); const [fallback] = list(); return fallback! } - const length = /^(\d*\.?\d+)(px|rem|vw)?$/u.exec(token) - if (length === null) throw new Error(`Unexpected CSS token ${token} in ${expression}`) - const value = Number(length[1]) - return length[2] === "rem" ? value * 16 : length[2] === "vw" ? value * viewportWidth / 100 : value - } - const value = sum() - if (index !== tokens.length) throw new Error(`Trailing CSS tokens in ${expression}`) - return value -} - /** The installed footer's custom-property tokens for the classes the rendered * footer element actually carries, split by pointer context. */ function installedFooterTokens(stylesheet: string, footerClasses: ReadonlySet): Record<"fine" | "coarse", Record> { @@ -865,9 +819,9 @@ describe("static Slopcamera site", () => { const artifacts = builtAssets.siteArtifacts const paths = artifacts.map(item => item.path) const blogDocuments = [blogIndexDocument, ...blogPosts.map(blogDocumentForPost)] - expect(artifacts).toHaveLength(19 + 2 + docPages.length + blogDocuments.length) + expect(artifacts).toHaveLength(17 + 2 + docPages.length + blogDocuments.length) expect(paths).toEqual([...paths].sort()) - expect(new Set(paths).size).toBe(19 + 2 + docPages.length + blogDocuments.length) + expect(new Set(paths).size).toBe(17 + 2 + docPages.length + blogDocuments.length) expect(paths.filter(path => path.endsWith(".html"))).toEqual([ "404.html", ...docPages.map(docsDocumentForPage), ...blogDocuments, "index.html", ].sort()) @@ -886,10 +840,10 @@ describe("static Slopcamera site", () => { const fonts = paths.filter(path => path.endsWith(".woff2")) expect(fonts).toHaveLength(14) const images = paths.filter(path => path.endsWith(".svg")) - expect(images).toHaveLength(3) + expect(images).toHaveLength(1) for (const name of ["grain.svg", "cells.svg"]) { const expected = await readFile(join(appDirectory, "vendor/marketing-preset/marketing-assets", name)) - expect(images.some(path => artifacts.find(item => item.path === path)!.sha256 === new Bun.CryptoHasher("sha256").update(expected).digest("hex"))).toBe(true) + expect(images.some(path => artifacts.find(item => item.path === path)!.sha256 === new Bun.CryptoHasher("sha256").update(expected).digest("hex"))).toBe(false) } // The third SVG is the foil wordmark mask: the canonical product mark bytes. const markBytes = await readFile(join(appDirectory, "src/marks/slopcamera.svg")) @@ -897,7 +851,7 @@ describe("static Slopcamera site", () => { const masks = images.filter(path => artifacts.find(item => item.path === path)!.sha256 === markSha256) const textures = images.filter(path => !masks.includes(path)) expect(masks).toHaveLength(1) - expect(textures).toHaveLength(2) + expect(textures).toHaveLength(0) const foundation = await readBuilt(builtAssets.siteFoundationPath.slice(1)) const union = await readBuilt(builtAssets.stylesPath.slice(1)) expect(foundation.match(/@font-face\b/gu)).toHaveLength(14) @@ -1094,13 +1048,13 @@ describe("static Slopcamera site", () => { expect(client).not.toMatch(/fetch\(|XMLHttpRequest|WebSocket|EventSource|sendBeacon/) }) - test("uses one install, examples, workflow, interfaces, and design information architecture", async () => { + test("presents examples and workflow before installation and trust details", async () => { const html = await readSource("index.html") const sections = [ - 'id="install"', 'id="examples"', 'id="workflow"', 'id="interfaces"', + 'id="install"', 'id="design"', 'id="questions"', 'id="closing"', @@ -1120,7 +1074,6 @@ describe("static Slopcamera site", () => { expect(navigation).toContain('class="site-action {{SITE_NAVIGATION_ACTION_CLASS}}" data-emphasis="primary" href="#install"') expect(html).not.toContain('class="docs-index"') for (const role of [ - "pillars", "install", "primitives", "section", @@ -1190,7 +1143,7 @@ describe("static Slopcamera site", () => { const html = await readSource("index.html") const searchableHtml = html.replace(/\s+/gu, " ") - for (const claim of ["Source", "Project", "Operations", "Outputs"]) { + for (const claim of ["Source media stays unchanged", "Project state stays on your machine", "Operations leave a record", "inputs and outputs"]) { expect(html).toContain(claim) } expect(searchableHtml).toContain("Native engine jobs run only with --allow-trusted-code") @@ -1308,7 +1261,7 @@ describe("static Slopcamera site", () => { expect(await readSource("preview.html")).not.toContain("data-hraness-marketing-preset") expect(html).not.toContain("Ben Guo") expect(html).not.toContain('class="hraness-marketing-hero__eyebrow"') - expect(html).toContain('class="hraness-marketing-hero slopcamera-product-hero hraness-material-wall" data-align="start"') + expect(html).toContain('class="hraness-marketing-hero slopcamera-product-hero" data-align="start"') expect(css).toContain("grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr)") for (const declaration of ["white-space: pre", "overflow-wrap: normal", "word-break: normal", "overflow: auto"]) { expect(css).toContain(declaration) @@ -1378,12 +1331,7 @@ describe("static Slopcamera site", () => { expect(icon).toContain('stop-color="#f6b94a"') }) - test("keeps the scrollport's optimal viewing region above the shared fixed footer bar", async () => { - // The shared footer bar is position: fixed with its own in-flow footprint, - // but Chromium's sequential focus only scrolls a target that leaves the - // layout viewport. The host's scroll-padding-block-end must equal the - // installed footer's exact bar height for both pointer contexts at every - // width, so focused and scrolled-to content is never obscured by the bar. + test("keeps the shared footer in document flow without fixed-bar scroll compensation", async () => { const [css, footerCss, html] = await Promise.all([ readSource("styles.css"), readFile(fileURLToPath(import.meta.resolve("@hraness/site-footer/stylex.css")), "utf8"), @@ -1392,33 +1340,22 @@ describe("static Slopcamera site", () => { const footerClasses = new Set(/