├── .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 | --------------------------------------------------------------------------------