From dbfe3eb333c3d1830c8207780f9c912e285749da Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Sun, 4 Oct 2026 00:08:22 +0530 Subject: [PATCH] Add Pare, the first template that is not dark --- README.md | 4 +- public/template-previews/pare.jpg | Bin 0 -> 22143 bytes src/__tests__/exportPages.test.ts | 16 +++++++ src/__tests__/exportProcedural.test.ts | 11 +++++ src/__tests__/lightPalette.test.ts | 44 ++++++++++++++++++ src/app/api/export-site/route.ts | 4 +- src/lib/generate2DFrames.ts | 29 ++++++++++-- src/lib/templates.ts | 61 +++++++++++++++++++++++++ 8 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 public/template-previews/pare.jpg create mode 100644 src/__tests__/lightPalette.test.ts 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 0000000000000000000000000000000000000000..93390520d5b8a7a59dbf43bfda69641af6919ff8 GIT binary patch literal 22143 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 Mj&f { 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, + }, + ], + }, ];