Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Binary file added public/template-previews/pare.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions src/__tests__/exportPages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
11 changes: 11 additions & 0 deletions src/__tests__/exportProcedural.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions src/__tests__/lightPalette.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
4 changes: 3 additions & 1 deletion src/app/api/export-site/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 25 additions & 4 deletions src/lib/generate2DFrames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
61 changes: 61 additions & 0 deletions src/lib/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
},
],
},
];
Loading