├── .prettierrc.json
├── .gitignore
├── README.md
├── postcss.config.js
├── .eslintrc.json
├── worker.js
├── next-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── app
├── registry.tsx
├── globals.css
├── layout.tsx
├── Fold.tsx
├── api
│ └── route.tsx
└── page.tsx
├── package.json
└── util.ts
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .next
3 | .vercel
4 | .env.*
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # I am The Fold
2 |
3 | Source for http://www.iamthefold.com
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "rules": {
4 | "react-hooks/exhaustive-deps": "off"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/worker.js:
--------------------------------------------------------------------------------
1 | import { solveWork } from "./util";
2 |
3 | addEventListener("message", async (event) => {
4 | const proof = await solveWork(event.data);
5 | postMessage(proof);
6 | });
7 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./app/**/*.tsx"],
4 | theme: {
5 | extend: {
6 | colors: {
7 | red: "#ff0000",
8 | dark: "#2a2a2a",
9 | darker: "#1a1a1a",
10 | },
11 | },
12 | },
13 | plugins: [],
14 | };
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "dom",
5 | "dom.iterable",
6 | "esnext"
7 | ],
8 | "allowJs": true,
9 | "skipLibCheck": true,
10 | "strict": false,
11 | "noEmit": true,
12 | "incremental": true,
13 | "module": "esnext",
14 | "esModuleInterop": true,
15 | "moduleResolution": "node",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "preserve",
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ]
24 | },
25 | "include": [
26 | "next-env.d.ts",
27 | "**/*.ts",
28 | "**/*.tsx",
29 | ".next/types/**/*.ts"
30 | ],
31 | "exclude": [
32 | "node_modules"
33 | ]
34 | }
35 |
--------------------------------------------------------------------------------
/app/registry.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useState } from "react";
4 | import { useServerInsertedHTML } from "next/navigation";
5 | import { StyleRegistry, createStyleRegistry } from "styled-jsx";
6 |
7 | export default function StyledJsxRegistry({
8 | children,
9 | }: {
10 | children: React.ReactNode;
11 | }) {
12 | // Only create stylesheet once with lazy initial state
13 | // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
14 | const [jsxStyleRegistry] = useState(() => createStyleRegistry());
15 |
16 | useServerInsertedHTML(() => {
17 | const styles = jsxStyleRegistry.styles();
18 | jsxStyleRegistry.flush();
19 | return <>{styles}>;
20 | });
21 |
22 | return {children} ;
23 | }
24 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body,
6 | ul {
7 | padding: 0;
8 | margin: 0;
9 | }
10 | body {
11 | font-family: "Fira Mono", "Menlo", monospace;
12 | }
13 | a {
14 | color: #333;
15 | font-weight: bold;
16 | text-decoration: underline;
17 | }
18 |
19 | a:visited {
20 | color: #666;
21 | }
22 |
23 | a:hover,
24 | a:focus {
25 | color: #000;
26 | background-color: #f5f5f5;
27 | }
28 |
29 | @media (prefers-color-scheme: dark) {
30 | body,
31 | ul {
32 | background-color: #fff;
33 | color: #000;
34 | }
35 |
36 | a {
37 | color: #ccc;
38 | }
39 |
40 | a:visited {
41 | color: #999;
42 | }
43 |
44 | a:hover,
45 | a:focus {
46 | color: #fff;
47 | background-color: #333;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "i-am-the-fold",
3 | "version": "3.0.0",
4 | "description": "",
5 | "repository": "https://github.com/iest/i-am-the-fold",
6 | "author": "Iestyn Williams",
7 | "license": "MIT",
8 | "scripts": {
9 | "dev": "next dev",
10 | "build": "next build",
11 | "start": "next start",
12 | "lint": "next lint"
13 | },
14 | "dependencies": {
15 | "@vercel/analytics": "^1.3.1",
16 | "@vercel/kv": "^2.0.0",
17 | "@vercel/speed-insights": "^1.0.12",
18 | "jsonwebtoken": "^9.0.2",
19 | "next": "^14.2.5",
20 | "react": "^18.3.1",
21 | "react-dom": "^18.3.1"
22 | },
23 | "devDependencies": {
24 | "@types/jsonwebtoken": "^9.0.6",
25 | "@types/node": "^20",
26 | "@types/react": "^18",
27 | "@types/react-dom": "^18",
28 | "autoprefixer": "^10.4.19",
29 | "eslint": "^8",
30 | "eslint-config-next": "14.2.5",
31 | "postcss": "^8.4.40",
32 | "tailwindcss": "^3.4.7",
33 | "typescript": "^5"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Analytics } from "@vercel/analytics/react";
2 | import { SpeedInsights } from "@vercel/speed-insights/next";
3 | import { Fira_Mono } from "next/font/google";
4 | import StyledJsxRegistry from "./registry";
5 | import type { Metadata } from "next";
6 | import "./globals.css";
7 |
8 | const fira = Fira_Mono({
9 | weight: ["400", "700"],
10 | subsets: ["latin"],
11 | });
12 |
13 | export const metadata: Metadata = {
14 | title: "I am the fold",
15 | description:
16 | "An experiment to show how designing for The Fold can be treacherous",
17 | };
18 |
19 | export default function RootLayout({
20 | children,
21 | }: {
22 | children: React.ReactNode;
23 | }) {
24 | return (
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/app/Fold.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useEffect, useRef, useState } from "react";
3 | import { solveWork } from "../util";
4 |
5 | const useWorker = (callback: (result: string) => void) => {
6 | const workerRef = useRef();
7 | useEffect(() => {
8 | if (!window.Worker) {
9 | console.log("No worker support");
10 | return;
11 | }
12 | workerRef.current = new Worker(new URL("../worker.js", import.meta.url));
13 | workerRef.current.onmessage = (e) => {
14 | callback(e.data);
15 | };
16 | return () => {
17 | workerRef.current?.terminate();
18 | };
19 | }, []);
20 | return workerRef;
21 | };
22 |
23 | export const Fold = () => {
24 | const [fold, setFold] = useState();
25 | const [proof, setProof] = useState();
26 | const worker = useWorker((result) => setProof(result));
27 | const [challenge, setChallenge] = useState();
28 | const [token, setToken] = useState();
29 |
30 | const saveFold = async () => {
31 | try {
32 | await fetch("/api", {
33 | method: "POST",
34 | headers: {
35 | "Content-Type": "application/json",
36 | },
37 | body: JSON.stringify({
38 | fold,
39 | proof,
40 | token,
41 | }),
42 | });
43 | } catch (e) {
44 | console.log("Error saving fold", e);
45 | }
46 | };
47 |
48 | useEffect(() => {
49 | const height = window.innerHeight;
50 | setFold(height);
51 |
52 | fetch("/api")
53 | .then((res) => res.json())
54 | .then((data) => {
55 | setChallenge(data.challenge);
56 | setToken(data.token);
57 | });
58 | }, []);
59 |
60 | useEffect(() => {
61 | if (!fold || !challenge) return;
62 | if (worker.current) {
63 | worker.current.postMessage(challenge);
64 | } else {
65 | solveWork(challenge).then((result) => setProof(result));
66 | }
67 | }, [fold, challenge]);
68 |
69 | useEffect(() => {
70 | if (!proof) return;
71 | saveFold();
72 | }, [proof]);
73 |
74 | if (!fold) {
75 | return null;
76 | }
77 |
78 | return (
79 |
83 |
86 | {fold}
87 |
88 |
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/app/api/route.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createToken,
3 | DB,
4 | verifyFold,
5 | verifyToken,
6 | verifyWork,
7 | } from "../../util";
8 | import { NextRequest, NextResponse } from "next/server";
9 | import crypto from "crypto";
10 |
11 | const db = new DB();
12 |
13 | export async function GET() {
14 | const challenge = crypto.randomBytes(50).toString("base64");
15 | const token = createToken(challenge);
16 | return NextResponse.json({ token, challenge });
17 | }
18 |
19 | export async function POST(req: NextRequest) {
20 | const { fold, token, proof } = await req.json();
21 | const forwarded = req.headers.get("x-forwarded-for");
22 | const ip = forwarded ? forwarded.split(",")[0] : req.ip;
23 |
24 | if (!fold || !token || !proof) {
25 | return NextResponse.json(
26 | { message: "Missing parameters" },
27 | { status: 400 }
28 | );
29 | }
30 |
31 | // Verify the challenge was created by this server and hasn't expired
32 | const { expired, challenge, err } = await verifyToken(token);
33 | if (err || expired) {
34 | console.log("Bad token", { err, expired });
35 | return NextResponse.json({ message: "Bad token" }, { status: 403 });
36 | }
37 |
38 | // Verify the challenge hasn't already been used
39 | if (await db.checkChallenge(challenge)) {
40 | console.log("Challenge reuse rejected", { challenge });
41 | return NextResponse.json(
42 | { message: "Challenge reuse rejected" },
43 | { status: 403 }
44 | );
45 | }
46 |
47 | // Verify that the proof-of-work checks out
48 | if (!(await verifyWork(challenge, proof))) {
49 | console.log("Challenge failed", { challenge });
50 | return NextResponse.json({ message: "Challenge failed" }, { status: 403 });
51 | }
52 |
53 | // Make sure this IP hasn't already submitted a fold
54 | if (await db.checkIP(ip)) {
55 | console.log("Fold already saved", { ip });
56 | return NextResponse.json(
57 | { message: "Fold already saved" },
58 | { status: 403 }
59 | );
60 | }
61 |
62 | // Cool! We have valid proof-of-work
63 | await db.useChallenge(challenge);
64 |
65 | // Check the submitted fold is actually valid
66 | if (verifyFold(fold)) {
67 | console.log("Invalid fold", { fold });
68 | return NextResponse.json({ message: "Invalid fold" }, { status: 400 });
69 | }
70 |
71 | // Cool! We have a valid fold and we're done here
72 | db.addFold(fold, ip);
73 | return NextResponse.json({ message: "Fold saved" });
74 | }
75 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { DB } from "../util";
4 | import { Fold } from "./Fold";
5 |
6 | const db = new DB();
7 |
8 | export default async function Page() {
9 | const folds = await db.getFoldSample();
10 | const max = Math.max(...folds);
11 |
12 | return (
13 | <>
14 |
15 | I am the fold
16 |
17 | An experiment to show how designing for The Fold can be
18 | treacherous. Each line below is a viewport height from a previous
19 | random visitor. Take care when making assumptions about people’s
20 | screen sizes on the web.
21 |
22 |
23 | Made with ❤ by{" "}
24 | @iest &{" "}
25 |
26 | friends
27 | {" "}
28 | | Born from an{" "}
29 | idea {" "}
30 | by Jordan Moore |{" "}
31 | Source on Github
32 |
33 |
34 |
35 |
39 | {folds.map((fold, i) => (
40 |
45 |
46 | {fold}
47 |
48 |
49 | ))}
50 |
51 |
52 |
53 |
54 |
55 | Brighter portions are where there are multiple similar viewports. The
56 | lines are a 1000-point sample of the full dataset.
57 |
58 |
59 | This is meant to show the diversity of viewports, not the popularity
60 | of them.
61 |
62 | Data was reset on 27th July 2024.
63 |
64 | 2015 - {new Date().getFullYear()}
65 |
66 |
67 | >
68 | );
69 | }
70 |
71 | export const revalidate = 60 * 5; // 5 minutes
72 |
--------------------------------------------------------------------------------
/util.ts:
--------------------------------------------------------------------------------
1 | import jwt, { JwtPayload } from "jsonwebtoken";
2 | import { kv } from "@vercel/kv";
3 |
4 | export const STRENGTH = 4;
5 | const SECRET = process.env.SECRET;
6 |
7 | export type ResponseData = {
8 | folds: number[];
9 | max: number;
10 | challenge: string;
11 | token: string;
12 | };
13 |
14 | interface FoldJWT extends JwtPayload {
15 | challenge: string;
16 | exp: number;
17 | }
18 |
19 | export class DB {
20 | challengeTTL = 2 * 60 * 1000; // 2 minutes in milliseconds
21 | ipTTL = 2 * 7 * 24 * 60 * 60; // 2 weeks in seconds
22 | challenges = new Set();
23 | FOLDS = "folds";
24 |
25 | async checkChallenge(challenge: string) {
26 | return this.challenges.has(challenge);
27 | }
28 | async useChallenge(challenge: string) {
29 | this.challenges.add(challenge);
30 | setTimeout(() => this.challenges.delete(challenge), this.challengeTTL);
31 | }
32 |
33 | async storeIP(ip: string) {
34 | return await kv.set(`ip:${ip}`, 1, { ex: this.ipTTL });
35 | }
36 | async checkIP(ip: string) {
37 | return await kv.exists(`ip:${ip}`);
38 | }
39 | async storeFold(fold: number) {
40 | return await kv.hincrby("folds", fold.toString(), 1);
41 | }
42 | async getAllFolds() {
43 | const folds: Record = await kv.hgetall("folds");
44 | return folds;
45 | }
46 |
47 | async getFoldArray() {
48 | const foldData = await this.getAllFolds();
49 |
50 | if (!foldData) {
51 | return [];
52 | }
53 |
54 | const folds: number[] = [];
55 |
56 | for (const [key, value] of Object.entries(foldData)) {
57 | for (let i = 0; i < value; i++) {
58 | folds.push(Number(key));
59 | }
60 | }
61 |
62 | return folds;
63 | }
64 |
65 | async getFoldSample() {
66 | const SAMPLE_SIZE = 1000;
67 | const folds = await this.getFoldArray();
68 | const uniqFolds = new Set();
69 |
70 | if (folds.length < SAMPLE_SIZE) {
71 | return folds;
72 | }
73 |
74 | while (uniqFolds.size < SAMPLE_SIZE) {
75 | const randomIndex = Math.floor(Math.random() * folds.length);
76 | uniqFolds.add(folds[randomIndex]);
77 | }
78 |
79 | return Array.from(uniqFolds);
80 | }
81 |
82 | async addFold(fold: number, ip: string) {
83 | await Promise.all([this.storeFold(fold), this.storeIP(ip)]);
84 | }
85 | }
86 |
87 | export const verifyFold = (fold: number) => {
88 | if (typeof fold !== "number") {
89 | return false;
90 | }
91 | const tallestScreen = 7680; // 8k screen
92 |
93 | return !fold || fold > tallestScreen || fold < 1;
94 | };
95 |
96 | export const verifyToken = async (token: string) => {
97 | try {
98 | const { challenge, exp } = jwt.verify(token, SECRET) as FoldJWT;
99 | return { challenge, expired: Date.now() > exp * 1000 };
100 | } catch (err) {
101 | return { err, expired: true };
102 | }
103 | };
104 | export const createToken = (challenge: string) => {
105 | return jwt.sign({ challenge }, SECRET, { expiresIn: "2m" });
106 | };
107 |
108 | async function sha256(message: string): Promise {
109 | const encoder = new TextEncoder();
110 | const data = encoder.encode(message);
111 | const hashBuffer = await crypto.subtle.digest("SHA-256", data);
112 | const hashArray = Array.from(new Uint8Array(hashBuffer));
113 | const hashHex = hashArray
114 | .map((byte) => byte.toString(16).padStart(2, "0"))
115 | .join("");
116 | return hashHex;
117 | }
118 | async function findProof(
119 | challenge: string,
120 | difficulty: number
121 | ): Promise {
122 | let proof = 0;
123 | const target = "0".repeat(difficulty);
124 |
125 | const timeoutPromise = new Promise((resolve) =>
126 | setTimeout(() => resolve(null), 10000)
127 | );
128 |
129 | const proofPromise = (async () => {
130 | while (true) {
131 | const hash = await sha256(challenge + proof);
132 | if (hash.startsWith(target)) {
133 | return proof.toString();
134 | }
135 | proof++;
136 | }
137 | })();
138 |
139 | return Promise.race([proofPromise, timeoutPromise]);
140 | }
141 |
142 | async function verifyProofOfWork(
143 | challenge: string,
144 | proof: string,
145 | difficulty: number
146 | ): Promise {
147 | const hash = await sha256(challenge + proof);
148 | return hash.startsWith("0".repeat(difficulty));
149 | }
150 |
151 | export const verifyWork = async (challenge: string, proof: string) =>
152 | verifyProofOfWork(challenge, proof, STRENGTH);
153 | export const solveWork = async (challenge: string) =>
154 | findProof(challenge, STRENGTH);
155 |
--------------------------------------------------------------------------------