diff --git a/README.md b/README.md index 75fe84a..a8752fb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A scroll-scrubbed site plays through a sequence of frames as the reader scrolls, background moves with them instead of sitting still behind the text. ScrollCraft builds those sites without WebGL, an animation library, or a render farm. -1. **Start from a template.** 23 finished sites, each with its own palette, +1. **Start from a template.** 24 finished sites, each with its own palette, typography, pacing and copy. Or upload a video and use your own footage. 2. **Get frames.** The engine draws the frame sequence on a canvas in your browser and maps it to scroll position. @@ -37,7 +37,7 @@ one exception is the copy assistant below, which is off unless you configure a k palette. Frames are drawn on canvas locally, with no API key and nothing to install. - **Scroll engine.** Smooth canvas scrubbing with separate desktop and portrait frame sets, so a phone gets a background shaped for it rather than a letterboxed one. -- **Template library** — 23 finished scroll sites across 17 categories, each with its own +- **Template library** — 24 finished scroll sites across 17 categories, each with its own Google Fonts pairing and palette. - **Plain HTML export.** No dependencies and no lock-in. The exported page scores 100 on Lighthouse for performance, accessibility, best practices and SEO. diff --git a/public/template-previews/pare.jpg b/public/template-previews/pare.jpg new file mode 100644 index 0000000..9339052 Binary files /dev/null and b/public/template-previews/pare.jpg differ diff --git a/src/__tests__/exportPages.test.ts b/src/__tests__/exportPages.test.ts index 538ff68..0147199 100644 --- a/src/__tests__/exportPages.test.ts +++ b/src/__tests__/exportPages.test.ts @@ -263,3 +263,19 @@ describe("the gate that measures an export", () => { expect(readFileSync("docs/DEPLOY.md", "utf8")).toContain("npm run check:export"); }); }); + +describe("the exported page is legible on a light palette too", () => { + it("does not dim the muted colour a second time", async () => { + // --sc-muted already carries its own alpha. The footer's legal line dimmed it again + // with opacity: 0.75, which compounded to 54% and measured 3.73:1 on Pare's light + // ground. The export gate refused it at accessibility 95. + const route = readFileSync("src/app/api/export-site/route.ts", "utf8"); + const footer = route.slice(route.indexOf(".footer-legal"), route.indexOf(".footer-legal") + 200); + expect(footer).not.toMatch(/opacity:\s*0\.\d+/); + }); + + it("exports the light template without falling back to a dark page", async () => { + const { body } = await exportSite({ template: "pare" }); + expect(body.html).toContain("#eff2f6"); + }); +}); diff --git a/src/__tests__/exportProcedural.test.ts b/src/__tests__/exportProcedural.test.ts index 6306037..98f0cc5 100644 --- a/src/__tests__/exportProcedural.test.ts +++ b/src/__tests__/exportProcedural.test.ts @@ -139,6 +139,17 @@ describe("procedural background export", () => { }); describe("the inlined runtime is executable", () => { + it("carries every helper the drawing functions call", () => { + // baseFill was added to three styles and left out of this list. Types passed, the app + // was fine, and every exported site using those styles would have thrown on load. + const { source } = proceduralRuntimeSource(); + const defined = new Set([...source.matchAll(/function\s+([A-Za-z0-9_$]+)/g)].map((m) => m[1])); + const called = new Set([...source.matchAll(/\b([A-Za-z0-9_$]+)\s*\(/g)].map((m) => m[1])); + const builtins = new Set(["Math", "Array", "Object", "String", "Number", "if", "for", "while", "switch", "catch", "return", "function", "typeof"]); + const missing = [...called].filter((name) => !defined.has(name) && !builtins.has(name) && /^(draw|hex|lerp|base)/i.test(name)); + expect(missing, `the exported runtime calls ${missing.join(", ")} without defining them`).toEqual([]); + }); + it("draws using the recipe the page actually emits, not a hand-built one", async () => { // The original version of this test built its own options object in the correct // FrameOptions shape and ran the runtime against that. It passed while the export diff --git a/src/__tests__/lightPalette.test.ts b/src/__tests__/lightPalette.test.ts new file mode 100644 index 0000000..205e5e5 --- /dev/null +++ b/src/__tests__/lightPalette.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { baseFill } from "@/lib/generate2DFrames"; +import { TEMPLATES } from "@/lib/templates"; + +/** + * Every template was dark, and not by choice: each style began with a hardcoded + * near-black fill, so a light palette was painted over before anybody could see it. + */ + +const DARK_BASES = ["#030308", "#020208", "#020510"]; + +const optsFor = (colors: readonly [string, string, string]) => ({ + style: "geometric" as const, + color1: colors[0], + color2: colors[1], + color3: colors[2], + frameCount: 1, +}); + +describe("a light palette gets a light page", () => { + it("uses the palette's own third colour when that colour is light", () => { + expect(baseFill(optsFor(["#1b3a8f", "#8a5a2b", "#eff2f6"]), "#030308")).toBe("#eff2f6"); + }); + + it("keeps the style's own dark base for a dark palette", () => { + expect(baseFill(optsFor(["#28407e", "#6b2f2a", "#0a0d18"]), "#030308")).toBe("#030308"); + }); + + it("changes nothing for any template that already existed", () => { + // The whole catalogue, minus the one light template this was built for: every one of + // them must still start from the exact fill it started from before. + for (const template of TEMPLATES) { + if (template.slug === "pare") continue; + for (const dark of DARK_BASES) { + expect(baseFill(optsFor(template.colors), dark), `${template.slug} moved`).toBe(dark); + } + } + }); + + it("the catalogue has a light template at all, which it did not before", () => { + const light = TEMPLATES.filter((t) => baseFill(optsFor(t.colors), "#030308") !== "#030308"); + expect(light.map((t) => t.slug)).toContain("pare"); + }); +}); diff --git a/src/app/api/export-site/route.ts b/src/app/api/export-site/route.ts index c3d7fa5..8b7a7cb 100644 --- a/src/app/api/export-site/route.ts +++ b/src/app/api/export-site/route.ts @@ -469,9 +469,11 @@ export async function POST(req: NextRequest) { font-size: 1rem; line-height: 1.6; max-width: var(--sc-measure, 600px); margin: 0 auto 1.5rem; } + /* No extra opacity: the muted colour already carries its own, and dimming it again + compounded to 54% and put this line at 3.73:1 on a light page. */ #site-footer .footer-legal { color: var(--sc-muted, rgba(255,255,255,0.72)); - font-size: 0.8125rem; margin: 0; opacity: 0.75; + font-size: 0.8125rem; margin: 0; } #scroll-hint { diff --git a/src/lib/generate2DFrames.ts b/src/lib/generate2DFrames.ts index 78beae7..ba5f765 100644 --- a/src/lib/generate2DFrames.ts +++ b/src/lib/generate2DFrames.ts @@ -19,6 +19,24 @@ function lerp(a: number, b: number, t: number) { return a + (b - a) * t; } +/** + * What a frame starts from. + * + * Every style began with a near-black fill, which is why all 23 templates before Pare + * were dark whether or not their palette was: a light third colour was painted over + * before it could be seen. A palette whose third colour is light now gets that colour as + * its base, and every dark palette keeps the exact fill it had, so no existing template + * moves by a pixel. + * + * drawGradient is not included: its stops multiply the palette down to rgb(5,5,15), so a + * light palette there needs different maths rather than a different base. + */ +export function baseFill(opts: FrameOptions, dark: string): string { + const [r, g, b] = hexToRgb(opts.color3); + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + return luminance > 0.5 ? opts.color3 : dark; +} + function drawGradient(ctx: CanvasRenderingContext2D, w: number, h: number, p: number, opts: FrameOptions) { const [r1, g1, b1] = hexToRgb(opts.color1); const [r2, g2, b2] = hexToRgb(opts.color2); @@ -50,7 +68,7 @@ function drawGradient(ctx: CanvasRenderingContext2D, w: number, h: number, p: nu } function drawGeometric(ctx: CanvasRenderingContext2D, w: number, h: number, p: number, opts: FrameOptions) { - ctx.fillStyle = "#030308"; + ctx.fillStyle = baseFill(opts, "#030308"); ctx.fillRect(0, 0, w, h); const [r1, g1, b1] = hexToRgb(opts.color1); @@ -100,7 +118,7 @@ function drawGeometric(ctx: CanvasRenderingContext2D, w: number, h: number, p: n } function drawParticles(ctx: CanvasRenderingContext2D, w: number, h: number, p: number, opts: FrameOptions) { - ctx.fillStyle = "#020208"; + ctx.fillStyle = baseFill(opts, "#020208"); ctx.fillRect(0, 0, w, h); const [r1, g1, b1] = hexToRgb(opts.color1); @@ -140,7 +158,7 @@ function drawParticles(ctx: CanvasRenderingContext2D, w: number, h: number, p: n } function drawWave(ctx: CanvasRenderingContext2D, w: number, h: number, p: number, opts: FrameOptions) { - ctx.fillStyle = "#020510"; + ctx.fillStyle = baseFill(opts, "#020510"); ctx.fillRect(0, 0, w, h); const [r1, g1, b1] = hexToRgb(opts.color1); @@ -220,7 +238,10 @@ export interface ProceduralRuntime { * carries whatever names the build produced and a minified build produces short ones. */ export function proceduralRuntimeSource(): ProceduralRuntime { - const fns = [hexToRgb, lerp, drawGradient, drawGeometric, drawParticles, drawWave, drawFrame2D]; + // Every function the exported page calls, including the helpers. A missing one is not a + // type error here and not a test failure in the app: it is a site that throws in the + // visitor's browser, which is how baseFill was caught. + const fns = [hexToRgb, lerp, baseFill, drawGradient, drawGeometric, drawParticles, drawWave, drawFrame2D]; const source = fns .map((fn) => { const src = fn.toString(); diff --git a/src/lib/templates.ts b/src/lib/templates.ts index 38b389f..d266b0f 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -12,6 +12,9 @@ export interface TemplateTheme { muted?: string; accent?: string; accentText?: string; + /** The page behind the canvas. Absent, the renderer's own near-black is used, which is + * why every template before Pare was dark whether or not it wanted to be. */ + ground?: string; radius?: number; } @@ -1563,4 +1566,62 @@ export const TEMPLATES: Template[] = [ }, ], }, + { + slug: "pare", + name: "Pare", + tagline: "An engineer who makes slow databases fast, on a page that is not dark", + category: "Portfolio", + tags: ["Light", "Technical", "Consulting"], + style: "geometric", + colors: ["#1b3a8f", "#8a5a2b", "#eff2f6"], + gradient: "from-slate-300 via-stone-300 to-slate-100", + theme: { fontDisplay: "JetBrains Mono", fontBody: "Public Sans", scale: "compact", displayWeight: 700, displayTracking: -0.03, ink: "#111823", muted: "rgba(17,24,35,0.72)", accent: "#1d4ed8", accentText: "#17347f", ground: "#eff2f6", radius: 4 }, + sections: [ + { + kind: "statement", layout: "center", reveal: "mask", + eyebrow: "Independent, booked by the fortnight", + heading: "Your database is not slow. One query is.", + ctaLabel: "See what that looks like", ctaHref: "#section-2", + scrollHeight: 1300, + }, + { + layout: "left", reveal: "stagger", + eyebrow: "The usual shape of it", + heading: "A report that took forty seconds now takes three hundred milliseconds", + body: "Nine times out of ten it is one query, one missing index and one ORM call in a loop. I read the explain plans, fix the three that matter, and leave the rest alone.", + scrollHeight: 1400, + }, + { + layout: "right", reveal: "fade", + eyebrow: "How I work", + heading: "Two weeks, on your branch, with your team watching", + body: "No access to production without someone from your side in the room. Every change arrives as a pull request with the before and after timings in the description, so your engineers can argue with it.", + scrollHeight: 1400, + }, + { kind: "spacer", scrollHeight: 600 }, + { + layout: "left", reveal: "stagger", + eyebrow: "What you keep", + heading: "The explain plans, not just the fix", + body: "You get a short document per query: what it was doing, why it was slow, what changed, and how to spot the same pattern again. The point is that you do not need me the second time.", + scrollHeight: 1400, + }, + { + layout: "right", reveal: "fade", + eyebrow: "What I turn down", + heading: "Greenfield, migrations and rewrites", + body: "Starting from nothing is somebody else's work, and a rewrite is rarely the cheapest way out of a slow query. If that is what you need I will say so in the first call rather than the fourth week.", + scrollHeight: 1300, + }, + { kind: "spacer", scrollHeight: 500 }, + { + layout: "center", reveal: "fade", + eyebrow: "Next opening", + heading: "Send me the slow query.", + body: "Paste the statement and its explain plan. I will tell you whether it is worth two weeks of my time or twenty minutes of yours, for free, within a day.", + ctaLabel: "Send a query", ctaHref: "#contact", + scrollHeight: 1200, + }, + ], + }, ];