diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea86f4b..5570d9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,15 @@ jobs: uses: ./.github/actions/docker-demo with: greeting: 'hello' + + # Catches an unreadable colour before it reaches main, rather than at deploy + # time — the Pages workflow runs the same check, but only after merge. + design-system: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Check every accent theme stays readable + run: node design-system/tools/check-contrast.js diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..65a01d6 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,67 @@ +name: Deploy design system to Pages + +# Publishes design-system/ as a public website, so anyone can use the intake +# and browse the examples from a URL without cloning anything. +# +# One-time setup: repo Settings → Pages → Source: "GitHub Actions". +# Until that is switched on this workflow builds but cannot deploy. + +on: + push: + branches: [main] + paths: + - 'design-system/**' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Let a running deploy finish rather than cancelling it mid-publish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check the site has no external dependencies + run: | + # The system is meant to be self-contained. A stray CDN link would + # break it offline and behind strict CSP, so fail the build instead. + if grep -rIn --include='*.html' -E '(src|href)="https?://' design-system/ ; then + echo "::error::Found an external asset reference in design-system/" + exit 1 + fi + echo "No external asset references." + + - name: Check every accent theme stays readable + run: node design-system/tools/check-contrast.js + + - name: Verify entry points exist + run: | + for f in index.html examples/picker.html examples/deck.html \ + examples/landing.html examples/intake-standalone.html; do + test -f "design-system/$f" || { echo "::error::missing design-system/$f"; exit 1; } + done + echo "All entry points present." + + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: design-system + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 05b7fc9..0b05976 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,24 @@ committing, and opening a pull request. This change was made from a remote Claude Code session to confirm that kicking off work from outside the terminal (web/mobile) works end to end. +## Design system + +`design-system/` holds my house style, codified so a website, a PowerPoint +template, or anything I commission comes back in the same format. + +- [`design-system/DESIGN-BRIEF.md`](design-system/DESIGN-BRIEF.md) — the spec to + hand a designer or an AI tool +- [`design-system/tokens/`](design-system/tokens) — colour, type, space and + radius tokens as JSON and CSS custom properties +- [`design-system/css/`](design-system/css) — the component and slide layer +- [`design-system/powerpoint/SPEC.md`](design-system/powerpoint/SPEC.md) — point + sizes and inch positions for building a `.potx` +- [`design-system/examples/`](design-system/examples) — a web landing page and a + 14-slide deck, both built from the system; open either in a browser + +Six accent themes ship with it (amber, electric, navy, teal, green, coral) and +swap with a single class on ``. + ## Agent A minimal Claude Agent SDK (TypeScript) starter lives in `agent.ts`. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..a933f10 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +.dev.vars diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..35349f1 --- /dev/null +++ b/app/README.md @@ -0,0 +1,197 @@ +# The online service + +Accounts, saved projects and server-built downloads for the design system. + +Registration is live. **Downloads are free** — payment is deliberately not +built yet. Every download is counted from day one, because a usage count is the +one thing that cannot be added retrospectively: turn charging on in six months +and you still need to know what everyone already took. + +--- + +## What runs where + +| | | +|---|---| +| **Pages** | `design-system/` served as static assets | +| **API** | A Cloudflare Worker owning `/auth/*` and `/api/*` | +| **Data** | D1 (SQLite) — users, projects, downloads | +| **Files** | Built on demand from the stored answers; nothing is kept | + +Files are rebuilt rather than stored, so improving the generator improves every +past project, not only new ones. + +--- + +## Running it locally + +```bash +cd app +npm install +npm run db:local # create the tables +npm run dev # http://localhost:8787 +``` + +With no mail provider configured, the sign-in route returns the link in its own +response and the page shows it. That happens **only** when `ENVIRONMENT` is +`development` — never in production. + +--- + +## Going live + +### 1. A Cloudflare account + +```bash +npx wrangler login +npx wrangler d1 create design-system +``` + +Put the printed `database_id` into `wrangler.toml`, then create the tables: + +```bash +npm run db:remote +npm run deploy +``` + +That is already a working site: people can register by email, build a project +and download it. + +### 2. Email, so sign-in links arrive + +Without this, nobody can sign in — the link is generated but never sent. + +1. Create a [Resend](https://resend.com) account (free tier is ample) and verify + your sending domain. +2. `npx wrangler secret put RESEND_API_KEY` +3. Set `MAIL_FROM` in `wrangler.toml` to an address on that domain. + +### 3. Google sign-in *(optional)* + +1. Google Cloud console → APIs & Services → Credentials → **OAuth client ID**, + type *Web application*. +2. Authorised redirect URI: `https://yourdomain.com/auth/google/callback` +3. `npx wrangler secret put GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` + +### 4. Apple sign-in *(optional, and the fiddly one)* + +Apple needs a **paid Apple Developer account (~$99/year)**. Worth knowing before +you start, because nothing here works without it. + +1. Register an **App ID**, then a **Services ID** (this is your `APPLE_CLIENT_ID`, + e.g. `com.yourcompany.designsystem.web`). +2. Configure the Services ID: domain `yourdomain.com`, return URL + `https://yourdomain.com/auth/apple/callback`. +3. Create a **Sign in with Apple key** and download the `.p8`. **You can only + download it once.** +4. Set four secrets: + ```bash + npx wrangler secret put APPLE_CLIENT_ID # the Services ID + npx wrangler secret put APPLE_TEAM_ID # Membership page + npx wrangler secret put APPLE_KEY_ID # the key's ID + npx wrangler secret put APPLE_PRIVATE_KEY # the whole .p8 contents + ``` + +Apple only ever sends someone's **name on their first authorisation** — miss it +and it is gone permanently. That is handled, but it is why an Apple user may +have no name where a Google user does. + +**A provider with no credentials simply does not appear on the sign-in page.** +A button that leads to a configuration error is worse than no button. + +--- + +## Turning charging on later + +The check is already written. Switching it on is configuration, not a rewrite. + +```toml +PAYWALL = "on" # in wrangler.toml +``` + +That alone enforces the free allowance: `FREE_DOWNLOADS` in `src/index.js` (2), +then `402 payment_required`. What is still missing is the part that takes money: + +1. **Pick a provider.** For selling internationally, a merchant of record + (Lemon Squeezy, Paddle) is the seller of record and handles VAT in every + country. Stripe is cheaper but leaves tax registration and filing to you. +2. **Add a checkout route** that sends people to the provider's hosted page. + Do not build a card form — see [`SELLING.md`](../design-system/SELLING.md). +3. **Add a webhook** that verifies the provider's signature and writes one row + into `entitlements`: + - a subscription → `kind='subscription'` with `ends_at` + - a single template → `kind='purchase'` with the `brief_id` + +The download check already reads that table first and falls back to the free +allowance, so nothing else changes. This has been tested: with the paywall on +and the allowance spent, inserting a subscription row lets the download through +and records it as `included`. + +### Holding files until you have checked them + +You mentioned confirming a job needs no further work before the files go out. +That is built and off by default: + +```toml +REVIEW_BEFORE_RELEASE = "on" +``` + +Downloads then return `409` with "we're checking your files over" until a brief +reaches `status = 'released'`. **The admin screen to do the releasing is not +built yet** — today you would set it in SQL. Leave this `off` until it is. + +--- + +## Configuration + +| Setting | Where | Default | What it does | +|---|---|---|---| +| `PAYWALL` | `wrangler.toml` | `off` | Enforce the free-download allowance | +| `REVIEW_BEFORE_RELEASE` | `wrangler.toml` | `off` | Hold files until released | +| `SUPPORT_EMAIL` | `wrangler.toml` | — | Printed in the delivered README | +| `RESEND_API_KEY` | secret | — | Sends the sign-in email | +| `GOOGLE_CLIENT_ID` / `_SECRET` | secret | — | Enables Google | +| `APPLE_CLIENT_ID` / `_TEAM_ID` / `_KEY_ID` / `_PRIVATE_KEY` | secret | — | Enables Apple | + +Secrets never go in `wrangler.toml` — it is committed. + +--- + +## How the security holds up + +Each of these is verified by a test against a running Worker, not by argument: + +- **The download is server-only.** It is a `POST`, it assembles the package from + stored answers, and it is the only place a package exists. A page that builds + its own zip can never be gated, which is why generation moved server-side. +- **Sessions and sign-in tokens are stored as SHA-256 hashes.** A leaked backup + yields nothing presentable. +- **One-shot tokens are read and burned in one statement**, so the same link + cannot be used twice — including by two requests racing. +- **ID tokens are fully verified**: signature against the provider's JWKS, + issuer, audience, expiry and nonce. A decoded JWT proves nothing on its own. +- **Email matching requires the provider to have verified it**, so asserting + someone's address at a lax provider cannot claim their account. +- **Customer text is escaped** wherever it reaches markup, and a logo `src` must + be a `data:image` URL. Briefs are stored and later rendered where someone else + is signed in, so this is a real boundary rather than self-inflicted. +- **Cross-origin state changes are rejected** on `Origin`, backing up + `SameSite=Lax`. +- **Another account's project returns 404**, not 403 — no confirmation that an + id exists. + +--- + +## Still to build + +Named honestly, because they are the difference between this and a finished +product: + +- **Taking money.** Everything around it is in place; the provider is not. +- **An admin screen** for releasing files, needed before + `REVIEW_BEFORE_RELEASE` is any use. +- **Account deletion.** People can sign up, so they must be able to leave and + take their data with them. `ON DELETE CASCADE` is in the schema; the route is + not. +- **A privacy policy and terms**, before real customers — you are storing + personal data the moment someone registers. diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..b0add08 --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,1583 @@ +{ + "name": "design-system-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "design-system-app", + "version": "0.1.0", + "devDependencies": { + "wrangler": "^3.90.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..a2d2933 --- /dev/null +++ b/app/package.json @@ -0,0 +1,18 @@ +{ + "name": "design-system-app", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Accounts, saved projects and server-built downloads for the design system.", + "scripts": { + "styles": "node tools/bundle-styles.js", + "db:local": "wrangler d1 execute design-system --local --file=schema.sql", + "db:remote": "wrangler d1 execute design-system --remote --file=schema.sql", + "dev": "npm run styles && wrangler dev --env development --local --port 8787", + "deploy": "npm run styles && wrangler deploy", + "check": "node tools/bundle-styles.js --check" + }, + "devDependencies": { + "wrangler": "^3.90.0" + } +} diff --git a/app/schema.sql b/app/schema.sql new file mode 100644 index 0000000..1862e58 --- /dev/null +++ b/app/schema.sql @@ -0,0 +1,177 @@ +-- Schema for the design system service (Cloudflare D1 / SQLite). +-- +-- Apply with: +-- npx wrangler d1 execute design-system --local --file=app/schema.sql +-- npx wrangler d1 execute design-system --remote --file=app/schema.sql +-- +-- Two things shape this file. First, one person is one account no matter how +-- many ways they sign in — so identities hang off users rather than replacing +-- them. Second, payment is deliberately not built yet, but the tables that +-- record entitlement and usage are, because retrofitting a usage count after +-- the fact means you cannot answer "how many did this person already take?" +-- for anyone who signed up before you started counting. + +PRAGMA foreign_keys = ON; + +-- --------------------------------------------------------------------------- +-- People +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, -- uuid + email TEXT NOT NULL, + email_norm TEXT NOT NULL, -- lowercased+trimmed; the identity key + name TEXT, + created_at INTEGER NOT NULL, -- epoch ms + last_seen_at INTEGER, + -- Set by hand for the people who review deliveries. No self-service path to + -- it, so an ordinary sign-in can never escalate into one. + is_admin INTEGER NOT NULL DEFAULT 0, + blocked_at INTEGER +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_email_norm ON users(email_norm); + +-- One row per way a person can sign in. Google, Apple and email magic link all +-- land here, so signing in with Google and later with the same address by link +-- is the same account rather than a duplicate with a separate free allowance. +CREATE TABLE IF NOT EXISTS identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, -- 'google' | 'apple' | 'email' + subject TEXT NOT NULL, -- provider's stable user id + email TEXT, + created_at INTEGER NOT NULL, + UNIQUE (provider, subject) +); + +CREATE INDEX IF NOT EXISTS identities_user ON identities(user_id); + +-- --------------------------------------------------------------------------- +-- Sessions and sign-in tokens +-- --------------------------------------------------------------------------- + +-- Only a hash is stored. A leaked database backup then yields no usable +-- session cookie, which is the whole reason not to store the token itself. +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, -- sha-256 of the cookie value + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + user_agent TEXT, + revoked_at INTEGER +); + +CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires_at); + +-- Magic links and OAuth state, same table because both are one-shot secrets +-- with a short life that must not be replayable. +CREATE TABLE IF NOT EXISTS login_tokens ( + id TEXT PRIMARY KEY, -- sha-256 of the token + kind TEXT NOT NULL, -- 'magic' | 'oauth_state' + email_norm TEXT, -- magic links only + payload TEXT, -- oauth: JSON {provider, redirect, verifier, nonce} + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER, + -- Cheap brute-force ceiling for the six-digit fallback code. + attempts INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS login_tokens_expiry ON login_tokens(expires_at); +CREATE INDEX IF NOT EXISTS login_tokens_email ON login_tokens(email_norm); + +-- --------------------------------------------------------------------------- +-- The work +-- --------------------------------------------------------------------------- + +-- A brief is the customer's answers. The generated files are not stored: they +-- are rebuilt from the answers on demand, so a fix to the generator improves +-- every past project rather than only new ones. +CREATE TABLE IF NOT EXISTS briefs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT, -- the customer's own name for it + kind TEXT, -- website | landing | deck | portfolio + pal_id TEXT, + answers TEXT NOT NULL, -- JSON, exactly what the generator takes + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + -- 'draft' while they are still answering, 'approved' once they confirm the + -- page is right, 'released' once we have checked it needs no further work. + status TEXT NOT NULL DEFAULT 'draft', + approved_at INTEGER, + released_at INTEGER, + released_by TEXT REFERENCES users(id), + review_note TEXT, + deleted_at INTEGER +); + +CREATE INDEX IF NOT EXISTS briefs_user ON briefs(user_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS briefs_status ON briefs(status, updated_at DESC); + +-- Every completed download, one row. This is what the free allowance is +-- counted from, and it is written inside the same transaction that authorises +-- the download so a burst of parallel requests cannot each see an old count. +CREATE TABLE IF NOT EXISTS downloads ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + brief_id TEXT NOT NULL REFERENCES briefs(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + bytes INTEGER, + -- How this one was paid for: 'free' while the allowance lasts, later + -- 'included' (subscription) or 'purchase' (one-off). + basis TEXT NOT NULL DEFAULT 'free' +); + +CREATE INDEX IF NOT EXISTS downloads_user ON downloads(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS downloads_brief ON downloads(brief_id); + +-- --------------------------------------------------------------------------- +-- Entitlement — the shape payment will fill in +-- --------------------------------------------------------------------------- + +-- Nothing writes 'subscription' or 'purchase' rows yet. The table exists so +-- that when a provider is chosen, its webhook has somewhere to land and the +-- download check does not have to change: it already asks this table first and +-- falls back to the free allowance. +CREATE TABLE IF NOT EXISTS entitlements ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- 'subscription' | 'purchase' | 'grant' + brief_id TEXT REFERENCES briefs(id) ON DELETE SET NULL, -- purchases only + provider TEXT, -- 'stripe' | 'lemonsqueezy' | null + provider_ref TEXT, -- their id, for reconciliation + starts_at INTEGER NOT NULL, + ends_at INTEGER, -- null = does not expire + cancelled_at INTEGER, + created_at INTEGER NOT NULL, + UNIQUE (provider, provider_ref) +); + +CREATE INDEX IF NOT EXISTS entitlements_user ON entitlements(user_id, kind); + +-- --------------------------------------------------------------------------- +-- Operational +-- --------------------------------------------------------------------------- + +-- Rate limiting for the endpoints that send email or mint sessions. Keyed by +-- whatever the route decides to bucket on (IP, email), swept by expiry. +CREATE TABLE IF NOT EXISTS rate_limits ( + bucket TEXT PRIMARY KEY, + count INTEGER NOT NULL, + window_start INTEGER NOT NULL +); + +-- A plain record of things worth being able to reconstruct later: who signed +-- in, what was released, what was refused. +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + at INTEGER NOT NULL, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + detail TEXT +); + +CREATE INDEX IF NOT EXISTS audit_at ON audit_log(at DESC); diff --git a/app/src/auth.js b/app/src/auth.js new file mode 100644 index 0000000..b48c10d --- /dev/null +++ b/app/src/auth.js @@ -0,0 +1,356 @@ +/* The three ways in: Google, Apple, and an emailed link. + * + * All three converge on findOrCreateUser, so the account is the person rather + * than the button they happened to press. + */ + +import { verifyIdToken, appleClientSecret, pkcePair } from './jwt.js'; +import { + findOrCreateUser, createSession, revokeSession, storeLoginToken, consumeLoginToken, + normaliseEmail, randomToken, hitRateLimit, audit, now, +} from './db.js'; +import { sendMagicLink } from './email.js'; + +const SESSION_COOKIE = 'ds_session'; + +export function sessionCookie(token, { secure, maxAgeDays = 30 } = {}) { + const parts = [ + `${SESSION_COOKIE}=${token}`, + 'Path=/', + 'HttpOnly', // script must never be able to read it + 'SameSite=Lax', // survives the OAuth redirect back to us + `Max-Age=${maxAgeDays * 86400}`, + ]; + if (secure) parts.push('Secure'); // omitted only for http://localhost in dev + return parts.join('; '); +} + +export const clearCookie = ({ secure }) => + `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? '; Secure' : ''}`; + +export function readCookie(request, name = SESSION_COOKIE) { + const header = request.headers.get('Cookie') || ''; + for (const part of header.split(';')) { + const [k, ...v] = part.trim().split('='); + if (k === name) return v.join('='); + } + return null; +} + +const isSecure = url => url.protocol === 'https:'; + +const redirect = (to, headers = {}) => + new Response(null, { status: 302, headers: { Location: to, ...headers } }); + +/* Only ever send people back inside this site. An open redirect here would let + * a phishing link borrow our domain and land the visitor somewhere else. */ +function safeReturnTo(value) { + const v = String(value || '/'); + return /^\/(?!\/)/.test(v) ? v : '/'; +} + +/* ---- Google --------------------------------------------------------------- + Authorization code + PKCE. We ask only for the identity scopes; there is + nothing in a Google account this service needs beyond who they are. ------- */ + +const GOOGLE = { + auth: 'https://accounts.google.com/o/oauth2/v2/auth', + token: 'https://oauth2.googleapis.com/token', + jwks: 'https://www.googleapis.com/oauth2/v3/certs', + issuers: ['https://accounts.google.com', 'accounts.google.com'], +}; + +export async function googleStart(request, env, url) { + if (!env.GOOGLE_CLIENT_ID) return providerUnavailable('Google'); + + const { verifier, challenge } = await pkcePair(); + const state = randomToken(16); + const nonce = randomToken(16); + + await storeLoginToken(env.DB, { + token: state, kind: 'oauth_state', minutes: 10, + payload: { provider: 'google', verifier, nonce, returnTo: safeReturnTo(url.searchParams.get('returnTo')) }, + }); + + const authorise = new URL(GOOGLE.auth); + authorise.search = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: `${url.origin}/auth/google/callback`, + response_type: 'code', + scope: 'openid email profile', + state, nonce, + code_challenge: challenge, + code_challenge_method: 'S256', + prompt: 'select_account', + }).toString(); + + return redirect(authorise.toString()); +} + +export async function googleCallback(request, env, url) { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + if (!code || !state) return signInFailed(url, 'Google did not return a sign-in code.'); + + const stored = await consumeLoginToken(env.DB, { token: state, kind: 'oauth_state' }); + if (!stored || stored.payload?.provider !== 'google') { + return signInFailed(url, 'That sign-in link has expired. Please try again.'); + } + + const res = await fetch(GOOGLE.token, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: `${url.origin}/auth/google/callback`, + grant_type: 'authorization_code', + code_verifier: stored.payload.verifier, + }), + }); + if (!res.ok) return signInFailed(url, 'Google would not complete the sign-in.'); + + const { id_token } = await res.json(); + let claims; + try { + claims = await verifyIdToken(id_token, { + jwksUrl: GOOGLE.jwks, issuer: GOOGLE.issuers, + audience: env.GOOGLE_CLIENT_ID, nonce: stored.payload.nonce, + }); + } catch (err) { + await audit(env.DB, { action: 'signin.rejected', detail: { provider: 'google', reason: err.message } }); + return signInFailed(url, 'We could not verify that sign-in.'); + } + + // An unverified address must not be allowed to claim an existing account. + if (claims.email && claims.email_verified !== true && claims.email_verified !== 'true') { + return signInFailed(url, 'Google has not verified that email address.'); + } + + return completeSignIn(request, env, url, { + provider: 'google', subject: claims.sub, email: claims.email, + name: claims.name, emailVerified: true, returnTo: stored.payload.returnTo, + }); +} + +/* ---- Apple ---------------------------------------------------------------- + Apple posts the result back as a form rather than a query string, and only + ever sends the person's name on the very first authorisation — so it is + taken from the form when present and never expected again. --------------- */ + +const APPLE = { + auth: 'https://appleid.apple.com/auth/authorize', + token: 'https://appleid.apple.com/auth/token', + jwks: 'https://appleid.apple.com/auth/keys', + issuer: 'https://appleid.apple.com', +}; + +export async function appleStart(request, env, url) { + if (!env.APPLE_CLIENT_ID) return providerUnavailable('Apple'); + + const state = randomToken(16); + const nonce = randomToken(16); + await storeLoginToken(env.DB, { + token: state, kind: 'oauth_state', minutes: 10, + payload: { provider: 'apple', nonce, returnTo: safeReturnTo(url.searchParams.get('returnTo')) }, + }); + + const authorise = new URL(APPLE.auth); + authorise.search = new URLSearchParams({ + client_id: env.APPLE_CLIENT_ID, + redirect_uri: `${url.origin}/auth/apple/callback`, + response_type: 'code id_token', + response_mode: 'form_post', // required once scopes are requested + scope: 'name email', + state, nonce, + }).toString(); + + return redirect(authorise.toString()); +} + +export async function appleCallback(request, env, url) { + const form = await request.formData(); + const state = form.get('state'); + const code = form.get('code'); + if (!state || !code) return signInFailed(url, 'Apple did not return a sign-in code.'); + + const stored = await consumeLoginToken(env.DB, { token: String(state), kind: 'oauth_state' }); + if (!stored || stored.payload?.provider !== 'apple') { + return signInFailed(url, 'That sign-in link has expired. Please try again.'); + } + + let secret; + try { + secret = await appleClientSecret({ + teamId: env.APPLE_TEAM_ID, clientId: env.APPLE_CLIENT_ID, + keyId: env.APPLE_KEY_ID, privateKeyPem: env.APPLE_PRIVATE_KEY, + }); + } catch (err) { + await audit(env.DB, { action: 'signin.misconfigured', detail: { provider: 'apple', reason: err.message } }); + return signInFailed(url, 'Apple sign-in is not configured correctly.'); + } + + const res = await fetch(APPLE.token, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code: String(code), + client_id: env.APPLE_CLIENT_ID, + client_secret: secret, + redirect_uri: `${url.origin}/auth/apple/callback`, + grant_type: 'authorization_code', + }), + }); + if (!res.ok) return signInFailed(url, 'Apple would not complete the sign-in.'); + + const { id_token } = await res.json(); + let claims; + try { + claims = await verifyIdToken(id_token, { + jwksUrl: APPLE.jwks, issuer: APPLE.issuer, + audience: env.APPLE_CLIENT_ID, nonce: stored.payload.nonce, + }); + } catch (err) { + await audit(env.DB, { action: 'signin.rejected', detail: { provider: 'apple', reason: err.message } }); + return signInFailed(url, 'We could not verify that sign-in.'); + } + + if (claims.email && claims.email_verified !== true && claims.email_verified !== 'true') { + return signInFailed(url, 'Apple has not verified that email address.'); + } + + // Sent once, on first authorisation only. Miss it and it is gone for good. + let name = null; + try { + const raw = form.get('user'); + if (raw) { + const parsed = JSON.parse(String(raw)); + name = [parsed?.name?.firstName, parsed?.name?.lastName].filter(Boolean).join(' ') || null; + } + } catch { /* a malformed extra is not a reason to fail the sign-in */ } + + return completeSignIn(request, env, url, { + provider: 'apple', subject: claims.sub, email: claims.email, + name, emailVerified: Boolean(claims.email), returnTo: stored.payload.returnTo, + }); +} + +/* ---- Magic link ----------------------------------------------------------- + No password to store, forget, reset, or leak. The link carries a one-shot + token; the six-digit code is the same token by another route, for people + who open their mail on a different device. ------------------------------- */ + +export async function magicRequest(request, env, url) { + const body = await request.json().catch(() => ({})); + const email = normaliseEmail(body.email); + const returnTo = safeReturnTo(body.returnTo); + + if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) { + return json({ error: 'That does not look like an email address.' }, 400); + } + + const ip = request.headers.get('CF-Connecting-IP') || 'local'; + for (const [bucket, limit, windowMs] of [ + [`magic:ip:${ip}`, 10, 15 * 60_000], + [`magic:email:${email}`, 5, 15 * 60_000], + ]) { + const gate = await hitRateLimit(env.DB, bucket, { limit, windowMs }); + if (!gate.allowed) { + return json({ error: 'Too many sign-in emails. Try again shortly.' }, 429, + { 'Retry-After': String(gate.retryAfter || 60) }); + } + } + + const token = randomToken(24); + const code = String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, '0'); + await storeLoginToken(env.DB, { token, kind: 'magic', emailNorm: email, minutes: 15, payload: { code, returnTo } }); + await storeLoginToken(env.DB, { token: `${email}:${code}`, kind: 'magic', emailNorm: email, minutes: 15, payload: { returnTo } }); + + const link = `${url.origin}/auth/magic/callback?token=${token}`; + const sent = await sendMagicLink(env, { to: email, link, code }); + + /* The same answer either way. Telling callers which addresses exist, or + * which sends failed, hands them an account-enumeration oracle. */ + await audit(env.DB, { action: 'signin.magic.requested', detail: { email, delivered: sent.ok } }); + + return json({ + ok: true, + message: 'If that address can receive mail, a sign-in link is on its way.', + // In development there is no mail provider, so surface the link rather + // than making it undiscoverable. Never enabled in production. + devLink: env.ENVIRONMENT === 'development' ? link : undefined, + devCode: env.ENVIRONMENT === 'development' ? code : undefined, + }); +} + +export async function magicCallback(request, env, url) { + const token = url.searchParams.get('token'); + const stored = await consumeLoginToken(env.DB, { token: String(token || ''), kind: 'magic' }); + if (!stored) return signInFailed(url, 'That link has expired or has already been used.'); + + return completeSignIn(request, env, url, { + provider: 'email', subject: stored.email_norm, email: stored.email_norm, + emailVerified: true, returnTo: stored.payload?.returnTo, + }); +} + +export async function magicVerifyCode(request, env, url) { + const body = await request.json().catch(() => ({})); + const email = normaliseEmail(body.email); + const code = String(body.code || '').trim(); + + const ip = request.headers.get('CF-Connecting-IP') || 'local'; + const gate = await hitRateLimit(env.DB, `code:${ip}:${email}`, { limit: 8, windowMs: 15 * 60_000 }); + if (!gate.allowed) return json({ error: 'Too many attempts. Try again shortly.' }, 429); + + const stored = await consumeLoginToken(env.DB, { token: `${email}:${code}`, kind: 'magic' }); + if (!stored) return json({ error: 'That code is wrong or has expired.' }, 400); + + const user = await findOrCreateUser(env.DB, { + provider: 'email', subject: email, email, emailVerified: true, + }); + const session = await createSession(env.DB, user.id, { userAgent: request.headers.get('User-Agent') }); + await audit(env.DB, { userId: user.id, action: 'signin', detail: { provider: 'email', via: 'code' } }); + + return json({ ok: true, user: publicUser(user) }, 200, + { 'Set-Cookie': sessionCookie(session, { secure: isSecure(url) }) }); +} + +/* ---- Shared tail ---------------------------------------------------------- */ + +async function completeSignIn(request, env, url, { provider, subject, email, name, emailVerified, returnTo }) { + const user = await findOrCreateUser(env.DB, { provider, subject, email, name, emailVerified }); + if (user.blocked_at) return signInFailed(url, 'That account is not available.'); + + const session = await createSession(env.DB, user.id, { userAgent: request.headers.get('User-Agent') }); + await audit(env.DB, { userId: user.id, action: 'signin', detail: { provider } }); + + return redirect(safeReturnTo(returnTo), { + 'Set-Cookie': sessionCookie(session, { secure: isSecure(url) }), + }); +} + +export async function signOut(request, env, url) { + const token = readCookie(request); + await revokeSession(env.DB, token); + return json({ ok: true }, 200, { 'Set-Cookie': clearCookie({ secure: isSecure(url) }) }); +} + +export const publicUser = user => ({ + id: user.id, email: user.email, name: user.name, isAdmin: Boolean(user.is_admin), +}); + +const providerUnavailable = which => + json({ error: `${which} sign-in is not configured on this deployment.` }, 501); + +/* Failures land back on the sign-in page with something a person can read, + * never with the underlying reason — which would only help someone probing. */ +const signInFailed = (url, message) => + redirect(`/signin?error=${encodeURIComponent(message)}`); + +export const json = (data, status = 200, headers = {}) => + new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers }, + }); diff --git a/app/src/db.js b/app/src/db.js new file mode 100644 index 0000000..08aaafc --- /dev/null +++ b/app/src/db.js @@ -0,0 +1,237 @@ +/* Every statement that touches D1 lives here, so the routes read as intent + * rather than SQL and there is one place to check when the schema moves. */ + +export const now = () => Date.now(); +export const uuid = () => crypto.randomUUID(); + +/* Tokens are compared by hash, never stored raw: a leaked backup then yields + * nothing a caller could present as a valid session. */ +export async function sha256(text) { + const bytes = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join(''); +} + +export function randomToken(bytes = 32) { + const buf = new Uint8Array(bytes); + crypto.getRandomValues(buf); + return [...buf].map(b => b.toString(16).padStart(2, '0')).join(''); +} + +export const normaliseEmail = email => String(email || '').trim().toLowerCase(); + +/* ---- Users and identities ------------------------------------------------- */ + +/* Resolve a sign-in to an account. The provider identity wins if we have seen + * it before; otherwise the verified email address decides, which is what makes + * "sign in with Google, then later with a magic link" one account and one free + * allowance rather than two. + * + * Only call this with an email the provider actually verified — matching on an + * unverified address would let anyone claim an existing account by asserting + * its address at a provider that never checked. */ +export async function findOrCreateUser(db, { provider, subject, email, name, emailVerified }) { + const emailNorm = normaliseEmail(email); + + const existing = await db.prepare( + 'SELECT user_id FROM identities WHERE provider = ? AND subject = ?') + .bind(provider, subject).first(); + + if (existing) { + await db.prepare('UPDATE users SET last_seen_at = ? WHERE id = ?') + .bind(now(), existing.user_id).run(); + return getUser(db, existing.user_id); + } + + let user = emailNorm && emailVerified + ? await db.prepare('SELECT * FROM users WHERE email_norm = ?').bind(emailNorm).first() + : null; + + if (!user) { + const id = uuid(); + await db.prepare( + `INSERT INTO users (id, email, email_norm, name, created_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?)`) + .bind(id, email || '', emailNorm, name || null, now(), now()).run(); + user = await getUser(db, id); + } else { + await db.prepare('UPDATE users SET last_seen_at = ?, name = COALESCE(name, ?) WHERE id = ?') + .bind(now(), name || null, user.id).run(); + } + + await db.prepare( + `INSERT OR IGNORE INTO identities (id, user_id, provider, subject, email, created_at) + VALUES (?, ?, ?, ?, ?, ?)`) + .bind(uuid(), user.id, provider, subject, email || null, now()).run(); + + return getUser(db, user.id); +} + +export const getUser = (db, id) => + db.prepare('SELECT * FROM users WHERE id = ?').bind(id).first(); + +/* ---- Sessions ------------------------------------------------------------- */ + +export async function createSession(db, userId, { userAgent, days = 30 } = {}) { + const token = randomToken(); + await db.prepare( + `INSERT INTO sessions (id, user_id, created_at, expires_at, user_agent) + VALUES (?, ?, ?, ?, ?)`) + .bind(await sha256(token), userId, now(), now() + days * 86400_000, + (userAgent || '').slice(0, 200)).run(); + return token; +} + +export async function userForSession(db, token) { + if (!token) return null; + const row = await db.prepare( + `SELECT u.* FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.id = ? AND s.revoked_at IS NULL AND s.expires_at > ? + AND u.blocked_at IS NULL`) + .bind(await sha256(token), now()).first(); + return row || null; +} + +export async function revokeSession(db, token) { + if (!token) return; + await db.prepare('UPDATE sessions SET revoked_at = ? WHERE id = ?') + .bind(now(), await sha256(token)).run(); +} + +/* ---- One-shot login tokens ------------------------------------------------ */ + +export async function storeLoginToken(db, { token, kind, emailNorm, payload, minutes = 15 }) { + await db.prepare( + `INSERT INTO login_tokens (id, kind, email_norm, payload, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`) + .bind(await sha256(token), kind, emailNorm || null, + payload ? JSON.stringify(payload) : null, now(), now() + minutes * 60_000).run(); +} + +/* Consume is deliberately one call: reading and burning a one-shot secret in + * separate steps leaves a window where the same link works twice. */ +export async function consumeLoginToken(db, { token, kind }) { + const id = await sha256(token); + const row = await db.prepare( + `SELECT * FROM login_tokens + WHERE id = ? AND kind = ? AND consumed_at IS NULL AND expires_at > ?`) + .bind(id, kind, now()).first(); + if (!row) return null; + + const burned = await db.prepare( + 'UPDATE login_tokens SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL') + .bind(now(), id).run(); + // If another request burned it between the read and here, it is not ours. + if (!burned.meta.changes) return null; + + return { ...row, payload: row.payload ? JSON.parse(row.payload) : null }; +} + +/* ---- Briefs --------------------------------------------------------------- */ + +export async function saveBrief(db, { id, userId, title, kind, palId, answers }) { + const json = JSON.stringify(answers); + if (id) { + const res = await db.prepare( + `UPDATE briefs SET title = ?, kind = ?, pal_id = ?, answers = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL AND status <> 'released'`) + .bind(title || null, kind || null, palId || null, json, now(), id, userId).run(); + if (res.meta.changes) return getBrief(db, id, userId); + } + const newId = uuid(); + await db.prepare( + `INSERT INTO briefs (id, user_id, title, kind, pal_id, answers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .bind(newId, userId, title || null, kind || null, palId || null, json, now(), now()).run(); + return getBrief(db, newId, userId); +} + +export const getBrief = (db, id, userId) => + db.prepare('SELECT * FROM briefs WHERE id = ? AND user_id = ? AND deleted_at IS NULL') + .bind(id, userId).first(); + +export const listBriefs = (db, userId) => + db.prepare( + `SELECT id, title, kind, pal_id, status, created_at, updated_at, approved_at, released_at + FROM briefs WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 50`) + .bind(userId).all(); + +export const approveBrief = (db, id, userId) => + db.prepare( + `UPDATE briefs SET status = 'approved', approved_at = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL AND status = 'draft'`) + .bind(now(), now(), id, userId).run(); + +/* ---- Downloads and the allowance ------------------------------------------ */ + +export const countDownloads = async (db, userId) => + (await db.prepare('SELECT COUNT(*) AS n FROM downloads WHERE user_id = ?') + .bind(userId).first()).n; + +/* Has this brief already been paid for or taken? A second copy of something + * they already have should never consume another allowance. */ +export const alreadyDownloaded = async (db, userId, briefId) => + Boolean(await db.prepare( + 'SELECT 1 FROM downloads WHERE user_id = ? AND brief_id = ? LIMIT 1') + .bind(userId, briefId).first()); + +export async function recordDownload(db, { userId, briefId, bytes, basis }) { + await db.prepare( + `INSERT INTO downloads (id, user_id, brief_id, created_at, bytes, basis) + VALUES (?, ?, ?, ?, ?, ?)`) + .bind(uuid(), userId, briefId, now(), bytes || null, basis || 'free').run(); +} + +/* An active subscription, or a purchase of this particular brief. Nothing + * writes these rows yet — the paywall is off — but the download check asks + * here first, so switching payment on is a matter of filling this table. */ +export async function entitlementFor(db, userId, briefId) { + const sub = await db.prepare( + `SELECT * FROM entitlements + WHERE user_id = ? AND kind IN ('subscription','grant') AND cancelled_at IS NULL + AND starts_at <= ? AND (ends_at IS NULL OR ends_at > ?) LIMIT 1`) + .bind(userId, now(), now()).first(); + if (sub) return { basis: 'included', entitlement: sub }; + + const one = await db.prepare( + `SELECT * FROM entitlements + WHERE user_id = ? AND kind = 'purchase' AND brief_id = ? AND cancelled_at IS NULL LIMIT 1`) + .bind(userId, briefId).first(); + if (one) return { basis: 'purchase', entitlement: one }; + + return null; +} + +/* ---- Rate limiting -------------------------------------------------------- */ + +/* A fixed window, which is coarse but honest: it is here to stop someone + * pumping the email sender or guessing codes, not to shape traffic. */ +export async function hitRateLimit(db, bucket, { limit, windowMs }) { + const start = now() - windowMs; + const row = await db.prepare('SELECT * FROM rate_limits WHERE bucket = ?').bind(bucket).first(); + + if (!row || row.window_start < start) { + await db.prepare( + `INSERT INTO rate_limits (bucket, count, window_start) VALUES (?, 1, ?) + ON CONFLICT(bucket) DO UPDATE SET count = 1, window_start = excluded.window_start`) + .bind(bucket, now()).run(); + return { allowed: true, remaining: limit - 1 }; + } + + if (row.count >= limit) { + return { allowed: false, retryAfter: Math.ceil((row.window_start + windowMs - now()) / 1000) }; + } + + await db.prepare('UPDATE rate_limits SET count = count + 1 WHERE bucket = ?').bind(bucket).run(); + return { allowed: true, remaining: limit - row.count - 1 }; +} + +/* ---- Audit ---------------------------------------------------------------- */ + +export async function audit(db, { userId, action, detail }) { + await db.prepare( + 'INSERT INTO audit_log (id, at, user_id, action, detail) VALUES (?, ?, ?, ?, ?)') + .bind(uuid(), now(), userId || null, action, + typeof detail === 'string' ? detail : JSON.stringify(detail || null)).run(); +} diff --git a/app/src/email.js b/app/src/email.js new file mode 100644 index 0000000..ddcc0db --- /dev/null +++ b/app/src/email.js @@ -0,0 +1,78 @@ +/* Sending the sign-in email. + * + * Resend is the default because it works from a Worker over plain HTTP with + * one key and no SDK. Swapping it means changing `deliver` and nothing else. + * + * With no key configured, sending is a no-op that reports failure honestly + * rather than pretending — in development the route surfaces the link + * directly, so nobody is left waiting for mail that was never sent. + */ + +export async function sendMagicLink(env, { to, link, code }) { + const from = env.MAIL_FROM || 'Design System '; + const brand = env.BRAND_NAME || 'Design System'; + + const subject = `Your sign-in link for ${brand}`; + const text = [ + `Sign in to ${brand}`, + '', + 'Open this link and you are in:', + link, + '', + `Or type this code in: ${code}`, + '', + 'The link and the code both stop working in 15 minutes, and each can only', + 'be used once.', + '', + "If you didn't ask to sign in, ignore this — nobody can get in without", + 'this email.', + ].join('\n'); + + const html = ` +
+

Sign in to ${escapeHtml(brand)}

+

Open the button below and you're in.

+ Sign me in +

Or type this code in:

+

${escapeHtml(code)}

+

+ Both stop working in 15 minutes and can only be used once. + If you didn't ask to sign in, you can ignore this email. +

+
+`; + + return deliver(env, { to, subject, text, html, from }); +} + +async function deliver(env, { to, subject, text, html, from }) { + if (!env.RESEND_API_KEY) { + return { ok: false, reason: 'no mail provider configured' }; + } + + try { + const res = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${env.RESEND_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ from, to: [to], subject, text, html }), + }); + if (!res.ok) { + return { ok: false, reason: `provider returned ${res.status}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, reason: err.message }; + } +} + +const escapeHtml = s => String(s ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + +/* A link goes into an href, so a quote or angle bracket would break out of the + * attribute. Only http(s) URLs are ever emitted. */ +const escapeAttr = url => + /^https?:\/\//.test(url) ? escapeHtml(url) : '#'; diff --git a/app/src/index.js b/app/src/index.js new file mode 100644 index 0000000..9894a6d --- /dev/null +++ b/app/src/index.js @@ -0,0 +1,225 @@ +/* The Worker: static pages, an API, and the one rule that has to hold — + * a package is only ever assembled by the server, for a signed-in person who + * is entitled to it. + */ + +import { + googleStart, googleCallback, appleStart, appleCallback, + magicRequest, magicCallback, magicVerifyCode, signOut, + readCookie, publicUser, json, +} from './auth.js'; +import { + userForSession, saveBrief, getBrief, listBriefs, approveBrief, + countDownloads, alreadyDownloaded, recordDownload, entitlementFor, audit, now, +} from './db.js'; +import { buildPage, packageFiles, makeZip, packageName, normalise, isReady } from '../../design-system/lib/package.js'; +import { STYLESHEET } from './styles.js'; + +/* How many packages a new account may take before paying. Payment is not built + * yet, so PAYWALL is off and this ceiling is recorded but not enforced — every + * download is still counted, which is the part that cannot be added later. */ +const FREE_DOWNLOADS = 2; + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + const { pathname } = url; + + try { + if (pathname.startsWith('/auth/') || pathname.startsWith('/api/')) { + const res = await route(request, env, url, pathname); + if (res) return withSecurityHeaders(res, { api: true }); + } + return withSecurityHeaders(await serveStatic(request, env, url)); + } catch (err) { + console.error('unhandled', err?.stack || err); + // Never leak an internal message to the caller. + return withSecurityHeaders(json({ error: 'Something went wrong on our side.' }, 500)); + } + }, +}; + +async function route(request, env, url, pathname) { + const method = request.method; + const post = method === 'POST'; + + /* ---- Sign in ------------------------------------------------------------ */ + if (pathname === '/auth/google' && method === 'GET') return googleStart(request, env, url); + if (pathname === '/auth/google/callback' && method === 'GET') return googleCallback(request, env, url); + if (pathname === '/auth/apple' && method === 'GET') return appleStart(request, env, url); + // Apple posts its result back as a form, so this one callback is a POST. + if (pathname === '/auth/apple/callback' && post) return appleCallback(request, env, url); + if (pathname === '/auth/magic' && post) return requireSameOrigin(request, url) || magicRequest(request, env, url); + if (pathname === '/auth/magic/callback' && method === 'GET') return magicCallback(request, env, url); + if (pathname === '/auth/code' && post) return requireSameOrigin(request, url) || magicVerifyCode(request, env, url); + if (pathname === '/auth/signout' && post) return requireSameOrigin(request, url) || signOut(request, env, url); + + /* ---- Everything below needs an account ---------------------------------- */ + const user = await userForSession(env.DB, readCookie(request)); + + if (pathname === '/api/me') { + return json(user + ? { signedIn: true, user: publicUser(user), usage: await usageFor(env, user) } + : { signedIn: false, providers: availableProviders(env) }); + } + + if (!pathname.startsWith('/api/')) return null; + if (!user) return json({ error: 'Please sign in first.' }, 401); + // Any state-changing call must have come from our own pages. + if (post) { const bad = requireSameOrigin(request, url); if (bad) return bad; } + + if (pathname === '/api/briefs' && method === 'GET') { + const { results } = await listBriefs(env.DB, user.id); + // Through publicBrief like every other brief response, so callers see one + // field naming everywhere rather than raw column names from this route. + return json({ briefs: (results || []).map(publicBrief) }); + } + + if (pathname === '/api/briefs' && post) { + const body = await request.json().catch(() => ({})); + const answers = normalise(body.answers || {}); + if (!isReady(answers)) return json({ error: 'Choose what you need and a look first.' }, 400); + + const brief = await saveBrief(env.DB, { + id: body.id, userId: user.id, title: body.title || null, + kind: answers.kind, palId: answers.palId, answers, + }); + return json({ brief: publicBrief(brief) }); + } + + const briefMatch = pathname.match(/^\/api\/briefs\/([\w-]+)(\/[a-z]+)?$/); + if (briefMatch) { + const [, id, action] = briefMatch; + const brief = await getBrief(env.DB, id, user.id); + if (!brief) return json({ error: 'No such project.' }, 404); + + if (!action && method === 'GET') return json({ brief: publicBrief(brief), answers: JSON.parse(brief.answers) }); + if (action === '/approve' && post) { + await approveBrief(env.DB, id, user.id); + return json({ brief: publicBrief(await getBrief(env.DB, id, user.id)) }); + } + if (action === '/preview' && method === 'GET') { + const html = buildPage(JSON.parse(brief.answers), STYLESHEET); + /* Served from a sandboxed iframe with its own headers: this HTML is + * customer-authored, so it must not run with the site's own origin. */ + return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); + } + if (action === '/download' && post) return download(request, env, user, brief); + } + + return json({ error: 'Not found.' }, 404); +} + +/* ---- The gate ------------------------------------------------------------- + The single place that decides whether a person may have a package, and the + only place one is assembled. It is a POST so no browser can be tricked into + walking a link and burning an allowance. -------------------------------- */ +async function download(request, env, user, brief) { + const paywall = env.PAYWALL === 'on'; + + // A second copy of something already taken never costs twice. + const repeat = await alreadyDownloaded(env.DB, user.id, brief.id); + const entitlement = repeat ? null : await entitlementFor(env.DB, user.id, brief.id); + const used = await countDownloads(env.DB, user.id); + const withinFree = used < FREE_DOWNLOADS; + + let basis = 'free'; + if (repeat) basis = 'repeat'; + else if (entitlement) basis = entitlement.basis; + else if (paywall && !withinFree) { + await audit(env.DB, { userId: user.id, action: 'download.refused', detail: { brief: brief.id, used } }); + return json({ + error: 'payment_required', + message: `Your ${FREE_DOWNLOADS} free downloads are used up. Choose a plan to carry on.`, + used, free: FREE_DOWNLOADS, + }, 402); + } + + /* Releasing is a human decision — we say a job needs no further work from + * us before the files go out. Off by default so today's downloads are + * immediate; turning it on costs a config change, not a rewrite. */ + if (env.REVIEW_BEFORE_RELEASE === 'on' && brief.status !== 'released') { + return json({ + error: 'awaiting_review', + message: "We're checking your files over. You'll get an email the moment they're ready.", + }, 409); + } + + const answers = JSON.parse(brief.answers); + const page = buildPage(answers, STYLESHEET); + const files = packageFiles(answers, page, { contact: env.SUPPORT_EMAIL || 'hello@example.com' }); + const zip = await makeZip(files); + + if (!repeat) { + await recordDownload(env.DB, { userId: user.id, briefId: brief.id, bytes: zip.length, basis }); + } + await audit(env.DB, { userId: user.id, action: 'download', detail: { brief: brief.id, basis, bytes: zip.length } }); + + return new Response(zip, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${packageName(answers)}"`, + 'Content-Length': String(zip.length), + 'Cache-Control': 'no-store', + }, + }); +} + +async function usageFor(env, user) { + const used = await countDownloads(env.DB, user.id); + const sub = await entitlementFor(env.DB, user.id, null); + return { + used, + free: FREE_DOWNLOADS, + remaining: Math.max(0, FREE_DOWNLOADS - used), + paywall: env.PAYWALL === 'on', + plan: sub ? sub.basis : null, + }; +} + +const availableProviders = env => ({ + google: Boolean(env.GOOGLE_CLIENT_ID), + apple: Boolean(env.APPLE_CLIENT_ID), + email: true, +}); + +const publicBrief = b => b && ({ + id: b.id, title: b.title, kind: b.kind, palId: b.pal_id, status: b.status, + createdAt: b.created_at, updatedAt: b.updated_at, + approvedAt: b.approved_at, releasedAt: b.released_at, +}); + +/* ---- Guards --------------------------------------------------------------- + SameSite=Lax already blocks the cross-site form post; this is the second + lock, because a cookie policy is one browser default away from not being + there. Checked on Origin, which a page cannot forge. -------------------- */ +function requireSameOrigin(request, url) { + const origin = request.headers.get('Origin'); + if (!origin) return null; // same-origin GETs and some clients omit it + if (origin === url.origin) return null; + return json({ error: 'Request rejected.' }, 403); +} + +function withSecurityHeaders(res, { api = false } = {}) { + const headers = new Headers(res.headers); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + headers.set('X-Frame-Options', 'SAMEORIGIN'); + if (api) headers.set('Cache-Control', headers.get('Cache-Control') || 'no-store'); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} + +/* ---- Static --------------------------------------------------------------- */ + +async function serveStatic(request, env, url) { + if (!env.ASSETS) return new Response('Not found', { status: 404 }); + + /* The assets layer already strips .html and canonicalises, so /brief and + * /signin resolve on their own. This only covers the friendlier alias we + * hand out in links, and it redirects rather than rewriting so the address + * bar ends up showing the one canonical URL. */ + if (url.pathname === '/start') { + return new Response(null, { status: 302, headers: { Location: '/brief' } }); + } + return env.ASSETS.fetch(request); +} diff --git a/app/src/jwt.js b/app/src/jwt.js new file mode 100644 index 0000000..3259949 --- /dev/null +++ b/app/src/jwt.js @@ -0,0 +1,127 @@ +/* Verifying the identity tokens Google and Apple hand back, and signing the + * one Apple demands in return. + * + * The verification here is the security boundary of the whole sign-in: a token + * that is merely *decoded* proves nothing at all, because anyone can write a + * JWT claiming to be anyone. So each check below — signature, issuer, + * audience, expiry, nonce — is load-bearing, and a token failing any one of + * them is rejected rather than repaired. + */ + +const b64urlToBytes = s => { + const pad = s.replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(pad + '='.repeat((4 - pad.length % 4) % 4)); + return Uint8Array.from(bin, c => c.charCodeAt(0)); +}; + +const bytesToB64url = bytes => + btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +const decodeJson = part => JSON.parse(new TextDecoder().decode(b64urlToBytes(part))); + +/* JWKS changes rarely and fetching it on every sign-in adds a round trip to + * the slowest part of the flow, so keep it for a few minutes. */ +const jwksCache = new Map(); + +async function fetchJwks(url) { + const hit = jwksCache.get(url); + if (hit && hit.expires > Date.now()) return hit.keys; + + const res = await fetch(url); + if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`); + const { keys } = await res.json(); + jwksCache.set(url, { keys, expires: Date.now() + 10 * 60_000 }); + return keys; +} + +const ALGS = { + RS256: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + ES256: { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' }, +}; + +/* Verify a provider's ID token and return its claims. + * + * `nonce` is required for the providers we use: it ties the token to the + * sign-in attempt that started in this browser, so a token lifted from another + * flow cannot be replayed into ours. */ +export async function verifyIdToken(idToken, { jwksUrl, issuer, audience, nonce, leewaySec = 60 }) { + const parts = String(idToken || '').split('.'); + if (parts.length !== 3) throw new Error('malformed token'); + + const header = decodeJson(parts[0]); + const claims = decodeJson(parts[1]); + const alg = ALGS[header.alg]; + if (!alg) throw new Error(`unsupported alg: ${header.alg}`); + + const keys = await fetchJwks(jwksUrl); + const jwk = keys.find(k => k.kid === header.kid && (!k.alg || k.alg === header.alg)); + if (!jwk) throw new Error('signing key not found'); + + const key = await crypto.subtle.importKey( + 'jwk', jwk, + header.alg === 'ES256' ? { name: 'ECDSA', namedCurve: 'P-256' } + : { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, ['verify']); + + const ok = await crypto.subtle.verify( + header.alg === 'ES256' ? { name: 'ECDSA', hash: 'SHA-256' } : 'RSASSA-PKCS1-v1_5', + key, + b64urlToBytes(parts[2]), + new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); + if (!ok) throw new Error('bad signature'); + + const nowSec = Math.floor(Date.now() / 1000); + const issuers = Array.isArray(issuer) ? issuer : [issuer]; + if (!issuers.includes(claims.iss)) throw new Error(`unexpected issuer: ${claims.iss}`); + + // aud may be a string or an array; either way ours has to be in it. + const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if (!aud.includes(audience)) throw new Error('token was not issued for this application'); + + if (typeof claims.exp !== 'number' || claims.exp + leewaySec < nowSec) throw new Error('token expired'); + if (typeof claims.iat === 'number' && claims.iat - leewaySec > nowSec) throw new Error('token issued in the future'); + if (nonce && claims.nonce !== nonce) throw new Error('nonce mismatch'); + if (!claims.sub) throw new Error('token carries no subject'); + + return claims; +} + +/* Apple does not issue a client secret. You register a key, download a .p8 + * once, and sign a short-lived ES256 JWT with it on every token exchange — + * which is why this exists and Google needs no equivalent. */ +export async function appleClientSecret({ teamId, clientId, keyId, privateKeyPem }) { + const pkcs8 = b64urlToBytes( + privateKeyPem.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, '').replace(/\s+/g, '') + .replace(/\+/g, '-').replace(/\//g, '_')); + + const key = await crypto.subtle.importKey( + 'pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); + + const nowSec = Math.floor(Date.now() / 1000); + const header = { alg: 'ES256', kid: keyId, typ: 'JWT' }; + const payload = { + iss: teamId, + iat: nowSec, + exp: nowSec + 3600, // Apple caps this at six months; an hour is plenty + aud: 'https://appleid.apple.com', + sub: clientId, + }; + + const enc = new TextEncoder(); + const signingInput = + `${bytesToB64url(enc.encode(JSON.stringify(header)))}.${bytesToB64url(enc.encode(JSON.stringify(payload)))}`; + + const sig = new Uint8Array(await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, key, enc.encode(signingInput))); + + return `${signingInput}.${bytesToB64url(sig)}`; +} + +/* PKCE. Without it, an authorization code intercepted on the redirect can be + * exchanged by whoever holds it; with it, the exchange also needs the verifier + * that never left this server. */ +export async function pkcePair() { + const verifier = bytesToB64url(crypto.getRandomValues(new Uint8Array(32))); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return { verifier, challenge: bytesToB64url(new Uint8Array(digest)) }; +} diff --git a/app/src/styles.js b/app/src/styles.js new file mode 100644 index 0000000..d85f126 --- /dev/null +++ b/app/src/styles.js @@ -0,0 +1,1496 @@ +/* Generated by app/tools/bundle-styles.js — do not edit. + * Source: design-system/tokens/tokens.css, design-system/css/base.css, design-system/css/components.css, design-system/css/web.css + * Re-run after changing any of them. */ + +export const STYLESHEET = `/* ===== tokens/tokens.css ===== */ +/* ========================================================================== + Corporate Deck Design System — Design Tokens + -------------------------------------------------------------------------- + Single source of truth for colour, type, space, radius, shadow and motion. + Import this first; every other stylesheet in the system depends on it. + + Re-theming: add a theme class to or any container. + -> whole document re-themes +
-> scoped re-theme + ========================================================================== */ + +:root { + /* --- Accent ramp (default: amber) ------------------------------------ */ + --accent-50: #FFF8E6; + --accent-100: #FFEDBF; + --accent-300: #FFD24D; + --accent-500: #F5B21A; + --accent-600: #9B6B00; + --accent-700: #6D4800; + + /* Text colour that stays legible on top of --accent-500 */ + --accent-on: #14181F; + + /* Text colour for the darker accent fill (--accent-600), used by the primary + button's hover state. Every theme's --accent-600 is chosen to clear 4.5:1 + against white — that is what makes it safe as link and eyebrow text on the + page ground — so white is the one value that works on it in all nine. */ + --accent-on-600: #FFFFFF; + + /* --- Ink (dark anchor) ---------------------------------------------- */ + --ink-900: #14181F; + --ink-800: #1B2333; + --ink-700: #233049; + --ink-600: #3A4761; + --ink-500: #5C6880; + --ink-400: #8993A6; + --ink-300: #B4BCC9; + --ink-200: #D8DDE5; + --ink-100: #ECEFF3; + + /* --- Surfaces -------------------------------------------------------- */ + --surface-page: #FFFFFF; + --surface-muted: #F7F8FA; + --surface-cream: #FAF6EC; + --surface-sunken: #EFF1F5; + --surface-inverse: #14181F; + + /* --- Foreground roles ------------------------------------------------ */ + --fg-strong: var(--ink-900); + --fg-body: var(--ink-600); + --fg-muted: var(--ink-400); + --fg-on-dark: #FFFFFF; + --fg-on-dark-muted: rgba(255, 255, 255, 0.72); + + /* --- Semantic -------------------------------------------------------- */ + --positive: #17A673; + --warning: #F5B21A; + --negative: #E04A3F; + --info: #2B50EE; + + /* --- Chart series ---------------------------------------------------- */ + --chart-1: var(--accent-500); + --chart-2: var(--ink-800); + --chart-3: var(--accent-300); + --chart-4: var(--ink-400); + --chart-5: var(--accent-100); + --chart-6: var(--ink-200); + --chart-grid: var(--ink-100); + --chart-axis: var(--ink-400); + + /* --- Typography ------------------------------------------------------ */ + --font-display: 'Poppins', 'Montserrat', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif; + --font-body: 'Inter', 'Poppins', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; + + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold: 600; + --fw-bold: 700; + --fw-black: 800; + + --fs-display: 4.5rem; + --fs-h1: 3rem; + --fs-h2: 2.25rem; + --fs-h3: 1.5rem; + --fs-h4: 1.125rem; + --fs-body: 1rem; + --fs-small: 0.875rem; + --fs-caption: 0.75rem; + --fs-eyebrow: 0.75rem; + --fs-numeral: 5rem; + + --lh-tight: 1.06; + --lh-snug: 1.2; + --lh-normal: 1.45; + --lh-loose: 1.65; + + --tracking-display: -0.03em; + --tracking-tight: -0.02em; + --tracking-normal: 0; + --tracking-eyebrow: 0.18em; + + /* --- Space (4px grid) ------------------------------------------------ */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-7: 2.5rem; + --space-8: 3rem; + --space-9: 4rem; + --space-10: 5rem; + --space-11: 6rem; + --space-12: 8rem; + + /* --- Radius ---------------------------------------------------------- */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-xl: 32px; + --radius-2xl: 48px; + --radius-pill: 999px; + --radius-blob: 42% 58% 63% 37% / 41% 44% 56% 59%; + + /* --- Shadow ---------------------------------------------------------- */ + --shadow-sm: 0 1px 2px rgba(20, 24, 31, 0.05); + --shadow-md: 0 4px 16px rgba(20, 24, 31, 0.07); + --shadow-lg: 0 12px 32px rgba(20, 24, 31, 0.09); + --shadow-xl: 0 24px 64px rgba(20, 24, 31, 0.12); + --shadow-slide: 0 18px 48px rgba(20, 24, 31, 0.14); + + /* --- Motion ---------------------------------------------------------- */ + --duration-fast: 140ms; + --duration-base: 220ms; + --duration-slow: 380ms; + --ease-standard: cubic-bezier(0.2, 0, 0.2, 1); + --ease-enter: cubic-bezier(0, 0, 0.2, 1); + --ease-exit: cubic-bezier(0.4, 0, 1, 1); + + /* --- Gradient & glow -------------------------------------------------- + Decks stay flat; the web-landing family uses these for hero surfaces and + the soft orbs behind content. Never as a brand colour — see the brief. */ + --gradient-hero: linear-gradient(135deg, var(--accent-600) 0%, var(--accent-500) 55%, var(--accent-300) 100%); + --gradient-sheen: linear-gradient(135deg, var(--accent-50) 0%, var(--surface-page) 60%); + --gradient-text: linear-gradient(90deg, var(--accent-500), var(--accent-300)); + --glow-accent: radial-gradient(circle, color-mix(in srgb, var(--accent-500) 38%, transparent) 0%, transparent 70%); + + /* --- Layout ---------------------------------------------------------- */ + --container-max: 1200px; + --section-py: 6rem; + --slide-safe: 6.5%; +} + +/* ========================================================================== + Accent themes — swap the ramp, everything downstream follows + ========================================================================== */ + +.theme-amber { + --accent-50: #FFF8E6; --accent-100: #FFEDBF; --accent-300: #FFD24D; + --accent-500: #F5B21A; --accent-600: #9B6B00; --accent-700: #6D4800; + --accent-on: #14181F; +} + +.theme-electric { + --accent-50: #EDF1FF; --accent-100: #D6E0FF; --accent-300: #8DA5FF; + --accent-500: #2B50EE; --accent-600: #1D3ACC; --accent-700: #152B99; + --accent-on: #FFFFFF; +} + +.theme-navy { + --accent-50: #EEF2F9; --accent-100: #D5DEEE; --accent-300: #8095C4; + --accent-500: #2B4A8B; --accent-600: #1F3768; --accent-700: #152547; + --accent-on: #FFFFFF; +} + +.theme-teal { + --accent-50: #E6F8FA; --accent-100: #C0EEF3; --accent-300: #63D6E2; + --accent-500: #22BCCE; --accent-600: #14818D; --accent-700: #11707B; + --accent-on: #14181F; +} + +.theme-green { + --accent-50: #E7F7EF; --accent-100: #C2EBD8; --accent-300: #63CFA1; + --accent-500: #17A673; --accent-600: #11855C; --accent-700: #0B6244; + --accent-on: #14181F; +} + +.theme-coral { + --accent-50: #FDEEEA; --accent-100: #FAD5CB; --accent-300: #F29B80; + --accent-500: #E85D33; --accent-600: #C64720; --accent-700: #993518; + --accent-on: #14181F; +} + +/* Added from the favourites set — the web-landing family. Violet is the + signature there; lime is the cream-ground outlier. */ + +.theme-violet { + --accent-50: #F3EEFF; --accent-100: #E2D6FF; --accent-300: #A98BFF; + --accent-500: #7C4DFF; --accent-600: #6534E0; --accent-700: #4B24A8; + --accent-on: #FFFFFF; +} + +.theme-indigo { + --accent-50: #EDF0FF; --accent-100: #D8DFFF; --accent-300: #93A5FF; + --accent-500: #4059F0; --accent-600: #2E42CC; --accent-700: #1F2E96; + --accent-on: #FFFFFF; +} + +.theme-lime { + --accent-50: #F7FBE8; --accent-100: #EAF5C2; --accent-300: #D8EC7E; + --accent-500: #C3DE4A; --accent-600: #6A7C1D; --accent-700: #4C5A11; + --accent-on: #14181F; +} + +/* ========================================================================== + Dark surface context + Any block that sits on ink flips its foreground roles here, so components + need no dark-specific variants of their own. + ========================================================================== */ + +.on-dark { + --fg-strong: #FFFFFF; + --fg-body: rgba(255, 255, 255, 0.78); + --fg-muted: rgba(255, 255, 255, 0.55); + --surface-page: var(--ink-900); + --surface-muted: var(--ink-800); + --chart-grid: rgba(255, 255, 255, 0.12); + --chart-axis: rgba(255, 255, 255, 0.45); + color: var(--fg-body); +} + +@media (prefers-reduced-motion: reduce) { + :root { + --duration-fast: 0ms; + --duration-base: 0ms; + --duration-slow: 0ms; + } +} + + +/* ===== css/base.css ===== */ +/* ========================================================================== + Corporate Deck Design System — Base + Reset, document defaults, and the typographic primitives. + Requires: tokens.css + ========================================================================== */ + +*, *::before, *::after { box-sizing: border-box; } + +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--surface-page); + color: var(--fg-body); + font-family: var(--font-body); + font-size: var(--fs-body); + line-height: var(--lh-loose); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +img, svg, video, canvas { display: block; max-width: 100%; height: auto; } + +h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd { margin: 0; } +ul, ol { margin: 0; padding: 0; } + +a { color: var(--accent-600); text-decoration-thickness: 1px; text-underline-offset: 2px; } +a:hover { color: var(--accent-700); } + +:focus-visible { + outline: 3px solid var(--accent-500); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* -------------------------------------------------------------------------- + Type primitives + The house voice: heavy, tight display type over roomy, quiet body copy. + -------------------------------------------------------------------------- */ + +.display, +.h1, .h2, .h3, .h4 { + font-family: var(--font-display); + color: var(--fg-strong); + text-wrap: balance; +} + +.display { + font-size: clamp(2.75rem, 6vw, var(--fs-display)); + font-weight: var(--fw-black); + line-height: 1.02; + letter-spacing: var(--tracking-display); +} + +.h1 { + font-size: clamp(2.25rem, 4.5vw, var(--fs-h1)); + font-weight: var(--fw-black); + line-height: var(--lh-tight); + letter-spacing: -0.025em; +} + +.h2 { + font-size: clamp(1.75rem, 3.2vw, var(--fs-h2)); + font-weight: var(--fw-bold); + line-height: 1.12; + letter-spacing: var(--tracking-tight); +} + +.h3 { + font-size: var(--fs-h3); + font-weight: var(--fw-bold); + line-height: var(--lh-snug); + letter-spacing: -0.01em; +} + +.h4 { + font-size: var(--fs-h4); + font-weight: var(--fw-semibold); + line-height: 1.3; +} + +.body-lg { font-size: 1.125rem; line-height: var(--lh-loose); } +.body { font-size: var(--fs-body); line-height: var(--lh-loose); } +.small { font-size: var(--fs-small); line-height: 1.6; } +.caption { font-size: var(--fs-caption); line-height: 1.5; color: var(--fg-muted); font-weight: var(--fw-medium); } + +/* The small tracked-out label that sits above nearly every headline. + This is the single most recognisable tic of the system — use it often. */ +.eyebrow { + display: inline-block; + font-family: var(--font-display); + font-size: var(--fs-eyebrow); + font-weight: var(--fw-semibold); + letter-spacing: var(--tracking-eyebrow); + text-transform: uppercase; + color: var(--accent-600); + line-height: var(--lh-snug); +} + +.eyebrow--muted { color: var(--fg-muted); } + +/* Two-tone headline. Wrap the emphasised word: "Doing Everything Right" */ +.h1 em, .h2 em, .display em { + font-style: normal; + color: var(--accent-600); +} + +/* The light/heavy headline pair seen on most cover slides: + a thin first line, a black second line. */ +.headline-pair { display: block; } +.headline-pair__light { display: block; font-weight: var(--fw-regular); } +.headline-pair__heavy { display: block; font-weight: var(--fw-black); } + +/* Oversized ghost numeral used behind section dividers and stat blocks. */ +.numeral { + font-family: var(--font-display); + font-size: var(--fs-numeral); + font-weight: var(--fw-black); + line-height: 0.9; + letter-spacing: -0.04em; + color: var(--fg-strong); + font-variant-numeric: tabular-nums; +} + +.numeral--ghost { color: var(--ink-100); } +.numeral--accent { color: var(--accent-500); } + +/* The short accent rule that separates an eyebrow or headline from its body. */ +.rule { + width: 56px; + height: 4px; + border: 0; + background: var(--accent-500); + border-radius: var(--radius-pill); + margin: 0; +} + +.rule--center { margin-inline: auto; } +.rule--long { width: 100%; height: 1px; background: var(--ink-200); border-radius: 0; } + +/* -------------------------------------------------------------------------- + Utilities + -------------------------------------------------------------------------- */ + +.stack > * + * { margin-top: var(--flow, var(--space-4)); } +.stack-2 { --flow: var(--space-2); } +.stack-3 { --flow: var(--space-3); } +.stack-5 { --flow: var(--space-5); } +.stack-6 { --flow: var(--space-6); } + +.container { + width: 100%; + max-width: var(--container-max); + margin-inline: auto; + padding-inline: var(--space-5); +} + +.section { padding-block: var(--section-py); } +.section--muted { background: var(--surface-muted); } +.section--cream { background: var(--surface-cream); } +.section--accent { background: var(--accent-500); color: var(--accent-on); } +.section--dark { background: var(--ink-900); } + +.text-center { text-align: center; } +.text-accent { color: var(--accent-600); } +.text-strong { color: var(--fg-strong); } +.text-muted { color: var(--fg-muted); } + +.measure { max-width: 62ch; } +.measure-sm { max-width: 46ch; } + +.visually-hidden { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + + +/* ===== css/components.css ===== */ +/* ========================================================================== + Corporate Deck Design System — Components + The recurring furniture: cards, stats, steps, timelines, buttons, tables, + photo treatments and the decorative geometry. + Requires: tokens.css, base.css + ========================================================================== */ + +/* -------------------------------------------------------------------------- + Grid + -------------------------------------------------------------------------- */ + +.grid { + display: grid; + gap: var(--space-5); + grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr)); +} +.grid--2 { --cols: 2; } +.grid--3 { --cols: 3; } +.grid--4 { --cols: 4; } +.grid--gap-lg { gap: var(--space-7); } + +/* Asymmetric split: the workhorse layout for "headline left, content right". */ +.split { + display: grid; + gap: var(--space-8); + grid-template-columns: minmax(0, var(--split, 5fr)) minmax(0, 7fr); + align-items: center; +} +.split--even { --split: 6fr; } +.split--wide-l { --split: 7fr; } + +@media (max-width: 900px) { + .grid { grid-template-columns: 1fr; } + .grid--2, .grid--3, .grid--4 { grid-template-columns: 1fr; } + .split { grid-template-columns: 1fr; gap: var(--space-6); } +} + +/* -------------------------------------------------------------------------- + Card — the base container for almost every content block + -------------------------------------------------------------------------- */ + +.card { + background: var(--surface-page); + border: 1px solid var(--ink-100); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); + transition: box-shadow var(--duration-base) var(--ease-standard), + transform var(--duration-base) var(--ease-standard); +} + +.card--raised { box-shadow: var(--shadow-md); border-color: transparent; } +.card--flat { box-shadow: none; background: var(--surface-muted); border-color: transparent; } +.card--accent { background: var(--accent-500); border-color: transparent; color: var(--accent-on); } +.card--dark { background: var(--ink-900); border-color: transparent; } +.card--interactive:hover { box-shadow: var(--shadow-lg); transform: translateY(-3px); } + +.card--accent .card__title, +.card--accent .h3 { color: var(--accent-on); } +.card--accent .card__body { color: color-mix(in srgb, var(--accent-on) 78%, transparent); } + +/* Left accent bar — used to mark the "featured" item in a row of three. */ +.card--barred { + border-left: 4px solid var(--accent-500); + border-radius: var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-sm); +} + +/* Top accent bar — the alternative emphasis treatment. */ +.card--capped { border-top: 4px solid var(--accent-500); } + +.card__title { font-family: var(--font-display); font-size: var(--fs-h4); font-weight: var(--fw-bold); color: var(--fg-strong); } +.card__body { font-size: var(--fs-small); color: var(--fg-body); margin-top: var(--space-2); } + +/* -------------------------------------------------------------------------- + Icon chip — icon in a rounded square or circle, always accent-filled + -------------------------------------------------------------------------- */ + +.icon-chip { + display: inline-grid; + place-items: center; + width: 52px; + height: 52px; + border-radius: var(--radius-md); + background: var(--accent-500); + color: var(--accent-on); + flex: none; +} + +.icon-chip--round { border-radius: var(--radius-pill); } +.icon-chip--soft { background: var(--accent-50); color: var(--accent-700); } +.icon-chip--dark { background: var(--ink-900); color: #fff; } +.icon-chip--outline { + background: transparent; + border: 2px solid var(--accent-500); + color: var(--accent-600); +} +.icon-chip--sm { width: 38px; height: 38px; border-radius: var(--radius-sm); } +.icon-chip--lg { width: 72px; height: 72px; border-radius: var(--radius-lg); } + +.icon-chip svg { width: 45%; height: 45%; } + +/* Feature row: chip left, copy right. The most-used content atom in the set. */ +.feature { + display: flex; + gap: var(--space-4); + align-items: flex-start; +} +.feature__title { font-family: var(--font-display); font-weight: var(--fw-semibold); color: var(--fg-strong); } +.feature__body { font-size: var(--fs-small); color: var(--fg-body); margin-top: var(--space-1); } + +/* -------------------------------------------------------------------------- + Stat — big number over a quiet label + -------------------------------------------------------------------------- */ + +.stat { display: flex; flex-direction: column; gap: var(--space-1); } + +.stat__value { + font-family: var(--font-display); + font-size: clamp(2.25rem, 4vw, 3.25rem); + font-weight: var(--fw-black); + line-height: 1; + letter-spacing: -0.035em; + color: var(--accent-600); + font-variant-numeric: tabular-nums; +} + +.stat__label { + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-muted); +} + +.stat__note { font-size: var(--fs-small); color: var(--fg-body); } +.stat--ink .stat__value { color: var(--fg-strong); } + +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--space-6); +} + +/* Divided stat strip — hairline rules between each figure. */ +.stat-row--divided > .stat + .stat { + border-left: 1px solid var(--ink-200); + padding-left: var(--space-6); +} +.on-dark .stat-row--divided > .stat + .stat { border-left-color: rgba(255,255,255,0.18); } +.on-dark .stat__value { color: var(--accent-300); } + +@media (max-width: 700px) { + .stat-row--divided > .stat + .stat { border-left: 0; padding-left: 0; } +} + +/* -------------------------------------------------------------------------- + Numbered steps — "01 / 02 / 03" process rows + -------------------------------------------------------------------------- */ + +.steps { display: grid; gap: var(--space-6); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); counter-reset: step; } + +.step { position: relative; padding-top: var(--space-6); } + +.step::before { + counter-increment: step; + content: "0" counter(step); + position: absolute; + top: 0; + left: 0; + font-family: var(--font-display); + font-size: 2.5rem; + font-weight: var(--fw-black); + line-height: 1; + letter-spacing: -0.04em; + color: var(--accent-100); +} + +.step__title { font-family: var(--font-display); font-weight: var(--fw-bold); color: var(--fg-strong); position: relative; } +.step__body { font-size: var(--fs-small); margin-top: var(--space-2); } + +/* Badge variant — the number sits in a filled chip instead of ghosting behind. */ +.step-badge { + display: inline-grid; + place-items: center; + min-width: 44px; + height: 44px; + padding-inline: var(--space-3); + border-radius: var(--radius-pill); + background: var(--accent-500); + color: var(--accent-on); + font-family: var(--font-display); + font-weight: var(--fw-bold); + font-variant-numeric: tabular-nums; +} + +/* -------------------------------------------------------------------------- + Timeline — horizontal milestone track + -------------------------------------------------------------------------- */ + +.timeline { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: var(--space-5); + position: relative; + padding-top: var(--space-7); +} + +.timeline::before { + content: ""; + position: absolute; + top: 10px; + left: 0; + right: 0; + height: 2px; + background: var(--ink-200); +} + +.timeline__item { position: relative; } + +.timeline__item::before { + content: ""; + position: absolute; + top: calc(var(--space-7) * -1 + 4px); + left: 0; + width: 14px; + height: 14px; + border-radius: var(--radius-pill); + background: var(--accent-500); + box-shadow: 0 0 0 4px var(--surface-page); +} + +.timeline__date { + font-family: var(--font-display); + font-weight: var(--fw-bold); + color: var(--accent-600); + font-variant-numeric: tabular-nums; +} +.timeline__body { font-size: var(--fs-small); margin-top: var(--space-1); } + +@media (max-width: 800px) { + .timeline { grid-auto-flow: row; padding-top: 0; padding-left: var(--space-6); } + .timeline::before { top: 0; bottom: 0; left: 6px; right: auto; width: 2px; height: auto; } + .timeline__item::before { top: 4px; left: calc(var(--space-6) * -1); } +} + +/* -------------------------------------------------------------------------- + Buttons + -------------------------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0.85rem 1.75rem; + border: 2px solid transparent; + border-radius: var(--radius-pill); + font-family: var(--font-display); + font-size: var(--fs-small); + font-weight: var(--fw-semibold); + letter-spacing: 0.01em; + text-decoration: none; + cursor: pointer; + transition: background var(--duration-base) var(--ease-standard), + color var(--duration-base) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); +} + +.btn:hover { transform: translateY(-1px); } +.btn:active { transform: translateY(0); } + +.btn--primary { background: var(--accent-500); color: var(--accent-on); } +.btn--primary:hover { background: var(--accent-600); color: var(--accent-on-600); } + +.btn--ink { background: var(--ink-900); color: #fff; } +.btn--ink:hover { background: var(--ink-800); color: #fff; } + +.btn--outline { border-color: var(--accent-500); color: var(--accent-600); background: transparent; } +.btn--outline:hover { background: var(--accent-500); color: var(--accent-on); } + +.btn--ghost { color: var(--fg-strong); background: transparent; padding-inline: var(--space-2); } +.btn--ghost:hover { color: var(--accent-600); } + +.btn--square { border-radius: var(--radius-md); } +.btn--lg { padding: 1.05rem 2.25rem; font-size: var(--fs-body); } + +/* Pill tag / chip label */ +.tag { + display: inline-block; + padding: 0.35rem 0.85rem; + border-radius: var(--radius-pill); + background: var(--accent-50); + color: var(--accent-700); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + letter-spacing: 0.04em; +} +.tag--solid { background: var(--accent-500); color: var(--accent-on); } +.tag--ink { background: var(--ink-900); color: #fff; } + +/* -------------------------------------------------------------------------- + Media — photo treatments + -------------------------------------------------------------------------- */ + +.media { + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface-sunken); +} +.media img { width: 100%; height: 100%; object-fit: cover; } + +.media--xl { border-radius: var(--radius-xl); } +.media--pill { border-radius: var(--radius-pill); } +.media--blob { border-radius: var(--radius-blob); } +.media--square { border-radius: 0; } + +/* The rounded-rect-with-one-square-corner mask used on section covers. */ +.media--notched { border-radius: var(--radius-xl) var(--radius-xl) var(--radius-xl) 0; } + +.media--circle { border-radius: var(--radius-pill); aspect-ratio: 1; } +.media--4x3 { aspect-ratio: 4 / 3; } +.media--16x9 { aspect-ratio: 16 / 9; } +.media--3x4 { aspect-ratio: 3 / 4; } + +/* Accent duotone wash — turns any stock photo into brand furniture. */ +.media--wash { position: relative; } +.media--wash::after { + content: ""; + position: absolute; + inset: 0; + background: var(--accent-500); + mix-blend-mode: multiply; + opacity: 0.55; +} + +.media--dim { position: relative; } +.media--dim::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(20,24,31,0.15) 0%, rgba(20,24,31,0.72) 100%); +} + +/* Avatar */ +.avatar { + width: 96px; + height: 96px; + border-radius: var(--radius-pill); + object-fit: cover; + border: 4px solid var(--surface-page); + box-shadow: var(--shadow-md); +} +.avatar--sm { width: 48px; height: 48px; border-width: 2px; } +.avatar--ring { border-color: var(--accent-500); } + +/* -------------------------------------------------------------------------- + Decorative geometry + The blobs, quarter-circles and offset blocks that sit behind content. + All are aria-hidden ornaments — never put meaning in them. + -------------------------------------------------------------------------- */ + +/* Ornaments sit on a negative layer so they fall behind in-flow content + without needing a z-index on the content itself — which would otherwise + clobber the \`position: absolute\` on slide furniture. \`.has-deco\` isolates, + so a negative layer never escapes behind the container's own background. */ +.deco { position: absolute; pointer-events: none; z-index: -1; } + +.deco-circle { + border-radius: var(--radius-pill); + background: var(--accent-500); +} +.deco-circle--soft { background: var(--accent-50); } +.deco-circle--ink { background: var(--ink-100); } +.deco-circle--ring { background: transparent; border: 2px solid var(--accent-300); } + +/* Quarter-circle corner ornament. */ +.deco-quarter { + border-radius: 0 0 100% 0; + background: var(--accent-100); +} + +.deco-blob { + border-radius: var(--radius-blob); + background: var(--accent-100); +} + +/* Dotted matrix — the small grid of dots tucked into slide corners. */ +.deco-dots { + background-image: radial-gradient(var(--accent-300) 1.5px, transparent 1.5px); + background-size: 12px 12px; + opacity: 0.6; +} + +/* Offset block behind a card or image — the "stacked paper" look. + The accent slab is kept *inside* the element's own box (via padding) rather + than bled outside it, so the effect survives any ancestor that clips + overflow — which \`.has-deco\` and \`.slide\` both do. */ +.offset-block { + position: relative; + isolation: isolate; + padding: 0 var(--offset-shift, var(--space-5)) var(--offset-shift, var(--space-5)) 0; +} +.offset-block::before { + content: ""; + position: absolute; + inset: var(--offset-shift, var(--space-5)) 0 0 var(--offset-shift, var(--space-5)); + background: var(--accent-500); + border-radius: var(--radius-lg); + z-index: -1; +} + +/* Shift the slab to the other side. */ +.offset-block--left { + padding: 0 0 var(--offset-shift, var(--space-5)) var(--offset-shift, var(--space-5)); +} +.offset-block--left::before { + inset: var(--offset-shift, var(--space-5)) var(--offset-shift, var(--space-5)) 0 0; +} + +.slide .offset-block { --offset-shift: 2.4cqw; } + +/* Anything that hosts .deco children needs this. */ +.has-deco { position: relative; overflow: hidden; isolation: isolate; } + +/* -------------------------------------------------------------------------- + Progress / meters + -------------------------------------------------------------------------- */ + +.meter { + height: 8px; + border-radius: var(--radius-pill); + background: var(--ink-100); + overflow: hidden; +} +.meter__fill { + height: 100%; + border-radius: inherit; + background: var(--accent-500); +} +.meter--thick { height: 14px; } + +.meter-row { display: grid; gap: var(--space-2); } +.meter-row__head { + display: flex; + justify-content: space-between; + font-size: var(--fs-small); + font-weight: var(--fw-semibold); + color: var(--fg-strong); +} + +/* -------------------------------------------------------------------------- + Table + -------------------------------------------------------------------------- */ + +.table-wrap { overflow-x: auto; } + +.table { + width: 100%; + border-collapse: collapse; + font-size: var(--fs-small); + min-width: 520px; +} + +.table th { + text-align: left; + font-family: var(--font-display); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + padding: var(--space-3) var(--space-4); + border-bottom: 2px solid var(--ink-200); + white-space: nowrap; +} + +.table td { + padding: var(--space-4); + border-bottom: 1px solid var(--ink-100); + color: var(--fg-body); +} + +.table tbody tr:hover { background: var(--surface-muted); } +.table td:first-child { font-weight: var(--fw-semibold); color: var(--fg-strong); } + +/* -------------------------------------------------------------------------- + Quote / testimonial + -------------------------------------------------------------------------- */ + +.quote { + position: relative; + padding-left: var(--space-7); + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: var(--fw-medium); + line-height: 1.5; + color: var(--fg-strong); +} + +.quote::before { + content: "\\201C"; + position: absolute; + left: 0; + top: -0.35em; + font-size: 4rem; + line-height: 1; + color: var(--accent-300); +} + +.quote__cite { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-5); + font-family: var(--font-body); + font-size: var(--fs-small); + font-weight: var(--fw-regular); + color: var(--fg-muted); +} +.quote__name { font-weight: var(--fw-semibold); color: var(--fg-strong); display: block; } + +/* -------------------------------------------------------------------------- + SWOT — the 2x2 that appears in nearly every deck in the reference set + -------------------------------------------------------------------------- */ + +.swot { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); } +.swot > * { padding: var(--space-5); border-radius: var(--radius-lg); } +.swot__s { background: var(--accent-500); color: var(--accent-on); } +.swot__w { background: var(--ink-900); color: #fff; } +.swot__o { background: var(--accent-50); color: var(--ink-900); } +.swot__t { background: var(--surface-muted); color: var(--ink-900); } +.swot__label { + font-family: var(--font-display); + font-size: var(--fs-caption); + font-weight: var(--fw-bold); + letter-spacing: 0.14em; + text-transform: uppercase; + opacity: 0.75; +} +.swot__body { font-size: var(--fs-small); margin-top: var(--space-2); } + +@media (max-width: 700px) { .swot { grid-template-columns: 1fr; } } + +/* -------------------------------------------------------------------------- + Navbar (web templates) + -------------------------------------------------------------------------- */ + +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-6); + padding: var(--space-4) 0; +} + +.navbar__brand { + display: flex; + align-items: center; + gap: var(--space-3); + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: var(--fw-bold); + letter-spacing: -0.01em; + color: var(--fg-strong); + text-decoration: none; +} + +.navbar__links { + display: flex; + gap: var(--space-6); + list-style: none; + font-size: var(--fs-small); + font-weight: var(--fw-medium); +} +.navbar__links a { color: var(--fg-body); text-decoration: none; } +.navbar__links a:hover { color: var(--accent-600); } + +@media (max-width: 800px) { .navbar__links { display: none; } } + + +/* ===== css/web.css ===== */ +/* ========================================================================== + Corporate Deck Design System — Web layer + Patterns from the favourites set (see FAVORITES.md) that the deck layer + does not cover: gradient heroes, wave dividers, device mockups, trust bars, + tinted feature cards, ghost step rails, pricing, FAQ, marquee, CTA bands. + Requires: tokens.css, base.css, components.css + ========================================================================== */ + +/* -------------------------------------------------------------------------- + Hero surfaces + -------------------------------------------------------------------------- */ + +.hero-gradient { + position: relative; + background: var(--gradient-hero); + color: #fff; + isolation: isolate; + overflow: hidden; +} +.hero-gradient .h1, +.hero-gradient .display, +.hero-gradient .h2 { color: #fff; } +.hero-gradient .eyebrow { color: rgba(255, 255, 255, 0.75); } + +.hero-sheen { background: var(--gradient-sheen); } + +/* Soft blurred orb — the glow behind hero content. Ornamental. */ +.orb { + position: absolute; + border-radius: var(--radius-pill); + background: var(--glow-accent); + filter: blur(28px); + pointer-events: none; + z-index: -1; +} + +/* Wave divider. Sits at the bottom of a coloured hero and curves into the + next section. Give it the *next* section's background via \`color\`. */ +.wave { + display: block; + width: 100%; + height: clamp(40px, 6vw, 90px); + color: var(--surface-page); +} +.wave svg { display: block; width: 100%; height: 100%; } +.wave path { fill: currentColor; } + +/* -------------------------------------------------------------------------- + Badge pill above a headline — "NEW · Introducing X" + -------------------------------------------------------------------------- */ + +.badge-pill { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0.35rem 0.35rem 0.35rem 0.85rem; + border-radius: var(--radius-pill); + background: var(--accent-50); + color: var(--accent-700); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); +} +.badge-pill__flag { + padding: 0.2rem 0.6rem; + border-radius: var(--radius-pill); + background: var(--accent-500); + color: var(--accent-on); + font-size: 0.6875rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.hero-gradient .badge-pill { + background: rgba(255, 255, 255, 0.16); + color: #fff; +} + +/* Headline word with an accent underline rather than an accent fill. */ +.underlined { + color: var(--accent-600); + text-decoration: underline; + text-decoration-thickness: 0.09em; + text-underline-offset: 0.12em; +} + +/* Gradient-filled headline word. Use once per page at most. */ +.gradient-text { + background: var(--gradient-text); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* -------------------------------------------------------------------------- + Device mockup frames — the product-UI hero visual + -------------------------------------------------------------------------- */ + +.device { + position: relative; + background: var(--ink-900); + box-shadow: var(--shadow-xl); + overflow: hidden; +} + +.device--phone { + border-radius: 2.25rem; + padding: 0.55rem; + aspect-ratio: 1179 / 2556; + max-width: 300px; +} +.device--phone::before { /* dynamic island */ + content: ""; + position: absolute; + top: 1.1rem; left: 50%; + transform: translateX(-50%); + width: 32%; height: 1.1rem; + border-radius: var(--radius-pill); + background: var(--ink-900); + z-index: 2; +} + +.device--browser { + border-radius: var(--radius-md); + padding: 1.9rem 0.4rem 0.4rem; +} +.device--browser::before { /* traffic lights */ + content: ""; + position: absolute; + top: 0.72rem; left: 0.85rem; + width: 2.6rem; height: 0.45rem; + border-radius: var(--radius-pill); + background: + radial-gradient(circle 3.5px at 3.5px 50%, var(--ink-600) 100%, transparent 0) 0 0/1rem 100% no-repeat, + radial-gradient(circle 3.5px at 3.5px 50%, var(--ink-600) 100%, transparent 0) 0.85rem 0/1rem 100% no-repeat, + radial-gradient(circle 3.5px at 3.5px 50%, var(--ink-600) 100%, transparent 0) 1.7rem 0/1rem 100% no-repeat; +} + +.device__screen { + border-radius: inherit; + overflow: hidden; + background: var(--surface-page); + height: 100%; +} +.device--browser .device__screen { border-radius: var(--radius-sm); } +.device__screen img { width: 100%; height: 100%; object-fit: cover; object-position: top; } + +/* Small stat/notification card that floats over a mockup. */ +.float-card { + position: absolute; + z-index: 3; + background: var(--surface-page); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: var(--space-3) var(--space-4); + font-size: var(--fs-caption); + color: var(--fg-body); + max-width: 200px; +} +.float-card__value { + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: var(--fw-bold); + color: var(--fg-strong); + line-height: 1.2; +} + +/* Floating label chip — the tags pinned around a portrait. */ +.pin { + position: absolute; + z-index: 3; + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0.45rem 0.9rem; + border-radius: var(--radius-pill); + background: var(--surface-page); + box-shadow: var(--shadow-md); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + color: var(--fg-strong); + white-space: nowrap; +} +.pin--ink { background: var(--ink-900); color: #fff; } +.pin::before { + content: ""; + width: 7px; height: 7px; + border-radius: var(--radius-pill); + background: var(--accent-500); +} + +/* -------------------------------------------------------------------------- + Trust bar — "Loved by innovative teams" + logo row + -------------------------------------------------------------------------- */ + +.trustbar { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-8); + flex-wrap: wrap; +} +.trustbar__label { + font-size: var(--fs-small); + font-weight: var(--fw-medium); + color: var(--fg-muted); +} +.trustbar__logos { + display: flex; + align-items: center; + gap: var(--space-8); + flex-wrap: wrap; + list-style: none; +} +.trustbar__logos li { + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: var(--fw-bold); + color: var(--ink-400); + letter-spacing: -0.01em; +} + +/* -------------------------------------------------------------------------- + Tinted feature cards — each card a different soft tint + -------------------------------------------------------------------------- */ + +.tint-grid { display: grid; gap: var(--space-4); grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); } + +.tint-card { + padding: var(--space-5); + border-radius: var(--radius-lg); + background: var(--tint, var(--accent-50)); +} +.tint-card__title { font-family: var(--font-display); font-weight: var(--fw-bold); color: var(--fg-strong); margin-top: var(--space-3); } +.tint-card__body { font-size: var(--fs-small); color: var(--fg-body); margin-top: var(--space-2); } + +/* Four house tints. They are *neutral supports*, not second accents — each is + desaturated far enough that the accent still owns every emphasis on the page. */ +.tint-1 { --tint: var(--accent-50); } +.tint-2 { --tint: #FFF1E8; } +.tint-3 { --tint: #E9F7EF; } +.tint-4 { --tint: #EAF2FF; } + +@media (max-width: 900px) { .tint-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 560px) { .tint-grid { grid-template-columns: 1fr; } } + +/* -------------------------------------------------------------------------- + Step rail — oversized ghost numerals down a dotted centre line + Alternating sides, copy one side / visual the other. + -------------------------------------------------------------------------- */ + +.rail { position: relative; display: grid; gap: var(--space-10); } + +.rail::before { /* the dotted spine */ + content: ""; + position: absolute; + top: 0; bottom: 0; left: 50%; + border-left: 2px dotted var(--ink-200); +} + +.rail__row { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--space-6); + align-items: center; +} + +.rail__num { + font-family: var(--font-display); + font-size: clamp(3.5rem, 7vw, 6rem); + font-weight: var(--fw-black); + line-height: 1; + letter-spacing: -0.04em; + color: var(--ink-100); + background: var(--surface-page); + padding-block: var(--space-3); + font-variant-numeric: tabular-nums; + text-align: center; +} +.rail__num--label::before { + content: "STEP"; + display: block; + font-size: 0.75rem; + font-weight: var(--fw-semibold); + letter-spacing: 0.18em; + color: var(--ink-300); +} + +/* Every child needs an explicit order, or the numeral keeps its default 0 and + lands in the first column — pushing the visual into the narrow centre one. */ +.rail__row--flip .rail__visual { order: 1; } +.rail__row--flip .rail__num { order: 2; } +.rail__row--flip .rail__copy { order: 3; } + +@media (max-width: 800px) { + .rail::before { left: 18px; } + .rail__row { grid-template-columns: auto 1fr; gap: var(--space-4); } + .rail__row .rail__num { order: 1; font-size: 2.5rem; } + .rail__row .rail__copy { order: 2; } + .rail__row .rail__visual { order: 3; grid-column: 2; } +} + +/* -------------------------------------------------------------------------- + Pricing + -------------------------------------------------------------------------- */ + +.price-grid { display: grid; gap: var(--space-5); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); align-items: start; } + +.price-card { + position: relative; + padding: var(--space-6); + border: 1px solid var(--ink-100); + border-radius: var(--radius-lg); + background: var(--surface-page); +} +.price-card--featured { + border-color: var(--accent-500); + border-width: 2px; + box-shadow: var(--shadow-lg); +} +.price-card__flag { + position: absolute; + top: 0; left: 50%; + transform: translate(-50%, -50%); + padding: 0.3rem 0.9rem; + border-radius: var(--radius-pill); + background: var(--accent-500); + color: var(--accent-on); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + white-space: nowrap; +} +.price-card__amount { + font-family: var(--font-display); + font-size: 2.75rem; + font-weight: var(--fw-black); + letter-spacing: -0.035em; + color: var(--fg-strong); + line-height: 1; +} +.price-card__period { font-size: var(--fs-small); color: var(--fg-muted); } + +.checklist { list-style: none; display: grid; gap: var(--space-3); font-size: var(--fs-small); } +.checklist li { display: flex; gap: var(--space-3); align-items: flex-start; } +.checklist li::before { + content: ""; + flex: none; + width: 18px; height: 18px; + margin-top: 2px; + border-radius: var(--radius-pill); + background: var(--accent-500); + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m5 13 4 4L19 7' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/12px no-repeat, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m5 13 4 4L19 7' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/12px no-repeat, + linear-gradient(#000 0 0); + mask-composite: exclude; +} + +/* -------------------------------------------------------------------------- + FAQ accordion + -------------------------------------------------------------------------- */ + +.faq { display: grid; gap: var(--space-3); } + +.faq__item { + border: 1px solid var(--ink-100); + border-radius: var(--radius-md); + background: var(--surface-page); + padding: var(--space-4) var(--space-5); +} +.faq__item[open] { border-color: var(--accent-300); } + +.faq__q { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + cursor: pointer; + list-style: none; + font-family: var(--font-display); + font-size: var(--fs-small); + font-weight: var(--fw-semibold); + color: var(--fg-strong); +} +.faq__q::-webkit-details-marker { display: none; } +.faq__q::after { + content: "+"; + font-size: 1.25rem; + font-weight: var(--fw-regular); + color: var(--accent-600); + line-height: 1; +} +.faq__item[open] .faq__q::after { content: "\\2212"; } +.faq__a { font-size: var(--fs-small); color: var(--fg-body); margin-top: var(--space-3); } + +/* -------------------------------------------------------------------------- + Marquee strip — the scrolling keyword band + -------------------------------------------------------------------------- */ + +.marquee { + overflow: hidden; + background: var(--accent-500); + color: var(--accent-on); + padding-block: var(--space-3); +} +.marquee__track { + display: flex; + gap: var(--space-6); + width: max-content; + animation: marquee-scroll 26s linear infinite; + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: var(--fw-semibold); +} +.marquee__track > span { display: flex; align-items: center; gap: var(--space-6); } +.marquee__track > span::after { content: "\\2726"; opacity: 0.6; } + +@keyframes marquee-scroll { to { transform: translateX(-50%); } } +@media (prefers-reduced-motion: reduce) { + .marquee__track { animation: none; } +} + +/* -------------------------------------------------------------------------- + CTA band — full-width accent slab with copy left, action right + -------------------------------------------------------------------------- */ + +.cta-band { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-6); + flex-wrap: wrap; + padding: var(--space-7) var(--space-8); + border-radius: var(--radius-xl); + background: var(--accent-500); + color: var(--accent-on); +} +.cta-band--gradient { background: var(--gradient-hero); color: #fff; } +.cta-band .h3 { color: inherit; } + +/* Newsletter input group */ +.field-group { + display: flex; + gap: var(--space-2); + background: var(--surface-page); + border: 1px solid var(--ink-200); + border-radius: var(--radius-pill); + padding: 0.3rem 0.3rem 0.3rem 1.1rem; + max-width: 420px; +} +.field-group input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + font: inherit; + font-size: var(--fs-small); + color: var(--fg-strong); +} +.field-group input:focus { outline: none; } +.field-group:focus-within { border-color: var(--accent-500); } + +/* -------------------------------------------------------------------------- + Dark page variant + The favourites include a fully dark portfolio. \`on-dark\` flips foreground + roles; these few overrides carry the web components across with it. + -------------------------------------------------------------------------- */ + +.on-dark .card, +.on-dark .price-card, +.on-dark .faq__item, +.on-dark .float-card { + background: var(--ink-800); + border-color: rgba(255, 255, 255, 0.09); +} +.on-dark .tint-card { background: rgba(255, 255, 255, 0.05); } +.on-dark .rail__num { background: var(--ink-900); color: rgba(255, 255, 255, 0.12); } +.on-dark .rail::before { border-color: rgba(255, 255, 255, 0.16); } +.on-dark .trustbar__logos li { color: rgba(255, 255, 255, 0.45); } +.on-dark .badge-pill { background: rgba(255, 255, 255, 0.1); color: var(--accent-300); } +.on-dark .field-group { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.14); } +.on-dark .field-group input { color: #fff; } +.on-dark .underlined { color: var(--accent-300); } +`; diff --git a/app/tools/bundle-styles.js b/app/tools/bundle-styles.js new file mode 100644 index 0000000..cea0b0d --- /dev/null +++ b/app/tools/bundle-styles.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Bakes the design system's stylesheets into app/src/styles.js. + * + * A Worker has no filesystem at runtime, so the CSS the generated pages carry + * inline has to be part of the bundle. Generating it keeps tokens.css the one + * place a colour is defined, rather than a second copy drifting inside the + * application. + * + * node app/tools/bundle-styles.js + * + * Runs automatically before dev and deploy; CI checks the committed file is + * in step with the source. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.join(HERE, '..', '..'); +const DS = path.join(REPO, 'design-system'); +const OUT = path.join(HERE, '..', 'src', 'styles.js'); + +const SHEETS = ['tokens/tokens.css', 'css/base.css', 'css/components.css', 'css/web.css']; + +const css = SHEETS + .map(f => `/* ===== ${f} ===== */\n${fs.readFileSync(path.join(DS, f), 'utf8')}`) + .join('\n\n'); + +// A backtick or ${ in the CSS would end the template literal early. +const escaped = css.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); + +const banner = `/* Generated by app/tools/bundle-styles.js — do not edit. + * Source: ${SHEETS.map(s => 'design-system/' + s).join(', ')} + * Re-run after changing any of them. */\n\n`; + +fs.writeFileSync(OUT, `${banner}export const STYLESHEET = \`${escaped}\`;\n`); + +const check = process.argv.includes('--check'); +if (check) { + // Used by CI: a stale bundle means delivered pages carry the old colours. + const committed = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''; + if (committed !== `${banner}export const STYLESHEET = \`${escaped}\`;\n`) { + console.error('app/src/styles.js is out of date — run: node app/tools/bundle-styles.js'); + process.exit(1); + } +} + +console.log(`Wrote ${path.relative(REPO, OUT)} — ${(css.length / 1024).toFixed(0)}KB of CSS.`); diff --git a/app/wrangler.toml b/app/wrangler.toml new file mode 100644 index 0000000..d104beb --- /dev/null +++ b/app/wrangler.toml @@ -0,0 +1,51 @@ +name = "design-system" +main = "src/index.js" +compatibility_date = "2024-11-06" + +# The design system's pages are served as static assets; the Worker handles +# /auth/* and /api/*, and everything else falls through to a file. +[assets] +directory = "../design-system" +binding = "ASSETS" + +[vars] +ENVIRONMENT = "production" +BRAND_NAME = "Design System" +SUPPORT_EMAIL = "hello@example.com" +# Registration is live; charging is not. Every download is still counted, so +# the allowance is enforceable the day a payment provider is wired in. +PAYWALL = "off" +# When "on", files are held until someone confirms the job needs no further +# work from us. Off for now, so downloads are immediate. +REVIEW_BEFORE_RELEASE = "off" + +[[d1_databases]] +binding = "DB" +database_name = "design-system" +# Replace with the id printed by: npx wrangler d1 create design-system +database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID" + +# Secrets are never written here. Set each with `wrangler secret put NAME`: +# +# GOOGLE_CLIENT_ID Google Cloud console → Credentials → OAuth client +# GOOGLE_CLIENT_SECRET +# APPLE_CLIENT_ID The Services ID, e.g. com.example.designsystem.web +# APPLE_TEAM_ID Apple Developer → Membership +# APPLE_KEY_ID The key you create for Sign in with Apple +# APPLE_PRIVATE_KEY Contents of the .p8 file, newlines and all +# RESEND_API_KEY For the magic-link email +# +# Every one is optional. A provider with no credentials simply does not appear +# on the sign-in page, rather than appearing and then failing. + +[env.development] +vars = { ENVIRONMENT = "development", BRAND_NAME = "Design System (dev)", PAYWALL = "off", REVIEW_BEFORE_RELEASE = "off", SUPPORT_EMAIL = "hello@example.com" } + +[[env.development.d1_databases]] +binding = "DB" +database_name = "design-system" +database_id = "00000000-0000-0000-0000-000000000000" + +[env.development.assets] +directory = "../design-system" +binding = "ASSETS" diff --git a/design-system/DESIGN-BRIEF.md b/design-system/DESIGN-BRIEF.md new file mode 100644 index 0000000..28662ff --- /dev/null +++ b/design-system/DESIGN-BRIEF.md @@ -0,0 +1,297 @@ +# Design Brief — House Style + +**Hand this document to any designer, agency, or AI tool.** It defines the format +to keep. Anything not specified here is open to interpretation; anything +specified here is not. + +> **Before designing anything, run the intake.** Six questions in +> [`INTAKE.md`](INTAKE.md) (or click through `examples/picker.html`) decide the +> layer, accent, ground, CTA, imagery and mode — and produce a *scope* naming the +> exact files and classes for the job, so nobody re-reads the whole system to +> build one page. + +This brief was distilled from a 32-template reference set of corporate +presentation and web designs. It describes what those designs have in common — +the rules that make them read as one house style rather than 32 unrelated files. + +--- + +## 1. The one-sentence version + +> Clean corporate layouts on white, driven by **a single saturated accent colour** +> and **one dark anchor**, with heavy geometric headline type, generous rounded +> corners, oversized section numerals, and soft geometric ornament in the corners. + +If a deliverable does not read that way at a glance, it is off-brief. + +--- + +## 2. Colour + +### The rule: one accent, one anchor, lots of white + +Every document commits to **exactly one accent hue**. That hue does all the +emphasis work — buttons, icon chips, section panels, the first chart series, the +highlighted word in a headline. A second accent hue is never introduced. + +| Role | What it does | Share of the page | +|---|---|---| +| **Accent** | Every point of emphasis. The brand signal. | 10–20% | +| **Ink** (near-black / deep navy) | Headlines, dark-fill slides, gravity. | 15–25% | +| **White / off-white** | The default ground. Space to breathe. | 55–70% | +| **Grey** | Body copy, hairlines, receded chart series. | remainder | + +### Approved accent families + +Pick one per document. All nine are equally house-correct. + +| Theme | Signature | Notes | +|---|---|---| +| **Amber** | `#F5B21A` | **Default for decks.** Warmest, most-used. Dark text on top. | +| **Violet** | `#7C4DFF` | **Default for web.** Signature of the favourites set. White text on top. | +| **Electric** | `#2B50EE` | Tech, SaaS, product. White text on top. | +| **Indigo** | `#4059F0` | Fintech, payments, product. White text on top. | +| **Navy** | `#2B4A8B` | Finance, consulting, formal. White text on top. | +| **Teal** | `#22BCCE` | Product, startup, healthcare. Dark text on top. | +| **Green** | `#17A673` | Sustainability, wellness. White text on top. | +| **Lime** | `#C3DE4A` | Pairs with a cream ground, not white. Dark text on top. | +| **Coral** | `#E85D33` | Marketing, creative, retail. White text on top. | + +Full ramps (50 → 700) live in `tokens/tokens.json`. + +### Ink and surfaces + +- Ink: `#14181F` (near-black) or `#1B2333` / `#233049` for a navy-leaning anchor. +- Ground: pure `#FFFFFF`, or `#F7F8FA` / `#FAF6EC` (cream) for a softer document. +- Never grey-on-grey. Body copy sits at `#3A4761`, not lighter. + +### Hard rules + +- **Never** put two accent families in one document. +- **Never** use a gradient as a brand colour. In decks, gradients are permitted + only as a photo scrim. On web pages they may additionally fill a hero surface, + a CTA band, or a glow orb — and must run *within one accent ramp* + (`accent-600 → accent-500 → accent-300`), so it is still one hue. See + [`FAVORITES.md`](FAVORITES.md) §"Rules specific to this set". +- Accent-on-accent text must clear 4.5:1. The tokens carry the right value for + each pairing, so never hand-pick one: + - text **on `--accent-500`** → `--accent-on` (dark ink on the bright themes, + white on the deep ones) + - text **on `--accent-600`**, i.e. the primary button's hover → `--accent-on-600` + - **accent text on the page ground** — links, eyebrows, `.text-accent` → + `--accent-600`, never `--accent-500` +- **`--accent-600` is a text colour first.** Every theme's must clear 4.5:1 on + white, which is what lets it double as a fill that white sits on. When adding + a theme, darken `600` until it does — do not lighten it to match the brand + swatch. `--accent-500` is where the brand's brightness lives. + +--- + +## 3. Typography + +### The rule: one family, heavy display over quiet body + +**One geometric-grotesque family** across the whole document. Poppins or +Montserrat for display; Inter for long body copy is an acceptable pairing. Never +more than two families total, never a serif, never a script. + +| Level | Weight | Tracking | Use | +|---|---|---|---| +| Display | 800 | −0.03em | Cover titles only | +| H1 | 800 | −0.025em | Section and slide titles | +| H2 | 700 | −0.02em | Sub-heads | +| H3 | 700 | −0.01em | Card titles | +| H4 | 600 | 0 | Feature labels | +| Body | 400 | 0 | Paragraphs, 1.65 line-height | +| Small | 400 | 0 | Card copy, captions | +| **Eyebrow** | 600 | **+0.18em, UPPERCASE** | The label above a headline | +| Numeral | 800 | −0.04em | Oversized `01` `02` `03` | + +### Signature moves + +1. **The eyebrow.** A short uppercase, wide-tracked, accent-coloured label sits + above nearly every headline. This is the single most recognisable tic of the + style. Use it constantly. + +2. **The two-tone headline.** One word in the headline flips to the accent + colour, or from regular to black weight: + `Doing Everything **Right**` · `Business **Report**` · `Vision & **Mission**` + +3. **The light/heavy pair.** Cover titles stack a thin line over a black line: + `CORPORATE` (regular) over `PRESENTATION` (black). + +4. **Tabular numerals** everywhere figures appear — stats, tables, timelines. + +### Hard rules + +- Headlines are **tight** (line-height ≤ 1.12) and **negatively tracked**. +- Body is **roomy** (line-height 1.6–1.65) and never tracked. +- Body copy never exceeds ~62 characters per line. +- Never centre a paragraph longer than two lines. + +--- + +## 4. Layout & space + +- **4px base grid.** Every measurement lands on it. +- **12-column** structure for web; slides use a **6.5% safe margin** on all sides. +- The workhorse layout is the **asymmetric split** — roughly 5:7 or 7:5, headline + block on one side, content or media on the other. Rarely 50:50. +- Content sits in **cards** with 20px radius and a 32px inner pad. +- White space is a feature, not waste. When a slide feels crowded, cut content — + do not shrink the type. + +--- + +## 5. Shape language + +**Generous rounding is a signature.** Sharp corners are the exception, reserved +for full-bleed colour blocks. + +| Radius | Use | +|---|---| +| 6px | Inputs, small chips | +| 12px | Icon chips, buttons (square variant) | +| 20px | **Cards — the default** | +| 32px | Media panels, large feature blocks | +| 48px | Hero media | +| pill | Buttons, tags, avatars, meter bars | + +Recurring shape motifs, all of them ornamental and `aria-hidden`: + +- **Circles and quarter-circles** bleeding off a corner +- **Organic blobs** in accent-50 or accent-100 behind content +- **Dotted matrices** tucked into slide corners +- **Offset blocks** — a solid accent rectangle peeking out from behind a card or + photo (the "stacked paper" look) +- **The notched panel** — rounded on three corners, square on one + +--- + +## 6. Imagery + +- **Real photography** of people at work, cities, and product. No cartoon + clip-art in business decks. Flat vector illustration is acceptable only for + education and children's material. +- Photos are **masked**: rounded rectangles (32px), circles for people, or the + notched panel. Never a bare square photo dropped on a slide. +- Two permitted colour treatments: + - **Accent wash** — accent multiplied over the photo at ~55% for section covers + - **Bottom scrim** — dark gradient bottom-up when type sits over the image +- People photos are cropped to torso-up, eyes on the upper third. +- Icons are **line or solid, single weight, one style throughout**, placed in an + accent-filled chip. Never mix icon styles. + +--- + +## 7. Data & charts + +- **Series 1 is always the accent.** Everything after it recedes into greys, so + one bar or line reads as "the point". +- Gridlines are `#ECEFF3` hairlines. No chart borders, no 3D, no drop shadows. +- Label data directly on the marks where possible; a legend is a fallback. +- Big figures get the **stat treatment**: accent-coloured value at 3× the label + size, uppercase tracked label beneath. +- Percentage rings, meter bars and progress tracks all use the accent at full + saturation on an `--ink-100` track. + +--- + +## 8. Standard deck sequence + +A complete presentation follows this order. Drop sections that do not apply; +never reorder them. + +1. **Cover** — title, subtitle, presenter, date, logo +2. **Contents / agenda** — numbered, two columns +3. **Section divider** — oversized numeral + section title on an accent panel +4. **About / who we are** — split layout, photo + copy +5. **Vision & mission** — two-up cards +6. **Services / what we do** — 3 or 4 icon cards +7. **Process / how it works** — numbered steps `01 02 03` +8. **Team** — circular avatars, name + role +9. **Numbers** — stat row on a dark or accent slide +10. **Timeline / roadmap** — horizontal milestone track +11. **SWOT** — 2×2, strengths on accent, weaknesses on ink +12. **Charts / financials** — accent + grey series +13. **Testimonials** — quote mark, avatar, name + company +14. **Pricing / comparison table** — if applicable +15. **Contact / thank you** — closing, details, socials + +Section dividers reset the numeral count. Every non-cover slide carries logo +top-left, section kicker top-right, page number bottom-right. + +--- + +## 9. Deliverable checklist + +Before accepting work, verify: + +- [ ] Exactly one accent hue used throughout +- [ ] White or off-white is the dominant ground (>55%) +- [ ] Eyebrow labels present above headlines +- [ ] At least one two-tone headline on the cover +- [ ] Headline tracking is negative, body tracking is zero +- [ ] Card radius is consistent at 20px (or 32px for media) +- [ ] All photos are masked, none are bare rectangles +- [ ] Chart series 1 is the accent; the rest are grey +- [ ] Slide furniture (logo / kicker / page number) on every content slide +- [ ] Section numerals are oversized and present on dividers +- [ ] Nothing crosses the 6.5% safe margin +- [ ] Body copy ≤ 62 characters per line +- [ ] Accent-on-accent text passes 4.5:1 contrast +- [ ] `--accent-600` clears 4.5:1 on white in every theme it ships with + +--- + +## 10. What this style is not + +Stating the negative space saves rounds of revision: + +- Not brutalist, not neumorphic, not glassmorphic +- Not dark-mode-first — the ground is light +- Not maximalist; no texture, no noise, no pattern fills +- No drop shadows harder than `0 12px 32px rgba(20,24,31,0.09)` +- No outlined display type, no letterpress, no bevels +- No stock illustration of the "corporate Memphis" blob-people variety in + business material +- No more than one accent hue, ever + +--- + +## 11. Machine-readable assets + +| File | What it is | +|---|---| +| `FAVORITES.md` | The second reference set — web landing pages — and the rules specific to it | +| `tokens/tokens.json` | Platform-agnostic tokens — import into Figma, Style Dictionary, or a build | +| `tokens/tokens.css` | CSS custom properties + the nine accent themes | +| `css/base.css` | Reset and typographic primitives | +| `css/components.css` | Cards, stats, steps, timelines, SWOT, buttons, media | +| `css/slides.css` | 16:9 slide surfaces and slide archetypes | +| `css/web.css` | Web-only: gradient heroes, device mockups, rails, pricing, FAQ, marquee | +| `powerpoint/SPEC.md` | Point sizes and inch positions for a 13.333″ × 7.5″ slide | +| `examples/index.html` | Corporate web page built from the deck layer | +| `examples/deck.html` | Full slide deck built from the system | +| `examples/landing.html` | SaaS landing page built from the web layer | + +**Which layer to start from:** presentations and reports use the deck layer and +default to amber; landing pages, SaaS sites and portfolios use the web layer and +default to violet. Both share the same tokens, so a deck and a site for the same +client stay in family. + +### Prompt to hand an AI tool + +> Build this using the Corporate Deck Design System. Single accent hue +> (`{amber|violet|electric|indigo|navy|teal|green|lime|coral}`), near-black ink +> anchor, white ground. Poppins display / Inter body. Uppercase tracked eyebrow +> labels above headlines, one two-tone headline at the top, 20px card radius, +> oversized section numerals, accent-filled icon chips, masked photography, +> charts with the accent as series 1 and greys after. Import `tokens/tokens.css` +> and use the existing component classes rather than writing new CSS. +> +> For a **deck**: also import `css/slides.css`, follow the standard deck +> sequence, default to amber. +> For a **web page**: also import `css/web.css`, default to violet, use a +> gradient hero with a device mockup, a trust bar, tinted feature cards, a step +> rail, pricing with a featured tier, an FAQ, and a CTA band. diff --git a/design-system/FAVORITES.md b/design-system/FAVORITES.md new file mode 100644 index 0000000..df7a3da --- /dev/null +++ b/design-system/FAVORITES.md @@ -0,0 +1,101 @@ +# Favourite Designs + +A second reference set, added after the original 32 deck templates. These are +**web landing pages**, and they behave differently enough to warrant their own +layer (`css/web.css`) rather than being folded into the deck rules. + +Where the deck set is flat, printed, and photography-led, the favourites set is +screen-native: gradients, glow, and **product UI as the hero visual**. + +--- + +## The 12 references + +| # | Subject | Accent | What it contributes | +|---|---|---|---| +| 1 | App landing page (FR) | Violet gradient | Gradient hero + **wave divider**, dotted matrices, icon-circle trio, stat cards | +| 2 | Fintech WordPress theme (FR) | **Lime on cream** | Dark UI cards on a pale ground, **underlined accent word**, FAQ accordion, CTA slab | +| 3 | Flowora — SaaS productivity | Indigo/violet | Two-tone headline, **tinted feature cards**, stat strip, pricing with "Most Popular" | +| 4 | Nexora — product/e-commerce | Indigo→violet | Laptop + phone mockups, floating price card, **trust icons row**, oversized display type | +| 5 | Swix — developer portfolio | Violet | **Marquee strip**, floating label pins over a portrait, service list rows | +| 6 | Alex — portfolio, **dark** | Violet on near-black | Full dark variant, glow orb, numbered work grid, dark stat cards | +| 7 | Aaron Reed — portfolio | Blue | Eyebrow labels throughout, stat row, 01–04 process with arrows | +| 8 | Education landing | Pink/violet | **Ghost numerals on a dotted rail**, alternating sides, quote card with tail | +| 9 | SwiftTechBuy — how it works | Red | `STEP 01/02/03` rail with dotted connectors, circular image masks | +| 10 | Ezypay — fintech transfer | Indigo | Numbered feature sections, full-page + thumbnail presentation | +| 11 | Paynt — e-wallet | Violet | Alternating light/violet section bands, logo wall, feature card grid | +| 12 | Payment gateway | Violet | Dark violet feature band, KPI row, testimonial grid | + +**Violet is the signature of this set** — six of twelve. It is now the default +recommendation for web work, as amber is for decks. + +--- + +## What this set adds to the system + +New accent themes: `theme-violet`, `theme-indigo`, `theme-lime`. + +New components in `css/web.css`: + +| Pattern | Class | +|---|---| +| Gradient hero + soft glow orb | `.hero-gradient`, `.orb` | +| Curved section transition | `.wave` | +| "NEW · Introducing X" pill | `.badge-pill` | +| Accent-underlined headline word | `.underlined` | +| Gradient-filled headline word | `.gradient-text` | +| Phone / browser mockup frames | `.device--phone`, `.device--browser` | +| Floating stat card over a mockup | `.float-card` | +| Label chip pinned to a portrait | `.pin` | +| Logo / trust row | `.trustbar` | +| Tinted feature cards | `.tint-grid`, `.tint-card`, `.tint-1…4` | +| Ghost numerals on a dotted rail | `.rail`, `.rail__num` | +| Pricing with a featured tier | `.price-card`, `.checklist` | +| FAQ accordion | `.faq` | +| Scrolling keyword band | `.marquee` | +| CTA slab + newsletter field | `.cta-band`, `.field-group` | +| Dark page variant | `.on-dark` overrides | + +--- + +## Rules specific to this set + +The core brief still governs — one accent, ink anchor, eyebrow labels, two-tone +headlines, 20px card radius. These are the additions and the exceptions: + +1. **Gradients are allowed here, in three places only:** the hero surface, a CTA + band, and a soft glow orb. Never on a card, a button, or a logo. The gradient + runs within one accent ramp — `accent-600 → accent-500 → accent-300` — so it + is still one hue, not two. + +2. **Product UI is the hero image.** A dashboard or app screen in a device frame + replaces stock photography. Screens are cropped to the top and never + letterboxed. + +3. **Tints are supports, not second accents.** The four card tints are + desaturated far enough that the accent still owns every point of emphasis. If + a tint starts competing, it is too saturated. + +4. **The step rail replaces the deck's `.steps`** for long-form web pages: + oversized ghost numerals down a dotted spine, copy and visual alternating + sides. The deck's compact three-column version stays for slides. + +5. **Dark pages are permitted on this set only** — portfolios and product pages. + Decks stay light-ground. Use `.on-dark` on a near-black surface; the web layer + carries cards, rails and fields across. + +6. **One motion element per page maximum.** The marquee is the usual choice. + Everything honours `prefers-reduced-motion`. + +--- + +## Choosing between the two sets + +| Deliverable | Start from | Default accent | +|---|---|---| +| Presentation, PPT template, report | deck layer (`slides.css`) | amber | +| Landing page, SaaS site, portfolio | web layer (`web.css`) | violet | +| Corporate website for a deck client | web layer, deck's accent | match the deck | + +Both layers share the same tokens, so a deck and a site for the same client stay +in family — swap the theme class and the whole system follows. diff --git a/design-system/INTAKE.md b/design-system/INTAKE.md new file mode 100644 index 0000000..1b23c2e --- /dev/null +++ b/design-system/INTAKE.md @@ -0,0 +1,248 @@ +# Intake — Ask Before You Design + +Nobody starts a design in this system until these questions are answered. The +point is not paperwork: **every question below changes a concrete setting**, and +the mapping is fixed, so two different designers given the same answers produce +the same starting point. + +Run it as a conversation, not a form. Six questions, two minutes. + +> Interactive version: [`examples/picker.html`](examples/picker.html) — answer on +> screen, get a filled brief and a live preview to paste into a designer's or an +> AI tool's prompt. + +--- + +## The six questions + +### 1. What are you making? + +> *Deck · Landing page · Report · Portfolio · Pitch deck · Product site* + +**Decides: which layer, and the section set.** + +| Answer | Layer | Starting sections | +|---|---|---| +| Presentation / deck | `slides.css` | The 14-step deck sequence (brief §8) | +| Report / whitepaper | `slides.css`, print styles | Cover, contents, sections, charts, closing | +| Pitch deck | `slides.css` | Cover, problem, solution, market, model, traction, team, ask | +| Landing page | `web.css` | Hero, trust bar, features, how-it-works, stats, pricing, FAQ, CTA | +| Product site | `web.css` | Hero with device mockup, features, integrations, pricing, FAQ, CTA | +| Portfolio | `web.css` | Hero with portrait, selected work, services, process, testimonials, contact | + +--- + +### 2. Who is the audience, and what should they feel in the first five seconds? + +> *Trusted · Energised · Reassured · Impressed · Curious* + +**Decides: the accent family and the ground.** + +| Feeling | Accent | Ground | +|---|---|---| +| Trusted, established, safe | navy or indigo | white | +| Serious about money | indigo or lime | white / cream | +| Energised, optimistic, warm | amber or coral | white | +| Modern, technical, premium | violet or electric | white, or ink for a dark page | +| Calm, healthy, sustainable | green or teal | off-white | +| Bold, creative, unmissable | coral or lime | cream | + +If the answer is "I don't know", ask what their closest competitor looks like and +go one step away from it. + +--- + +### 3. Do you already have brand colours, a logo, or fonts? + +> *Yes, locked · Yes, but flexible · No, choose for me* + +**Decides: whether Q2's answer is a recommendation or a rule.** + +- **Locked** — map the brand colour to the nearest accent family and override the + `--accent-*` ramp with the real values. Keep every other token. +- **Flexible** — use Q2's recommendation, show the brand colour as an alternate. +- **None** — Q2 decides outright. + +Answering "locked" or "flexible" opens **3a — your colours** in the picker. Type +or pick any hex and it generates the full 50→700 ramp, derives the gradient, and +checks contrast, applying it to the page live so you can see it before you commit. + +The contrast check matters more than it sounds. A custom accent is the most +common way this system gets broken: a mid-tone brand colour often clears 4.5:1 +against *neither* white nor near-black, which means no text can legally sit on +it. The picker says so and tells you to use it for fills and ornament only. + +Output is a `:root` block to paste into the **project's** stylesheet. +`tokens.css` is never edited for one project. + +Whichever applies, one accent only. A brand with two colours gets the dominant +one as the accent and the second demoted to a neutral support tint. + +--- + +### 4. What is the one thing you want the reader to do? + +> *Book a call · Buy · Sign up / trial · Approve the plan · Get in touch · Just understand* + +**Decides: the CTA treatment and how often it repeats.** + +| Action | Treatment | +|---|---| +| Book a call / Get in touch | `.btn--primary` in the hero, `.cta-band` before the footer | +| Buy / Sign up / trial | Hero CTA + `.price-grid` with a featured tier + closing `.cta-band` | +| Approve the plan | No CTA band. Closing slide states the decision and the date needed. | +| Just understand | No CTA. Ends on a summary or contact slide. | + +One primary action per document. A second competing action halves both. + +--- + +### 5. What imagery do you actually have? + +> *Photography · Upload my own · Find one for me · Product screenshots · +> Illustration · Logos only · Nothing yet* + +**Decides: media treatment — and this is the question most often skipped, then +regretted.** + +| Have | Use | +|---|---| +| Photography | `.media--notched` / `.media--circle`, accent wash on section covers | +| **Upload my own** | Drop it into `picker.html` and see it in all four house treatments instantly. Read in-browser; nothing is uploaded. | +| **Find one for me** | The picker builds a search brief from your other answers and links to free-licence libraries. | +| Product screenshots | `.device--phone` / `.device--browser` with `.float-card` | +| Illustration | Flat vector on `.tint-card` grounds; no photo masks | +| Logos only | `.trustbar`, big type, `.deco` geometry carrying the visual load | +| Nothing yet | Type-led layout + geometry. **Do not** design around placeholders you cannot fill. | + +Either photo route adds a **sourcing line** to the spec — rights confirmation for +your own photo, licence and attribution for sourced stock. Full rules, size +floors and treatment guidance are in [`PHOTOS.md`](PHOTOS.md). + +--- + +### 6. Light or dark — and where does this live? + +> *Light · Dark · Both · Screen only · Printed · Projected* + +**Decides: surface, contrast floor, and export.** + +- **Dark** is available on web only (portfolio, product). Decks stay light. +- **Projected** → raise contrast, no hairlines under 2px, no body text under 15pt. +- **Printed** → light ground, CMYK-safe accent, hairlines at 0.75pt minimum. +- **Both** → build light first, then apply `.on-dark`; never design them separately. + +--- + +--- + +## 6a. Anything else to hand? *(optional)* + +> *Logo · Copy / content · Existing material* + +Three separate slots in the picker, on purpose. A combined "upload your stuff" +box loses what makes each one different. + +### Logo + +Previewed on **white, on ink, and on your accent** simultaneously — because the +usual failure is a logo that looks fine in the navbar and vanishes in the dark +footer. The picker flags: + +- **JPG** — no transparency, so it sits in a white box on every dark surface. + SVG, or PNG with an alpha channel. +- **Aspect** — a wide wordmark works in the navbar and slide furniture but not in + a square avatar slot; a square mark is the opposite. Most brands need both. +- **Resolution** — under 400px raster will blur on retina and badly when + projected. 800px+, or SVG. +- **A light (knockout) variant** — if the logo disappears on the ink or accent + preview, you need one. Most brands have it and forget to send it. + +### Copy / content + +Paste the real words or drop a `.txt` / `.md`. **Designing against real copy +instead of lorem ipsum is the cheapest quality win available** — placeholder text +hides every length problem, and length problems are what break layouts. + +The picker flags lines over 62 characters (the body measure), and headings over +~45 characters, which stop working as display lines at this weight and tracking. + +### Existing material + +Current site, old deck, brand guidelines. Filenames are listed only — nothing is +read or uploaded. This exists so the material lands in the project folder instead +of a chat thread. + +--- + +## Three more, only if it is a deck + +7. **How long, and how long do you have to present it?** — roughly one slide per + minute of talking. If the answers don't reconcile, cut sections now, not later. +8. **Who presents it, and do they need speaker notes?** +9. **Will anyone edit it after you?** — if yes, ship a `.potx` with proper slide + master layouts, not a finished `.pptx`. + +--- + +## The output is a *scope*, not a restatement + +The answers collapse into a targeted work order that names the exact files and +classes for this one job — and explicitly rules out everything else. + +This matters for cost as much as for quality. The system is already written; a +handover that re-reads or regenerates the tokens and components on every job +pays for the same work repeatedly. **The intake output exists to stop that.** + +> **Deliverable:** landing page · **Layer:** web · **Accent:** violet (flexible) · +> **Ground:** white · **Primary action:** start free trial → hero CTA + featured +> pricing tier + closing CTA band · **Imagery:** product screenshots in browser and +> phone frames · **Mode:** light, screen · **Sections:** hero, trust bar, features, +> how-it-works, stats, pricing, FAQ, CTA. +> +> **━━ SCOPE — touch only what is listed ━━** +> +> **Import (already written, do not modify):** +> `tokens/tokens.css`, `css/base.css`, `css/components.css`, `css/web.css` +> +> **Write exactly one new file:** the page markup. +> **Do not** re-read, regenerate or restate the tokens, the components, or the +> rest of the system. They exist and they work. +> +> **Classes to compose from:** `.hero-gradient` `.orb` `.wave` `.badge-pill` +> `.trustbar` `.tint-grid` `.rail` `.stat-row` `.price-grid` `.faq` `.cta-band` +> `.device--browser` `.device--phone` `.float-card` +> +> **Theme:** `` +> +> **Read only:** `FAVORITES.md` §"Rules specific to this set". Skip the rest of +> the docs — that line is the whole brief needed. +> +> **Rules that still apply:** one accent hue; eyebrow label above each headline; +> one two-tone headline at the top; 20px card radius; charts use the accent as +> series 1 and greys after. + +### Why the scope block is worded that way + +| Line | What it prevents | +|---|---| +| "already written, do not modify" | Rewriting tokens that other work depends on | +| "write exactly one new file" | Sprawl across the system for a single page | +| "do not re-read … the rest of the system" | Re-processing thousands of lines to build one page | +| "classes to compose from" | Inventing parallel CSS that drifts from the house style | +| "read only §X" | Consuming the full brief when six lines would do | + +A designer who needs more than this list should ask for it, rather than reading +everything by default on the chance it matters. + +--- + +## Why refuse to start without this + +Every one of these questions maps to a decision that is expensive to reverse +late. Choosing an accent after the photography is picked, or discovering at +handover that the deck will be projected, means redoing work that was already +approved. Two minutes here is the cheapest part of the project. + +If someone will not answer Q5, assume "nothing yet" and design type-led. That +assumption is recoverable. Designing around imagery that never arrives is not. diff --git a/design-system/PHOTOS.md b/design-system/PHOTOS.md new file mode 100644 index 0000000..9a09994 --- /dev/null +++ b/design-system/PHOTOS.md @@ -0,0 +1,132 @@ +# Photography — sourcing and treatment + +Two routes into the system: **your own photo**, or **one sourced to brief**. Both +end in the same place — masked to a house treatment, never dropped in raw. + +Run either from `examples/picker.html` (question 5 → "Upload my own" or "Find one +for me"). + +--- + +## Route 1 — your own photo + +Drop it into the picker and it renders immediately in the four house treatments, +in your accent theme. The file is read with `FileReader` in the browser and is +never uploaded anywhere. + +Before it ships, confirm two things: + +- **You hold the rights.** A photo taken by a contractor is usually theirs unless + the contract assigns it. Commissioned ≠ owned. +- **Anyone recognisable has signed a release**, if the page is advertising. This + covers staff photos used on a sales page, not just models. + +### Size floor + +| Use | Minimum width | +|---|---| +| Card / thumbnail | 800px | +| Section media panel | 1200px | +| Full-bleed web hero | 1600px | +| Projected slide | 2400px | + +Below the floor the image softens on retina displays. The picker flags this +automatically once it can read the dimensions. + +--- + +## Route 2 — sourced to brief + +The picker builds a search query from your other answers — subject from what +you're making, mood from what the audience should feel, plus composition +constraints the system needs: + +> *creative professional at work, studio desk, portrait — natural light, neutral +> muted tones, calm, unposed, landscape 16:9, negative space on the right for a +> headline, no text or logos in frame, no heavy filter* + +The last three clauses matter more than people expect. Headlines sit **over** +imagery in this system, so a photo with a busy centre has nowhere for type to go, +and a photo with baked-in text or a heavy colour filter fights the accent. + +### Where to source + +| Library | Licence | +|---|---| +| [Unsplash](https://unsplash.com) | Unsplash Licence — free, commercial use, no attribution required (give it anyway) | +| [Pexels](https://www.pexels.com) | Pexels Licence — free, commercial use | +| [Pixabay](https://pixabay.com) | Pixabay Content Licence — free, commercial use | + +Paid stock (Getty, Adobe Stock, Stocksy) is fine when the licence is bought and +the deliverable falls inside it. + +### Where **not** to source + +This is the part that causes real trouble, so it is stated plainly: + +- **Not** from an image search results page. Those results are indexed from + sites that own them; the search engine is not licensing them to you. +- **Not** from a competitor's site, a press page, or a social feed. +- **Not** a paid-stock preview with the watermark removed or AI-inpainted. +- **Not** an AI upscale of a low-res image you found somewhere. + +A takedown notice lands on the client, not the designer who sourced it. One +minute checking a licence is cheaper than the alternative. + +### Check the photo, not the site + +Free libraries carry a small number of **editorial-only** images — recognisable +people, branded products, trademarked buildings. Those cannot be used to sell +anything. The licence is stated on the individual photo page. + +### Record what you used + +Keep this line next to every asset, in the repo or the asset folder: + +``` +hero-workspace.jpg — Unsplash — © Jane Okonkwo — unsplash.com/photos/XXXXXXX — Unsplash Licence — 2026-08-03 +``` + +If a photo's provenance cannot be reconstructed later, it cannot be defended +later either. + +--- + +## Treatment — how a photo enters the system + +Photos are **always masked**. A bare rectangle is the single most common way this +style gets broken. + +| Class | Use | +|---|---| +| `.media--notched` | Rounded on three corners, square on one. The default section panel. | +| `.media--circle` | People. Team grids, testimonials, avatars. | +| `.media--wash` | Accent multiplied at ~55%. Section covers and dividers. | +| `.media--dim` | Dark bottom-up scrim. **Required** whenever type sits over the image. | +| `.media--xl` | Plain 32px radius, for a hero panel with no type over it. | + +### Rules + +1. **Type over a photo always gets `.media--dim`.** Carry the contrast in the + scrim, never by lightening the type — light-grey headlines fail on a + projector and fail WCAG. +2. **Crop people torso-up, eyes on the upper third.** Full-body shots read as + stock; tight crops read as real. +3. **One treatment per section.** Mixing a wash and a scrim in the same block + makes two photos look like an accident. +4. **No filters beyond the two above.** No duotone beyond `.media--wash`, no + vignettes, no grain. +5. **Object-fit is `cover`, object-position is `top`** for screenshots, `center` + for people. Never letterbox. + +--- + +## If you have no photo yet + +Answer "Nothing yet" and design type-led with geometry — `.deco-circle`, +`.deco-dots`, `.offset-block`, big display type on tinted grounds. This is a +first-class route in the system, not a fallback, and the reference set includes +pages that work this way. + +Designing around imagery that never arrives is the one imagery mistake that +cannot be recovered cheaply. diff --git a/design-system/README.md b/design-system/README.md new file mode 100644 index 0000000..0befe70 --- /dev/null +++ b/design-system/README.md @@ -0,0 +1,327 @@ +# Corporate Deck Design System + +A codified version of my house style, so that a website, a PowerPoint template, +or anything I commission from a designer comes back looking like it belongs to +the same family. + +Distilled from a 32-template reference set of corporate presentation and web +designs. The system captures what those files have in common — one saturated +accent hue, a near-black anchor, heavy geometric headline type, generous rounded +corners, oversized section numerals, and soft geometric ornament. + +--- + +## What's here + +``` +design-system/ +├── index.html ← the site's front door — routes three audiences +├── brief.html ← CLIENT-facing intake: four plain questions, no code +├── brief-standalone.html ← the same, as one file you can email +├── SHARING.md ← how anyone else uses this (link / file / repo) +├── SELLING.md ← approval → payment → delivery, and its real limits +├── ROADMAP.md ← ⏳ the systems marketplace, once we have 10+ systems +├── INTAKE.md ← START HERE — six questions asked before any design +├── DESIGN-BRIEF.md ← hand this to any designer or AI tool +├── FAVORITES.md ← the web-landing reference set + its extra rules +├── PHOTOS.md ← sourcing, licensing and photo treatment rules +├── tokens/ +│ ├── tokens.json ← platform-agnostic tokens (Figma, Style Dictionary, builds) +│ └── tokens.css ← CSS custom properties + the nine accent themes +├── css/ +│ ├── base.css ← reset + typographic primitives +│ ├── components.css ← cards, stats, steps, timelines, SWOT, buttons, media +│ ├── slides.css ← 16:9 slide surfaces and slide archetypes +│ └── web.css ← gradient heroes, device mockups, rails, pricing, FAQ +├── powerpoint/ +│ └── SPEC.md ← pt sizes and inch positions for a 13.333" × 7.5" slide +├── tools/ +│ └── build-standalone.js ← rebuilds the one-file intake after CSS changes +├── examples/ ← reference demos of the system; these don't change +│ ├── picker.html ← the intake, interactive → outputs a scope +│ ├── intake-standalone.html ← the intake as ONE file, works offline +│ ├── index.html ← corporate web page (deck layer) +│ ├── deck.html ← 14-slide deck +│ └── landing.html ← SaaS landing page (web layer) +└── projects/ ← one folder per request — everything commissioned + ├── README.md ← the naming and asset-folder convention + └── 001-portfolio-marin/ + ├── SPEC.md ← intake answers + the scope they produced + └── index.html +``` + +## Giving it to someone else + +Three routes, differing only in what the other person has to install — see +[`SHARING.md`](SHARING.md). + +| Route | They need | Best for | +|---|---|---| +| **A link** — `index.html` as a site | A browser | Clients, one-off collaborators | +| **A file** — `brief-standalone.html` | A browser | Email, offline, no internet | +| **The repo** | git | Designers building with it repeatedly | + +A GitHub Pages workflow ships in `.github/workflows/pages.yml`. It needs one +switch flipped once — **Settings → Pages → Source → "GitHub Actions"** — after +which the site is live at `https://.github.io//`. + +The intake's **Download starter file** button is what makes this useful to +someone without the repo: instead of a scope telling them to import files they +don't have, they get a single self-contained HTML file with the CSS inlined, +their accent baked in, and a scaffold of their chosen sections. + +### Two front doors + +`brief.html` is for **clients**: four plain questions, a row of looks to choose +from, and no file names, class names or CSS anywhere on the page. It copes with +having no logo (describe it instead) and no copy (answer six questions and it +drafts the page from your own words), and it reads a `.docx` in the browser +without uploading it. + +`examples/picker.html` is the same job for **designers and AI tools** — layers, +accent hex, class names, the scope block. + +### Approving and paying + +The client sees their page, approves it, and gets a `.zip` with the page, their +words as a separate document, the brief and a short guide — written in the +browser with no server involved. + +Checkout hands off to a payment provider. **There is no card form, deliberately:** +a static page can't verify a payment or hold card details safely. Leave the +provider unset and the button emails you the brief instead of pretending a +checkout exists. [`SELLING.md`](SELLING.md) covers the setup and the one real +limitation — a static page cannot gate a download behind a payment, so the +provider should deliver the file. + +## Starting a new design + +Don't pick a layout first — **answer the six questions in +[`INTAKE.md`](INTAKE.md)**, or run [`examples/picker.html`](examples/picker.html) +and click through them. Each answer maps to a fixed setting, so the same answers +always produce the same starting point. + +The output is a **scope**, not a restatement: it names the exact files to import, +the exact classes to compose from, the one doc section to read — and says +explicitly not to re-read or regenerate the rest of the system. That keeps each +new page or deck cheap to produce and stops parallel CSS drifting away from the +house style. + +### Photos + +Question 5 offers two routes beyond "I already have some": + +- **Upload my own** — drop a file into the picker and it renders immediately in + all four house treatments (notched, circle, accent wash, dark scrim with type + over it), in your accent theme. The file is read with `FileReader` and never + leaves the browser. The picker flags it if the resolution is below the floor + for the use, or if the crop is wrong for a hero. +- **Find one for me** — the picker builds a search brief from your other answers + (subject from what you're making, mood from what the audience should feel, plus + the composition constraints the system needs — 16:9, negative space for the + headline, no baked-in text) and links straight into free-licence libraries. + +Sourced imagery goes through free-licence libraries or bought stock only. Pulling +images off a search results page, a competitor's site or a social feed is how a +takedown notice ends up with the client. [`PHOTOS.md`](PHOTOS.md) has the +licensing rules, the resolution floors, and the treatment rules. + +### Colours and assets + +Say you have brand colours and the picker opens a **colour module**: type any hex +and it generates the full 50→700 ramp, derives the gradient, checks contrast, and +applies it to the page live. It outputs a `:root` block for the **project's** +stylesheet — `tokens.css` is never edited for one project. + +The contrast check is the point. A mid-tone brand colour often clears 4.5:1 +against neither white nor near-black, meaning no text can sit on it at all. Better +to learn that in the picker than in review. + +There's also an optional **assets module** with three separate slots — logo, copy, +and existing material. Separate because the rules differ: a logo is previewed on +white, ink *and* accent at once (the usual failure is one that looks fine in the +navbar and vanishes in the dark footer), copy is checked against the 62-character +body measure, and reference files are listed by name only. + +### A worked example + +`projects/001-portfolio-marin/` was built from a real intake run — *portfolio site · +trusted/established · buy or sign up · illustration* — and shows what the process +catches. The portfolio archetype normally opens on a **portrait hero with label +pins**, but the imagery answer was *illustration, no photography*. The imagery +answer wins: no portrait, no photo masks, inline SVG on tinted grounds instead. +That collision would have surfaced as a revision round; here it surfaced before +anything was drawn. + +The whole page is one file. It imports the system, composes from existing +classes, and adds five lines of scoped CSS for one hero-specific grid fix — +nothing in `css/` or `tokens/` was touched. + +Open any example file directly in a browser — no build step, no dependencies, +no network requests. + +### Two layers, one set of tokens + +| Deliverable | Import | Default accent | +|---|---|---| +| Presentation, PPT template, report | `+ css/slides.css` | **amber** | +| Landing page, SaaS site, portfolio | `+ css/web.css` | **violet** | + +Both share `tokens.css`, so a deck and a site for the same client stay in family. +See [`FAVORITES.md`](FAVORITES.md) for what the web layer adds and the three +rules that differ (gradients, product-UI heroes, dark pages). + +--- + +## Using it on the web + +```html + + + + + + + + +``` + +Then compose from the existing classes: + +```html +
+
+
+ What we do +

Four practices, one rhythm

+
+
+
+
+ +

Planning

+

Plans that survive the calendar.

+
+ +
+
+
+``` + +### Re-theming + +Nine accent families ship with the system. Swap the class on `` — or on any +container for a scoped change — and everything downstream follows, including +gradients, charts, buttons, icon chips and section panels. + +```html + + +``` + +Every example file includes a live theme switcher so you can see this happen. + +### Dark blocks + +Add `on-dark` alongside a dark surface and the foreground roles flip. Components +need no dark-specific variants of their own. + +```html +
+``` + +--- + +## Building slides + +A `.slide` is a self-contained 16:9 canvas. Everything inside sizes in container +query units, so a slide is identical full-screen or as a thumbnail in a grid. + +```html +
+ +
Who we are
+
04
+
+
+
+ About us +

Let's talk about who we are

+
+

+
+
…
+
+
+
+``` + +Printing `examples/deck.html` gives one slide per landscape page. + +Slide archetypes provided: cover (split / bleed), agenda, section divider, split +with media, card grids, numbered steps, stat row, timeline, SWOT, chart, team, +testimonial, closing. + +--- + +## Building a PowerPoint template + +`powerpoint/SPEC.md` has everything needed to build a `.potx`: + +- theme colour slot mapping for all six accent families +- the type scale in points, with pre-computed tracking values +- corner radii as PowerPoint `adj` values +- the single approved shadow preset +- the 14 slide-master layouts and where the furniture sits +- chart formatting rules + +--- + +## The rules, in brief + +The full brief is in [`DESIGN-BRIEF.md`](DESIGN-BRIEF.md). The short version: + +- **One accent hue per document.** Never two. It carries every point of emphasis. +- **White or off-white dominates** — 55–70% of the page. +- **Uppercase tracked eyebrow labels** above headlines. The most recognisable tic. +- **Two-tone headlines** — one word flips to the accent, or from regular to black. +- **Headlines tight and negatively tracked; body roomy and untracked.** +- **20px card radius**, 32px on media. Sharp corners only on full-bleed colour. +- **Photos are always masked** — rounded, circular, or notched. Never bare. +- **Chart series 1 is the accent**, everything after it is grey. +- **Oversized numerals** on section dividers and process steps. + +And the negatives, which save revision rounds: not brutalist, not neumorphic, not +glassmorphic, not dark-mode-first, no texture or noise, no gradients as brand +colour, no hard shadows, no mixed icon styles. + +--- + +## Handing this to someone + +For a human designer: send `DESIGN-BRIEF.md`, plus `powerpoint/SPEC.md` if the +deliverable is a deck template. The checklist in §9 of the brief is the +acceptance criteria. + +For an AI tool, the prompt at the end of the brief works as-is: + +> Build this using the Corporate Deck Design System. Single accent hue +> (`{amber|electric|navy|teal|green|coral}`), near-black ink anchor, white ground. +> Poppins display / Inter body. Uppercase tracked eyebrow labels above headlines, +> one two-tone headline on the cover, 20px card radius, oversized section +> numerals, accent-filled icon chips, masked photography, charts with the accent +> as series 1 and greys after. Follow the standard deck sequence. Import +> `tokens/tokens.css` and use the existing component classes rather than writing +> new CSS. + +--- + +## Notes + +- **Fonts.** Poppins and Inter are the intended families; the stacks in + `tokens.css` fall back through Montserrat and Segoe UI to system sans, so + nothing breaks if they are unavailable. Load the real fonts in production. +- **Accessibility.** `--accent-on` is set per theme to the text colour that + clears 4.5:1 on that accent. Use it rather than hard-coding white or black. + Decorative geometry is `aria-hidden` throughout. +- **No dependencies.** Plain CSS, no build step, no framework, no network calls. diff --git a/design-system/ROADMAP.md b/design-system/ROADMAP.md new file mode 100644 index 0000000..258a807 --- /dev/null +++ b/design-system/ROADMAP.md @@ -0,0 +1,62 @@ +# Roadmap + +## ⏳ Reminder — build the systems marketplace + +**Trigger: once we have 10 or more finished systems.** Current count: **1** +(this one — the Corporate Deck system). + +The plan, recorded so it isn't lost between sessions: + +> Build a website that sells design systems. Each system is a different style +> and a different kind of build — not variations of this one. Ten or more, +> each priced fairly, sold from one place. + +### Why the count matters + +One system is a portfolio piece. Ten is a catalogue, and a catalogue is what +justifies a storefront: browsing, comparing and filtering only mean something +when there's enough to browse. Building the shop first means maintaining an +empty shop. + +### Where the count lives + +`projects/README.md` indexes commissioned work. **Systems are different from +projects** — a system is reusable, a project is one deliverable built from one. +When system #2 exists, add it to the table below. + +| # | System | Style | Kinds of build it covers | Status | +|---|---|---|---|---| +| 01 | Corporate Deck | Clean corporate, one accent, geometric | Decks · PowerPoint · landing pages · portfolios | Built | +| 02 | — | — | — | — | + +### What the marketplace needs when the time comes + +Noted now while the thinking is fresh, not to be built yet: + +- **A live preview per system** — the intake pages already prove people decide + by looking, not by reading a feature list. +- **Consistent packaging.** Every system should ship the same shape: tokens, a + CSS layer, a brief, an intake, examples. That consistency is what makes them + comparable and priceable. +- **A fair price per system**, not a subscription. These are tools, not a + service — someone buys one, uses it, and owns it. +- **Delivery through a payment provider**, for the reason in `SELLING.md`: a + static page can't verify a payment, so the provider has to release the file. +- **Licence terms per system** — who may use it, on how many projects, whether + they may resell derivatives. Decide this once and apply it to all of them. + +### Keeping the systems comparable + +The strongest version of this is **ten genuinely different systems**, not ten +recolours. Each should differ in more than palette: + +- a different type personality (grotesque / serif / mono / condensed) +- a different structural idea (grid-led, editorial, brutalist, dense-data) +- a different job (marketing, documentation, dashboards, print, events) + +If two systems would give a client the same-looking result, they are one system +with two themes — and belong in the same product. + +--- + +*Raise this again once the table above reaches ten rows.* diff --git a/design-system/SELLING.md b/design-system/SELLING.md new file mode 100644 index 0000000..9e900d3 --- /dev/null +++ b/design-system/SELLING.md @@ -0,0 +1,118 @@ +# Selling from the brief page + +The client answers four questions, looks at their page, approves it, and gets +their files. This is how that last step actually works — and the one thing it +deliberately does not do. + +--- + +## The flow + +1. **Four questions** — `brief.html` +2. **See my page** — opens their real page in a new tab +3. **Approve** — they confirm they're happy, which unlocks the rest +4. **Continue** — goes to your payment provider +5. **Files** — a `.zip` containing the page, the copy, the brief and a guide + +--- + +## What's in the package + +Built in the browser, no server involved. A `.zip` written by hand — CRC-32, +per-file headers, central directory — so the page keeps its no-dependency +promise. Verified against `unzip -t` and Python's `zipfile`. + +| File | What it is | +|---|---| +| `index.html` | The finished page. Self-contained; opens anywhere. | +| `copy.md` | The words on their own, so they can edit without touching markup. | +| `BRIEF.md` | What they chose, dated. Their record and yours. | +| `README.md` | Plain-English guide: changing words, putting it online. | + +--- + +## Payment: there is no card form, on purpose + +**A static page cannot take card details safely and cannot verify a payment.** +It has no server, so there is nothing to validate a charge against and nothing +to keep a secret in. A card form here would be, at best, theatre — and at worst +it would put you inside PCI scope for handling card numbers you have no +infrastructure to protect. + +So checkout hands off to a provider that does this properly: + +```js +const SHOP = { + currency: '£', + prices: { website: 450, landing: 350, deck: 250, portfolio: 400 }, + CHECKOUT: '', // ← your payment link + CONTACT: 'hello@example.com', +}; +``` + +Set `CHECKOUT` to a payment link from any of these — all of them work from a +static page and all handle tax, receipts and refunds for you: + +| Provider | Good for | +|---|---| +| **Stripe Payment Links** | Cheapest per transaction; you handle delivery | +| **Gumroad** | Simplest; hosts the file and delivers it automatically | +| **Lemon Squeezy** / **Paddle** | Merchant of record — they handle VAT and sales tax | + +**Leave `CHECKOUT` empty and nothing pretends.** The button starts an email to +you with their brief in it, and says plainly that payment isn't set up yet. + +--- + +## The honest limitation + +**A download in a static page cannot be gated behind a payment.** Anyone who +can open the page can reach the file. The approval checkbox is a confirmation +step, not a lock. + +Three ways to handle that, in order of effort: + +### 1. Let the provider deliver (simplest, recommended) +Gumroad and Lemon Squeezy host the file and release it only after payment. Turn +off the in-page download and let checkout do the work. No code needed. + +### 2. Send it yourself +Keep the in-page download for *your* use. The client's "Continue" emails you the +brief; you reply with an invoice, then send the zip. Fine at low volume and it +gives you a moment of human contact before delivery. + +### 3. Add a small backend +A function that verifies a Stripe webhook and returns a signed, expiring link. +Worth it only once volume makes options 1 and 2 annoying. + +Whichever you pick, don't add a fake lock in the page. It stops nobody who +looks, and it misleads everyone who doesn't. + +--- + +## Pricing + +`SHOP.prices` is keyed by what they chose: + +```js +prices: { website: 450, landing: 350, deck: 250, portfolio: 400 } +``` + +Two things worth saying out loud in the order summary, because both remove a +common hesitation: + +- **One payment, not a subscription.** The files are theirs. +- **Nothing phones home.** Plain files that keep working whether or not you do. + +--- + +## Before you take money for this + +- **Say what a revision costs**, or how many are included. It's the first thing + clients ask and the first thing that sours a job. +- **Be clear the copy is a draft.** The package says so; make sure your terms do + too, so nobody expects a copywriter. +- **Sort out the logo case.** If they described a logo rather than supplying + one, someone has to draw it. Either price that in or exclude it explicitly. +- **Check the imagery rules** in [`PHOTOS.md`](PHOTOS.md). Selling a page with an + unlicensed photo in it makes their problem your problem. diff --git a/design-system/SHARING.md b/design-system/SHARING.md new file mode 100644 index 0000000..9ef4170 --- /dev/null +++ b/design-system/SHARING.md @@ -0,0 +1,142 @@ +# Sharing this system + +Three ways someone other than you can use it. They differ in what the other +person has to install — which is the only thing that matters when you're trying +to get a designer or a client to actually use something. + +| Route | They need | Best for | +|---|---|---| +| **1. A link** | A browser | Clients, one-off collaborators, anyone you don't want to onboard | +| **2. A file** | A browser | Sending in an email; working offline; no internet at all | +| **3. The repo** | git | Designers and developers building with the system repeatedly | + +--- + +## 1. A link — the website + +`design-system/` is a complete static site. `index.html` routes three audiences +(commissioning / designing / using AI) to the right starting point. + +### Publish it with GitHub Pages + +`.github/workflows/pages.yml` deploys it on every push to `main`. **One switch is +needed first**, and only you can flip it: + +> Repo **Settings → Pages → Source → "GitHub Actions"** + +After that the site is live at: + +``` +https://.github.io// +``` + +The workflow also gates the build: it fails if any HTML picks up an external +`src`/`href`, because the whole point is that this works offline and behind a +strict CSP. + +### Or host it anywhere + +It's static files with no build step. Drag `design-system/` onto Netlify, Vercel, +Cloudflare Pages, S3, or any web server. Nothing to configure. + +--- + +### Two front doors, on purpose + +| Page | Who it's for | What it shows | +|---|---|---| +| **`brief.html`** | Clients and anyone commissioning | Four plain questions, a row of looks to pick from, and somewhere to add a logo and words. **No file names, no class names, no CSS.** | +| **`examples/picker.html`** | Designers, developers, AI tools | The same job in technical terms — layer, accent hex, classes, the scope block. | + +They produce the same thing. Send a client the technical one and you'll get +silence; send them `brief.html` and you'll get a brief back. + +`brief.html` also handles the two cases that stall most projects: + +- **No logo?** They describe what they want instead — name, rough style, a + sentence about the feel. +- **No copy written?** They answer six short questions about the business and it + drafts the page from their own words. Labelled as a first draft everywhere it + appears, because that is what it is. +- **Got a Word file?** It reads `.docx` in the browser — the zip is unpacked and + inflated with the browser's own decompression, so nothing is uploaded. + +## 2. A file — the standalone intake + +`examples/intake-standalone.html` is the entire intake in **one file**, every +stylesheet inlined, no network requests. Email it, put it on a shared drive, open +it from a USB stick. It works. + +`brief-standalone.html` is the same for the client-facing version. + +Regenerate both after changing any CSS: + +```bash +node tools/build-standalone.js +``` + +### And what they get out of it + +The intake's **"Download starter file"** button is the piece that makes this +useful to someone without the repo. Instead of a scope that says *"import +`tokens/tokens.css`"* — which they don't have — it hands them a single +self-contained HTML file with: + +- every stylesheet inlined +- their chosen accent baked in as a `:root` override +- a scaffold of the sections their answers selected +- a comment header recording the spec that produced it + +They open it in a browser and start editing. No clone, no build, no npm. + +> The button needs the stylesheets readable, so it works on the **hosted site** +> and in the **standalone file**. Opening `examples/picker.html` directly from +> disk (`file://`) blocks `fetch`, and the button will say so rather than fail +> silently. + +--- + +## 3. The repo — for people building with it + +Designers and developers who'll use the system repeatedly should clone it and +read [`DESIGN-BRIEF.md`](DESIGN-BRIEF.md). + +Their working loop: + +1. Run the intake, save the output as `projects/NNN-name/SPEC.md` +2. Write `index.html` against the scope in that file — **and nothing beyond it** +3. Never edit `css/` or `tokens/` for one project + +That third rule is what keeps the system a system. A project that genuinely needs +a new component is a change to the system, made deliberately and separately — not +a side effect of one page. + +--- + +## What to send whom + +**A client, or someone commissioning work** +Send `brief.html` — or `brief-standalone.html` as an attachment. Four questions, +no jargon, and a way through even if they have no logo and nothing written. They +send back a page with the brief embedded in it. + +**A designer or agency** +Send `DESIGN-BRIEF.md` plus the filled-in spec. Add `powerpoint/SPEC.md` if the +deliverable is a deck template. §9 of the brief is your acceptance checklist. + +**An AI tool** +Paste the scope block the intake produces. It names the exact files, the exact +classes, and instructs the tool not to regenerate the system — which keeps output +consistent and stops it burning context re-deriving what already exists. + +--- + +## Licensing, if you share it publicly + +The system itself is your work. Two things to keep straight if it goes public: + +- **Fonts.** Poppins and Inter are both SIL Open Font License — free to bundle + and serve. The stacks fall back to system fonts, so nothing breaks if you + don't ship them. +- **Photography.** No images are bundled. Anything a user adds is theirs, under + the rules in [`PHOTOS.md`](PHOTOS.md). diff --git a/design-system/brief-shareable.html b/design-system/brief-shareable.html new file mode 100644 index 0000000..b11a513 --- /dev/null +++ b/design-system/brief-shareable.html @@ -0,0 +1,3041 @@ +Try it — build your page in four questions + + +
+
+ Try it +

+ Answer four questions and you'll have a real page to look at. Nothing you + type or upload leaves your computer — there's no account and no server + behind this. +

+
+
+ + +
+ + + +
+ Project brief +

Tell us about your project

+

+ Four questions. There's a way through every one of them even if you don't + have a logo or your words written yet — just say so and we'll handle it. + Nothing you add here is uploaded anywhere; it stays on your computer. +

+
+ + +
+
+
1

What do you need?

+

Pick the closest one. We can adjust later.

+
+
+ + + + +
+
+ + +
+
+
2

Which look do you like?

+

+ Pick whichever feels right — there's no wrong answer, and every one of + these is checked so your text stays easy to read. +

+
+
+
+ + +
+
+
3

Your logo

+

Have one? Add it. Don't? Describe what you'd like and we'll take it from there.

+
+
+ + + +
+ + + + +
+ + +
+
+
4

Your words

+

Send us what you've written, or answer a few questions and we'll write a first draft for you.

+
+
+ + + +
+ + + + +
+ + + + + +
+
+
+ Your brief +
+ 0 of 4 +
+
+

Answer the questions above and your brief appears here.

+
+
+ + + +
+

+
+
+ + + + +
+ + + + +
+
+
?

What did you think?

+

Honest answers are more useful than kind ones. Nothing here is sent anywhere — you'll get a summary to copy and send back.

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ + + + diff --git a/design-system/brief-standalone.html b/design-system/brief-standalone.html new file mode 100644 index 0000000..91656d2 --- /dev/null +++ b/design-system/brief-standalone.html @@ -0,0 +1,2926 @@ + + + + + +Tell us about your project + + + + + +
+ + + +
+ Project brief +

Tell us about your project

+

+ Four questions. There's a way through every one of them even if you don't + have a logo or your words written yet — just say so and we'll handle it. + Nothing you add here is uploaded anywhere; it stays on your computer. +

+
+ + +
+
+
1

What do you need?

+

Pick the closest one. We can adjust later.

+
+
+ + + + +
+
+ + +
+
+
2

Which look do you like?

+

+ Pick whichever feels right — there's no wrong answer, and every one of + these is checked so your text stays easy to read. +

+
+
+
+ + +
+
+
3

Your logo

+

Have one? Add it. Don't? Describe what you'd like and we'll take it from there.

+
+
+ + + +
+ + + + +
+ + +
+
+
4

Your words

+

Send us what you've written, or answer a few questions and we'll write a first draft for you.

+
+
+ + + +
+ + + + +
+ + + + + +
+
+
+ Your brief +
+ 0 of 4 +
+
+

Answer the questions above and your brief appears here.

+
+
+ + + +
+

+
+
+ + + + +
+ + + + + diff --git a/design-system/brief.html b/design-system/brief.html new file mode 100644 index 0000000..de0d5aa --- /dev/null +++ b/design-system/brief.html @@ -0,0 +1,849 @@ + + + + + +Tell us about your project + + + + + + + +
+ + + +
+ Project brief +

Tell us about your project

+

+ Four questions. There's a way through every one of them even if you don't + have a logo or your words written yet — just say so and we'll handle it. + Nothing you add here is uploaded anywhere; it stays on your computer. +

+
+ + +
+
+
1

What do you need?

+

Pick the closest one. We can adjust later.

+
+
+ + + + +
+
+ + +
+
+
2

Which look do you like?

+

+ Pick whichever feels right — there's no wrong answer, and every one of + these is checked so your text stays easy to read. +

+
+
+
+ + +
+
+
3

Your logo

+

Have one? Add it. Don't? Describe what you'd like and we'll take it from there.

+
+
+ + + +
+ + + + +
+ + +
+
+
4

Your words

+

Send us what you've written, or answer a few questions and we'll write a first draft for you.

+
+
+ + + +
+ + + + +
+ + + + + +
+
+
+ Your brief +
+ 0 of 4 +
+
+

Answer the questions above and your brief appears here.

+
+
+ + + +
+

+
+
+ + + + +
+ + + + diff --git a/design-system/css/base.css b/design-system/css/base.css new file mode 100644 index 0000000..5e37ae7 --- /dev/null +++ b/design-system/css/base.css @@ -0,0 +1,182 @@ +/* ========================================================================== + Corporate Deck Design System — Base + Reset, document defaults, and the typographic primitives. + Requires: tokens.css + ========================================================================== */ + +*, *::before, *::after { box-sizing: border-box; } + +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--surface-page); + color: var(--fg-body); + font-family: var(--font-body); + font-size: var(--fs-body); + line-height: var(--lh-loose); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +img, svg, video, canvas { display: block; max-width: 100%; height: auto; } + +h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd { margin: 0; } +ul, ol { margin: 0; padding: 0; } + +a { color: var(--accent-600); text-decoration-thickness: 1px; text-underline-offset: 2px; } +a:hover { color: var(--accent-700); } + +:focus-visible { + outline: 3px solid var(--accent-500); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* -------------------------------------------------------------------------- + Type primitives + The house voice: heavy, tight display type over roomy, quiet body copy. + -------------------------------------------------------------------------- */ + +.display, +.h1, .h2, .h3, .h4 { + font-family: var(--font-display); + color: var(--fg-strong); + text-wrap: balance; +} + +.display { + font-size: clamp(2.75rem, 6vw, var(--fs-display)); + font-weight: var(--fw-black); + line-height: 1.02; + letter-spacing: var(--tracking-display); +} + +.h1 { + font-size: clamp(2.25rem, 4.5vw, var(--fs-h1)); + font-weight: var(--fw-black); + line-height: var(--lh-tight); + letter-spacing: -0.025em; +} + +.h2 { + font-size: clamp(1.75rem, 3.2vw, var(--fs-h2)); + font-weight: var(--fw-bold); + line-height: 1.12; + letter-spacing: var(--tracking-tight); +} + +.h3 { + font-size: var(--fs-h3); + font-weight: var(--fw-bold); + line-height: var(--lh-snug); + letter-spacing: -0.01em; +} + +.h4 { + font-size: var(--fs-h4); + font-weight: var(--fw-semibold); + line-height: 1.3; +} + +.body-lg { font-size: 1.125rem; line-height: var(--lh-loose); } +.body { font-size: var(--fs-body); line-height: var(--lh-loose); } +.small { font-size: var(--fs-small); line-height: 1.6; } +.caption { font-size: var(--fs-caption); line-height: 1.5; color: var(--fg-muted); font-weight: var(--fw-medium); } + +/* The small tracked-out label that sits above nearly every headline. + This is the single most recognisable tic of the system — use it often. */ +.eyebrow { + display: inline-block; + font-family: var(--font-display); + font-size: var(--fs-eyebrow); + font-weight: var(--fw-semibold); + letter-spacing: var(--tracking-eyebrow); + text-transform: uppercase; + color: var(--accent-600); + line-height: var(--lh-snug); +} + +.eyebrow--muted { color: var(--fg-muted); } + +/* Two-tone headline. Wrap the emphasised word: "Doing Everything Right" */ +.h1 em, .h2 em, .display em { + font-style: normal; + color: var(--accent-600); +} + +/* The light/heavy headline pair seen on most cover slides: + a thin first line, a black second line. */ +.headline-pair { display: block; } +.headline-pair__light { display: block; font-weight: var(--fw-regular); } +.headline-pair__heavy { display: block; font-weight: var(--fw-black); } + +/* Oversized ghost numeral used behind section dividers and stat blocks. */ +.numeral { + font-family: var(--font-display); + font-size: var(--fs-numeral); + font-weight: var(--fw-black); + line-height: 0.9; + letter-spacing: -0.04em; + color: var(--fg-strong); + font-variant-numeric: tabular-nums; +} + +.numeral--ghost { color: var(--ink-100); } +.numeral--accent { color: var(--accent-500); } + +/* The short accent rule that separates an eyebrow or headline from its body. */ +.rule { + width: 56px; + height: 4px; + border: 0; + background: var(--accent-500); + border-radius: var(--radius-pill); + margin: 0; +} + +.rule--center { margin-inline: auto; } +.rule--long { width: 100%; height: 1px; background: var(--ink-200); border-radius: 0; } + +/* -------------------------------------------------------------------------- + Utilities + -------------------------------------------------------------------------- */ + +.stack > * + * { margin-top: var(--flow, var(--space-4)); } +.stack-2 { --flow: var(--space-2); } +.stack-3 { --flow: var(--space-3); } +.stack-5 { --flow: var(--space-5); } +.stack-6 { --flow: var(--space-6); } + +.container { + width: 100%; + max-width: var(--container-max); + margin-inline: auto; + padding-inline: var(--space-5); +} + +.section { padding-block: var(--section-py); } +.section--muted { background: var(--surface-muted); } +.section--cream { background: var(--surface-cream); } +.section--accent { background: var(--accent-500); color: var(--accent-on); } +.section--dark { background: var(--ink-900); } + +.text-center { text-align: center; } +.text-accent { color: var(--accent-600); } +.text-strong { color: var(--fg-strong); } +.text-muted { color: var(--fg-muted); } + +.measure { max-width: 62ch; } +.measure-sm { max-width: 46ch; } + +.visually-hidden { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/design-system/css/components.css b/design-system/css/components.css new file mode 100644 index 0000000..264de37 --- /dev/null +++ b/design-system/css/components.css @@ -0,0 +1,578 @@ +/* ========================================================================== + Corporate Deck Design System — Components + The recurring furniture: cards, stats, steps, timelines, buttons, tables, + photo treatments and the decorative geometry. + Requires: tokens.css, base.css + ========================================================================== */ + +/* -------------------------------------------------------------------------- + Grid + -------------------------------------------------------------------------- */ + +.grid { + display: grid; + gap: var(--space-5); + grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr)); +} +.grid--2 { --cols: 2; } +.grid--3 { --cols: 3; } +.grid--4 { --cols: 4; } +.grid--gap-lg { gap: var(--space-7); } + +/* Asymmetric split: the workhorse layout for "headline left, content right". */ +.split { + display: grid; + gap: var(--space-8); + grid-template-columns: minmax(0, var(--split, 5fr)) minmax(0, 7fr); + align-items: center; +} +.split--even { --split: 6fr; } +.split--wide-l { --split: 7fr; } + +@media (max-width: 900px) { + .grid { grid-template-columns: 1fr; } + .grid--2, .grid--3, .grid--4 { grid-template-columns: 1fr; } + .split { grid-template-columns: 1fr; gap: var(--space-6); } +} + +/* -------------------------------------------------------------------------- + Card — the base container for almost every content block + -------------------------------------------------------------------------- */ + +.card { + background: var(--surface-page); + border: 1px solid var(--ink-100); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); + transition: box-shadow var(--duration-base) var(--ease-standard), + transform var(--duration-base) var(--ease-standard); +} + +.card--raised { box-shadow: var(--shadow-md); border-color: transparent; } +.card--flat { box-shadow: none; background: var(--surface-muted); border-color: transparent; } +.card--accent { background: var(--accent-500); border-color: transparent; color: var(--accent-on); } +.card--dark { background: var(--ink-900); border-color: transparent; } +.card--interactive:hover { box-shadow: var(--shadow-lg); transform: translateY(-3px); } + +.card--accent .card__title, +.card--accent .h3 { color: var(--accent-on); } +.card--accent .card__body { color: color-mix(in srgb, var(--accent-on) 78%, transparent); } + +/* Left accent bar — used to mark the "featured" item in a row of three. */ +.card--barred { + border-left: 4px solid var(--accent-500); + border-radius: var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-sm); +} + +/* Top accent bar — the alternative emphasis treatment. */ +.card--capped { border-top: 4px solid var(--accent-500); } + +.card__title { font-family: var(--font-display); font-size: var(--fs-h4); font-weight: var(--fw-bold); color: var(--fg-strong); } +.card__body { font-size: var(--fs-small); color: var(--fg-body); margin-top: var(--space-2); } + +/* -------------------------------------------------------------------------- + Icon chip — icon in a rounded square or circle, always accent-filled + -------------------------------------------------------------------------- */ + +.icon-chip { + display: inline-grid; + place-items: center; + width: 52px; + height: 52px; + border-radius: var(--radius-md); + background: var(--accent-500); + color: var(--accent-on); + flex: none; +} + +.icon-chip--round { border-radius: var(--radius-pill); } +.icon-chip--soft { background: var(--accent-50); color: var(--accent-700); } +.icon-chip--dark { background: var(--ink-900); color: #fff; } +.icon-chip--outline { + background: transparent; + border: 2px solid var(--accent-500); + color: var(--accent-600); +} +.icon-chip--sm { width: 38px; height: 38px; border-radius: var(--radius-sm); } +.icon-chip--lg { width: 72px; height: 72px; border-radius: var(--radius-lg); } + +.icon-chip svg { width: 45%; height: 45%; } + +/* Feature row: chip left, copy right. The most-used content atom in the set. */ +.feature { + display: flex; + gap: var(--space-4); + align-items: flex-start; +} +.feature__title { font-family: var(--font-display); font-weight: var(--fw-semibold); color: var(--fg-strong); } +.feature__body { font-size: var(--fs-small); color: var(--fg-body); margin-top: var(--space-1); } + +/* -------------------------------------------------------------------------- + Stat — big number over a quiet label + -------------------------------------------------------------------------- */ + +.stat { display: flex; flex-direction: column; gap: var(--space-1); } + +.stat__value { + font-family: var(--font-display); + font-size: clamp(2.25rem, 4vw, 3.25rem); + font-weight: var(--fw-black); + line-height: 1; + letter-spacing: -0.035em; + color: var(--accent-600); + font-variant-numeric: tabular-nums; +} + +.stat__label { + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-muted); +} + +.stat__note { font-size: var(--fs-small); color: var(--fg-body); } +.stat--ink .stat__value { color: var(--fg-strong); } + +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--space-6); +} + +/* Divided stat strip — hairline rules between each figure. */ +.stat-row--divided > .stat + .stat { + border-left: 1px solid var(--ink-200); + padding-left: var(--space-6); +} +.on-dark .stat-row--divided > .stat + .stat { border-left-color: rgba(255,255,255,0.18); } +.on-dark .stat__value { color: var(--accent-300); } + +@media (max-width: 700px) { + .stat-row--divided > .stat + .stat { border-left: 0; padding-left: 0; } +} + +/* -------------------------------------------------------------------------- + Numbered steps — "01 / 02 / 03" process rows + -------------------------------------------------------------------------- */ + +.steps { display: grid; gap: var(--space-6); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); counter-reset: step; } + +.step { position: relative; padding-top: var(--space-6); } + +.step::before { + counter-increment: step; + content: "0" counter(step); + position: absolute; + top: 0; + left: 0; + font-family: var(--font-display); + font-size: 2.5rem; + font-weight: var(--fw-black); + line-height: 1; + letter-spacing: -0.04em; + color: var(--accent-100); +} + +.step__title { font-family: var(--font-display); font-weight: var(--fw-bold); color: var(--fg-strong); position: relative; } +.step__body { font-size: var(--fs-small); margin-top: var(--space-2); } + +/* Badge variant — the number sits in a filled chip instead of ghosting behind. */ +.step-badge { + display: inline-grid; + place-items: center; + min-width: 44px; + height: 44px; + padding-inline: var(--space-3); + border-radius: var(--radius-pill); + background: var(--accent-500); + color: var(--accent-on); + font-family: var(--font-display); + font-weight: var(--fw-bold); + font-variant-numeric: tabular-nums; +} + +/* -------------------------------------------------------------------------- + Timeline — horizontal milestone track + -------------------------------------------------------------------------- */ + +.timeline { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: var(--space-5); + position: relative; + padding-top: var(--space-7); +} + +.timeline::before { + content: ""; + position: absolute; + top: 10px; + left: 0; + right: 0; + height: 2px; + background: var(--ink-200); +} + +.timeline__item { position: relative; } + +.timeline__item::before { + content: ""; + position: absolute; + top: calc(var(--space-7) * -1 + 4px); + left: 0; + width: 14px; + height: 14px; + border-radius: var(--radius-pill); + background: var(--accent-500); + box-shadow: 0 0 0 4px var(--surface-page); +} + +.timeline__date { + font-family: var(--font-display); + font-weight: var(--fw-bold); + color: var(--accent-600); + font-variant-numeric: tabular-nums; +} +.timeline__body { font-size: var(--fs-small); margin-top: var(--space-1); } + +@media (max-width: 800px) { + .timeline { grid-auto-flow: row; padding-top: 0; padding-left: var(--space-6); } + .timeline::before { top: 0; bottom: 0; left: 6px; right: auto; width: 2px; height: auto; } + .timeline__item::before { top: 4px; left: calc(var(--space-6) * -1); } +} + +/* -------------------------------------------------------------------------- + Buttons + -------------------------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0.85rem 1.75rem; + border: 2px solid transparent; + border-radius: var(--radius-pill); + font-family: var(--font-display); + font-size: var(--fs-small); + font-weight: var(--fw-semibold); + letter-spacing: 0.01em; + text-decoration: none; + cursor: pointer; + transition: background var(--duration-base) var(--ease-standard), + color var(--duration-base) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); +} + +.btn:hover { transform: translateY(-1px); } +.btn:active { transform: translateY(0); } + +.btn--primary { background: var(--accent-500); color: var(--accent-on); } +.btn--primary:hover { background: var(--accent-600); color: var(--accent-on-600); } + +.btn--ink { background: var(--ink-900); color: #fff; } +.btn--ink:hover { background: var(--ink-800); color: #fff; } + +.btn--outline { border-color: var(--accent-500); color: var(--accent-600); background: transparent; } +.btn--outline:hover { background: var(--accent-500); color: var(--accent-on); } + +.btn--ghost { color: var(--fg-strong); background: transparent; padding-inline: var(--space-2); } +.btn--ghost:hover { color: var(--accent-600); } + +.btn--square { border-radius: var(--radius-md); } +.btn--lg { padding: 1.05rem 2.25rem; font-size: var(--fs-body); } + +/* Pill tag / chip label */ +.tag { + display: inline-block; + padding: 0.35rem 0.85rem; + border-radius: var(--radius-pill); + background: var(--accent-50); + color: var(--accent-700); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + letter-spacing: 0.04em; +} +.tag--solid { background: var(--accent-500); color: var(--accent-on); } +.tag--ink { background: var(--ink-900); color: #fff; } + +/* -------------------------------------------------------------------------- + Media — photo treatments + -------------------------------------------------------------------------- */ + +.media { + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface-sunken); +} +.media img { width: 100%; height: 100%; object-fit: cover; } + +.media--xl { border-radius: var(--radius-xl); } +.media--pill { border-radius: var(--radius-pill); } +.media--blob { border-radius: var(--radius-blob); } +.media--square { border-radius: 0; } + +/* The rounded-rect-with-one-square-corner mask used on section covers. */ +.media--notched { border-radius: var(--radius-xl) var(--radius-xl) var(--radius-xl) 0; } + +.media--circle { border-radius: var(--radius-pill); aspect-ratio: 1; } +.media--4x3 { aspect-ratio: 4 / 3; } +.media--16x9 { aspect-ratio: 16 / 9; } +.media--3x4 { aspect-ratio: 3 / 4; } + +/* Accent duotone wash — turns any stock photo into brand furniture. */ +.media--wash { position: relative; } +.media--wash::after { + content: ""; + position: absolute; + inset: 0; + background: var(--accent-500); + mix-blend-mode: multiply; + opacity: 0.55; +} + +.media--dim { position: relative; } +.media--dim::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(20,24,31,0.15) 0%, rgba(20,24,31,0.72) 100%); +} + +/* Avatar */ +.avatar { + width: 96px; + height: 96px; + border-radius: var(--radius-pill); + object-fit: cover; + border: 4px solid var(--surface-page); + box-shadow: var(--shadow-md); +} +.avatar--sm { width: 48px; height: 48px; border-width: 2px; } +.avatar--ring { border-color: var(--accent-500); } + +/* -------------------------------------------------------------------------- + Decorative geometry + The blobs, quarter-circles and offset blocks that sit behind content. + All are aria-hidden ornaments — never put meaning in them. + -------------------------------------------------------------------------- */ + +/* Ornaments sit on a negative layer so they fall behind in-flow content + without needing a z-index on the content itself — which would otherwise + clobber the `position: absolute` on slide furniture. `.has-deco` isolates, + so a negative layer never escapes behind the container's own background. */ +.deco { position: absolute; pointer-events: none; z-index: -1; } + +.deco-circle { + border-radius: var(--radius-pill); + background: var(--accent-500); +} +.deco-circle--soft { background: var(--accent-50); } +.deco-circle--ink { background: var(--ink-100); } +.deco-circle--ring { background: transparent; border: 2px solid var(--accent-300); } + +/* Quarter-circle corner ornament. */ +.deco-quarter { + border-radius: 0 0 100% 0; + background: var(--accent-100); +} + +.deco-blob { + border-radius: var(--radius-blob); + background: var(--accent-100); +} + +/* Dotted matrix — the small grid of dots tucked into slide corners. */ +.deco-dots { + background-image: radial-gradient(var(--accent-300) 1.5px, transparent 1.5px); + background-size: 12px 12px; + opacity: 0.6; +} + +/* Offset block behind a card or image — the "stacked paper" look. + The accent slab is kept *inside* the element's own box (via padding) rather + than bled outside it, so the effect survives any ancestor that clips + overflow — which `.has-deco` and `.slide` both do. */ +.offset-block { + position: relative; + isolation: isolate; + padding: 0 var(--offset-shift, var(--space-5)) var(--offset-shift, var(--space-5)) 0; +} +.offset-block::before { + content: ""; + position: absolute; + inset: var(--offset-shift, var(--space-5)) 0 0 var(--offset-shift, var(--space-5)); + background: var(--accent-500); + border-radius: var(--radius-lg); + z-index: -1; +} + +/* Shift the slab to the other side. */ +.offset-block--left { + padding: 0 0 var(--offset-shift, var(--space-5)) var(--offset-shift, var(--space-5)); +} +.offset-block--left::before { + inset: var(--offset-shift, var(--space-5)) var(--offset-shift, var(--space-5)) 0 0; +} + +.slide .offset-block { --offset-shift: 2.4cqw; } + +/* Anything that hosts .deco children needs this. */ +.has-deco { position: relative; overflow: hidden; isolation: isolate; } + +/* -------------------------------------------------------------------------- + Progress / meters + -------------------------------------------------------------------------- */ + +.meter { + height: 8px; + border-radius: var(--radius-pill); + background: var(--ink-100); + overflow: hidden; +} +.meter__fill { + height: 100%; + border-radius: inherit; + background: var(--accent-500); +} +.meter--thick { height: 14px; } + +.meter-row { display: grid; gap: var(--space-2); } +.meter-row__head { + display: flex; + justify-content: space-between; + font-size: var(--fs-small); + font-weight: var(--fw-semibold); + color: var(--fg-strong); +} + +/* -------------------------------------------------------------------------- + Table + -------------------------------------------------------------------------- */ + +.table-wrap { overflow-x: auto; } + +.table { + width: 100%; + border-collapse: collapse; + font-size: var(--fs-small); + min-width: 520px; +} + +.table th { + text-align: left; + font-family: var(--font-display); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + padding: var(--space-3) var(--space-4); + border-bottom: 2px solid var(--ink-200); + white-space: nowrap; +} + +.table td { + padding: var(--space-4); + border-bottom: 1px solid var(--ink-100); + color: var(--fg-body); +} + +.table tbody tr:hover { background: var(--surface-muted); } +.table td:first-child { font-weight: var(--fw-semibold); color: var(--fg-strong); } + +/* -------------------------------------------------------------------------- + Quote / testimonial + -------------------------------------------------------------------------- */ + +.quote { + position: relative; + padding-left: var(--space-7); + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: var(--fw-medium); + line-height: 1.5; + color: var(--fg-strong); +} + +.quote::before { + content: "\201C"; + position: absolute; + left: 0; + top: -0.35em; + font-size: 4rem; + line-height: 1; + color: var(--accent-300); +} + +.quote__cite { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-5); + font-family: var(--font-body); + font-size: var(--fs-small); + font-weight: var(--fw-regular); + color: var(--fg-muted); +} +.quote__name { font-weight: var(--fw-semibold); color: var(--fg-strong); display: block; } + +/* -------------------------------------------------------------------------- + SWOT — the 2x2 that appears in nearly every deck in the reference set + -------------------------------------------------------------------------- */ + +.swot { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); } +.swot > * { padding: var(--space-5); border-radius: var(--radius-lg); } +.swot__s { background: var(--accent-500); color: var(--accent-on); } +.swot__w { background: var(--ink-900); color: #fff; } +.swot__o { background: var(--accent-50); color: var(--ink-900); } +.swot__t { background: var(--surface-muted); color: var(--ink-900); } +.swot__label { + font-family: var(--font-display); + font-size: var(--fs-caption); + font-weight: var(--fw-bold); + letter-spacing: 0.14em; + text-transform: uppercase; + opacity: 0.75; +} +.swot__body { font-size: var(--fs-small); margin-top: var(--space-2); } + +@media (max-width: 700px) { .swot { grid-template-columns: 1fr; } } + +/* -------------------------------------------------------------------------- + Navbar (web templates) + -------------------------------------------------------------------------- */ + +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-6); + padding: var(--space-4) 0; +} + +.navbar__brand { + display: flex; + align-items: center; + gap: var(--space-3); + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: var(--fw-bold); + letter-spacing: -0.01em; + color: var(--fg-strong); + text-decoration: none; +} + +.navbar__links { + display: flex; + gap: var(--space-6); + list-style: none; + font-size: var(--fs-small); + font-weight: var(--fw-medium); +} +.navbar__links a { color: var(--fg-body); text-decoration: none; } +.navbar__links a:hover { color: var(--accent-600); } + +@media (max-width: 800px) { .navbar__links { display: none; } } diff --git a/design-system/css/slides.css b/design-system/css/slides.css new file mode 100644 index 0000000..14e1e42 --- /dev/null +++ b/design-system/css/slides.css @@ -0,0 +1,325 @@ +/* ========================================================================== + Corporate Deck Design System — Slides + 16:9 slide surfaces and the standard slide archetypes. + Requires: tokens.css, base.css, components.css + + A .slide is a self-contained 16:9 canvas that scales with its container. + Everything inside sizes in `cqw` (container query units) so a slide looks + identical whether it renders full-screen or as a thumbnail in a grid. + ========================================================================== */ + +.slide { + container-type: inline-size; + position: relative; + aspect-ratio: 16 / 9; + width: 100%; + overflow: hidden; + background: var(--surface-page); + color: var(--fg-body); + border-radius: var(--radius-md); + box-shadow: var(--shadow-slide); + isolation: isolate; +} + +/* Safe area — nothing meaningful crosses this margin. */ +.slide__inner { + position: relative; + z-index: 1; + height: 100%; + padding: 7cqw 6.5cqw; + display: flex; + flex-direction: column; + justify-content: center; +} + +.slide__inner--top { justify-content: flex-start; } +.slide__inner--bottom { justify-content: flex-end; } +.slide__inner--flush { padding: 0; } + +/* Slide surface variants */ +.slide--muted { background: var(--surface-muted); } +.slide--cream { background: var(--surface-cream); } +.slide--accent { background: var(--accent-500); color: var(--accent-on); } +.slide--dark { background: var(--ink-900); } +.slide--dark .h1, .slide--dark .h2, .slide--dark .h3, +.slide--dark .display { color: #fff; } + +/* -------------------------------------------------------------------------- + Slide type scale + Sized in cqw so type stays proportional at any render size. + -------------------------------------------------------------------------- */ + +.slide .display { font-size: 7.2cqw; line-height: 1.0; letter-spacing: -0.03em; } +.slide .h1 { font-size: 5.4cqw; line-height: 1.05; letter-spacing: -0.025em; } +.slide .h2 { font-size: 4.0cqw; line-height: 1.1; letter-spacing: -0.02em; } +.slide .h3 { font-size: 2.6cqw; line-height: 1.2; } +.slide .h4 { font-size: 2.0cqw; line-height: 1.3; } +.slide .body { font-size: 1.65cqw; line-height: 1.6; } +.slide .small { font-size: 1.45cqw; line-height: 1.55; } +.slide .caption { font-size: 1.25cqw; } +.slide .eyebrow { font-size: 1.3cqw; letter-spacing: 0.16em; } +.slide .numeral { font-size: 11cqw; } +.slide .stat__value { font-size: 5cqw; } +.slide .stat__label { font-size: 1.2cqw; } +.slide .btn { font-size: 1.5cqw; padding: 1.1cqw 2.4cqw; } +.slide .icon-chip { width: 5.6cqw; height: 5.6cqw; border-radius: 1.2cqw; } +.slide .rule { width: 6cqw; height: 0.45cqw; } +.slide .tag { font-size: 1.2cqw; padding: 0.5cqw 1.2cqw; } +.slide .card { padding: 2.6cqw; border-radius: 1.6cqw; } +.slide .card__title { font-size: 2cqw; } +.slide .card__body { font-size: 1.45cqw; } +.slide .grid { gap: 2.2cqw; } +.slide .split { gap: 4cqw; } +.slide .quote { font-size: 2.4cqw; padding-left: 5cqw; } +.slide .quote::before { font-size: 7cqw; } +.slide .avatar { width: 9cqw; height: 9cqw; border-width: 0.4cqw; } +.slide .swot > * { padding: 2.2cqw; border-radius: 1.4cqw; } +.slide .swot__label { font-size: 1.15cqw; } +.slide .swot__body { font-size: 1.4cqw; margin-top: 0.6cqw; } +.slide .meter { height: 0.85cqw; } +.slide .step__title { font-size: 2cqw; } +.slide .step__body { font-size: 1.4cqw; margin-top: 0.6cqw; } +.slide .timeline__date { font-size: 1.9cqw; } +.slide .timeline__body { font-size: 1.35cqw; margin-top: 0.4cqw; } +.slide .feature__body { font-size: 1.4cqw; } + +/* --- Spacing inside a slide is proportional, not absolute ----------------- + The `--space-*` tokens are rem-based, which is correct for a document but + wrong on a fixed canvas: at thumbnail size a 24px gap eats a tenth of the + slide and pushes content out of the frame. Every rhythm inside .slide is + therefore restated in cqw so a slide composes identically at any size. */ + +.slide .stack { --flow: 1.8cqw; } +.slide .stack-2 { --flow: 0.8cqw; } +.slide .stack-3 { --flow: 1.2cqw; } +.slide .stack-5 { --flow: 2.6cqw; } +.slide .stack-6 { --flow: 3.4cqw; } + +.slide .steps { gap: 2.4cqw; padding-top: 0; } +.slide .step { padding-top: 3.6cqw; } +.slide .step::before { font-size: 3.4cqw; } +.slide .timeline { gap: 2.2cqw; padding-top: 3.4cqw; } +.slide .timeline__item::before { top: -3cqw; width: 1.5cqw; height: 1.5cqw; box-shadow: 0 0 0 0.45cqw var(--surface-page); } +.slide .stat-row { gap: 2.6cqw; } +.slide .stat-row--divided > .stat + .stat { padding-left: 2.6cqw; } +.slide .swot { gap: 1.8cqw; } +.slide .quote__cite { gap: 1.4cqw; margin-top: 2.2cqw; font-size: 1.4cqw; } +.slide .meter-row { gap: 0.8cqw; } +.slide .meter-row__head { font-size: 1.4cqw; } +.slide .agenda__item { padding-bottom: 1.2cqw; } + +/* --- A slide is a fixed canvas: it never reflows -------------------------- + The responsive collapses in components.css are right for a scrolling page + and wrong here. Column counts inside a slide are pinned at every width so + a 460px thumbnail and a 1920px projection show the same composition. */ + +.slide .grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.slide .grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.slide .grid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.slide .split { grid-template-columns: minmax(0, var(--split, 5fr)) minmax(0, 7fr); } +.slide .swot { grid-template-columns: 1fr 1fr; } + +/* auto-fit minimums are px-based and wrap far too early on a slide canvas */ +.slide .steps, +.slide .stat-row { + grid-template-columns: none; + grid-auto-flow: column; + grid-auto-columns: 1fr; +} + +.slide .timeline { + grid-auto-flow: column; + grid-auto-columns: 1fr; + padding-left: 0; +} +.slide .timeline::before { top: 0.4cqw; bottom: auto; left: 0; right: 0; width: auto; height: 0.2cqw; } +.slide .stat-row--divided > .stat + .stat { border-left: 1px solid var(--ink-200); } +.slide.on-dark .stat-row--divided > .stat + .stat { border-left-color: rgba(255,255,255,0.18); } + +/* -------------------------------------------------------------------------- + Slide furniture — the persistent chrome on every non-cover slide + -------------------------------------------------------------------------- */ + +.slide__logo { + position: absolute; + top: 4cqw; + left: 6.5cqw; + z-index: 2; + display: flex; + align-items: center; + gap: 1cqw; + font-family: var(--font-display); + font-size: 1.6cqw; + font-weight: var(--fw-bold); + letter-spacing: -0.01em; + color: var(--fg-strong); +} + +.slide__kicker { + position: absolute; + top: 4.4cqw; + right: 6.5cqw; + z-index: 2; + font-family: var(--font-display); + font-size: 1.15cqw; + font-weight: var(--fw-semibold); + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--fg-muted); +} + +.slide__pagenum { + position: absolute; + bottom: 4cqw; + right: 6.5cqw; + z-index: 2; + font-family: var(--font-display); + font-size: 1.3cqw; + font-weight: var(--fw-bold); + color: var(--ink-300); + font-variant-numeric: tabular-nums; +} + +.slide--dark .slide__logo, +.slide--accent .slide__logo { color: currentColor; } +.slide--dark .slide__kicker { color: rgba(255,255,255,0.55); } +.slide--dark .slide__pagenum { color: rgba(255,255,255,0.45); } + +/* A slide with furniture needs headroom so content clears the logo row. */ +.slide--chromed .slide__inner { padding-top: 11cqw; padding-bottom: 9cqw; } + +/* -------------------------------------------------------------------------- + Archetype: cover + Split canvas — type block on one side, full-bleed media on the other. + -------------------------------------------------------------------------- */ + +.slide-cover { display: grid; grid-template-columns: 1.05fr 1fr; height: 100%; } + +.slide-cover__type { + padding: 7cqw 5cqw 7cqw 6.5cqw; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2cqw; +} + +.slide-cover__media { position: relative; overflow: hidden; } +.slide-cover__media img { width: 100%; height: 100%; object-fit: cover; } + +/* Media panel with the signature curved inner edge. */ +.slide-cover__media--curved { border-radius: 18cqw 0 0 18cqw; overflow: hidden; } + +.slide-cover--reverse { grid-template-columns: 1fr 1.05fr; } +.slide-cover--reverse .slide-cover__type { order: 2; padding: 7cqw 6.5cqw 7cqw 5cqw; } +.slide-cover--reverse .slide-cover__media { order: 1; } + +/* Full-bleed cover — image behind, type on top. */ +.slide-cover--bleed { grid-template-columns: 1fr; } +.slide-cover--bleed .slide-cover__media { position: absolute; inset: 0; } +.slide-cover--bleed .slide-cover__type { position: relative; z-index: 2; max-width: 62%; } + +/* -------------------------------------------------------------------------- + Archetype: section divider + Oversized numeral, short title, optional accent panel. + -------------------------------------------------------------------------- */ + +.slide-section { display: grid; grid-template-columns: 1fr 1fr; height: 100%; align-items: stretch; } + +.slide-section__panel { + background: var(--accent-500); + color: var(--accent-on); + padding: 7cqw 5cqw 7cqw 6.5cqw; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1.5cqw; +} + +.slide-section__panel .numeral { color: color-mix(in srgb, var(--accent-on) 32%, transparent); } +.slide-section__panel .h1 { color: var(--accent-on); } + +.slide-section__aside { + padding: 7cqw 6.5cqw 7cqw 5cqw; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1.5cqw; +} + +/* -------------------------------------------------------------------------- + Archetype: contents / agenda + -------------------------------------------------------------------------- */ + +.agenda { display: grid; grid-template-columns: 1fr 1fr; gap: 1.4cqw 4cqw; } + +.agenda__item { + display: flex; + align-items: baseline; + gap: 1.6cqw; + padding-bottom: 1.2cqw; + border-bottom: 1px solid var(--ink-100); +} + +.agenda__num { + font-family: var(--font-display); + font-size: 2.4cqw; + font-weight: var(--fw-black); + color: var(--accent-500); + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; + flex: none; +} + +.agenda__label { + font-family: var(--font-display); + font-size: 1.9cqw; + font-weight: var(--fw-semibold); + color: var(--fg-strong); +} + +/* -------------------------------------------------------------------------- + Archetype: closing + -------------------------------------------------------------------------- */ + +.slide-close { text-align: center; display: grid; place-content: center; gap: 2cqw; height: 100%; padding: 6.5cqw; } +.slide-close .display { font-size: 8cqw; } + +/* -------------------------------------------------------------------------- + Deck — a scrollable grid of slide thumbnails + -------------------------------------------------------------------------- */ + +/* The track minimum is capped at 100% so a viewport narrower than the minimum + collapses to one column instead of forcing the grid wider than the screen. */ +.deck { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); + gap: var(--space-6); +} + +.deck--single { grid-template-columns: 1fr; } +.deck--wide { grid-template-columns: repeat(auto-fill, minmax(min(460px, 100%), 1fr)); } + +.deck__slide { display: flex; flex-direction: column; gap: var(--space-2); } +.deck__caption { + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); +} + +/* -------------------------------------------------------------------------- + Print — one slide per page, landscape + -------------------------------------------------------------------------- */ + +@media print { + @page { size: landscape; margin: 0; } + .deck { display: block; } + .deck__caption { display: none; } + .slide { + break-after: page; + border-radius: 0; + box-shadow: none; + width: 100%; + } +} diff --git a/design-system/css/web.css b/design-system/css/web.css new file mode 100644 index 0000000..328593a --- /dev/null +++ b/design-system/css/web.css @@ -0,0 +1,482 @@ +/* ========================================================================== + Corporate Deck Design System — Web layer + Patterns from the favourites set (see FAVORITES.md) that the deck layer + does not cover: gradient heroes, wave dividers, device mockups, trust bars, + tinted feature cards, ghost step rails, pricing, FAQ, marquee, CTA bands. + Requires: tokens.css, base.css, components.css + ========================================================================== */ + +/* -------------------------------------------------------------------------- + Hero surfaces + -------------------------------------------------------------------------- */ + +.hero-gradient { + position: relative; + background: var(--gradient-hero); + color: #fff; + isolation: isolate; + overflow: hidden; +} +.hero-gradient .h1, +.hero-gradient .display, +.hero-gradient .h2 { color: #fff; } +.hero-gradient .eyebrow { color: rgba(255, 255, 255, 0.75); } + +.hero-sheen { background: var(--gradient-sheen); } + +/* Soft blurred orb — the glow behind hero content. Ornamental. */ +.orb { + position: absolute; + border-radius: var(--radius-pill); + background: var(--glow-accent); + filter: blur(28px); + pointer-events: none; + z-index: -1; +} + +/* Wave divider. Sits at the bottom of a coloured hero and curves into the + next section. Give it the *next* section's background via `color`. */ +.wave { + display: block; + width: 100%; + height: clamp(40px, 6vw, 90px); + color: var(--surface-page); +} +.wave svg { display: block; width: 100%; height: 100%; } +.wave path { fill: currentColor; } + +/* -------------------------------------------------------------------------- + Badge pill above a headline — "NEW · Introducing X" + -------------------------------------------------------------------------- */ + +.badge-pill { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0.35rem 0.35rem 0.35rem 0.85rem; + border-radius: var(--radius-pill); + background: var(--accent-50); + color: var(--accent-700); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); +} +.badge-pill__flag { + padding: 0.2rem 0.6rem; + border-radius: var(--radius-pill); + background: var(--accent-500); + color: var(--accent-on); + font-size: 0.6875rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.hero-gradient .badge-pill { + background: rgba(255, 255, 255, 0.16); + color: #fff; +} + +/* Headline word with an accent underline rather than an accent fill. */ +.underlined { + color: var(--accent-600); + text-decoration: underline; + text-decoration-thickness: 0.09em; + text-underline-offset: 0.12em; +} + +/* Gradient-filled headline word. Use once per page at most. */ +.gradient-text { + background: var(--gradient-text); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* -------------------------------------------------------------------------- + Device mockup frames — the product-UI hero visual + -------------------------------------------------------------------------- */ + +.device { + position: relative; + background: var(--ink-900); + box-shadow: var(--shadow-xl); + overflow: hidden; +} + +.device--phone { + border-radius: 2.25rem; + padding: 0.55rem; + aspect-ratio: 1179 / 2556; + max-width: 300px; +} +.device--phone::before { /* dynamic island */ + content: ""; + position: absolute; + top: 1.1rem; left: 50%; + transform: translateX(-50%); + width: 32%; height: 1.1rem; + border-radius: var(--radius-pill); + background: var(--ink-900); + z-index: 2; +} + +.device--browser { + border-radius: var(--radius-md); + padding: 1.9rem 0.4rem 0.4rem; +} +.device--browser::before { /* traffic lights */ + content: ""; + position: absolute; + top: 0.72rem; left: 0.85rem; + width: 2.6rem; height: 0.45rem; + border-radius: var(--radius-pill); + background: + radial-gradient(circle 3.5px at 3.5px 50%, var(--ink-600) 100%, transparent 0) 0 0/1rem 100% no-repeat, + radial-gradient(circle 3.5px at 3.5px 50%, var(--ink-600) 100%, transparent 0) 0.85rem 0/1rem 100% no-repeat, + radial-gradient(circle 3.5px at 3.5px 50%, var(--ink-600) 100%, transparent 0) 1.7rem 0/1rem 100% no-repeat; +} + +.device__screen { + border-radius: inherit; + overflow: hidden; + background: var(--surface-page); + height: 100%; +} +.device--browser .device__screen { border-radius: var(--radius-sm); } +.device__screen img { width: 100%; height: 100%; object-fit: cover; object-position: top; } + +/* Small stat/notification card that floats over a mockup. */ +.float-card { + position: absolute; + z-index: 3; + background: var(--surface-page); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: var(--space-3) var(--space-4); + font-size: var(--fs-caption); + color: var(--fg-body); + max-width: 200px; +} +.float-card__value { + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: var(--fw-bold); + color: var(--fg-strong); + line-height: 1.2; +} + +/* Floating label chip — the tags pinned around a portrait. */ +.pin { + position: absolute; + z-index: 3; + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0.45rem 0.9rem; + border-radius: var(--radius-pill); + background: var(--surface-page); + box-shadow: var(--shadow-md); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + color: var(--fg-strong); + white-space: nowrap; +} +.pin--ink { background: var(--ink-900); color: #fff; } +.pin::before { + content: ""; + width: 7px; height: 7px; + border-radius: var(--radius-pill); + background: var(--accent-500); +} + +/* -------------------------------------------------------------------------- + Trust bar — "Loved by innovative teams" + logo row + -------------------------------------------------------------------------- */ + +.trustbar { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-8); + flex-wrap: wrap; +} +.trustbar__label { + font-size: var(--fs-small); + font-weight: var(--fw-medium); + color: var(--fg-muted); +} +.trustbar__logos { + display: flex; + align-items: center; + gap: var(--space-8); + flex-wrap: wrap; + list-style: none; +} +.trustbar__logos li { + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: var(--fw-bold); + color: var(--ink-400); + letter-spacing: -0.01em; +} + +/* -------------------------------------------------------------------------- + Tinted feature cards — each card a different soft tint + -------------------------------------------------------------------------- */ + +.tint-grid { display: grid; gap: var(--space-4); grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); } + +.tint-card { + padding: var(--space-5); + border-radius: var(--radius-lg); + background: var(--tint, var(--accent-50)); +} +.tint-card__title { font-family: var(--font-display); font-weight: var(--fw-bold); color: var(--fg-strong); margin-top: var(--space-3); } +.tint-card__body { font-size: var(--fs-small); color: var(--fg-body); margin-top: var(--space-2); } + +/* Four house tints. They are *neutral supports*, not second accents — each is + desaturated far enough that the accent still owns every emphasis on the page. */ +.tint-1 { --tint: var(--accent-50); } +.tint-2 { --tint: #FFF1E8; } +.tint-3 { --tint: #E9F7EF; } +.tint-4 { --tint: #EAF2FF; } + +@media (max-width: 900px) { .tint-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 560px) { .tint-grid { grid-template-columns: 1fr; } } + +/* -------------------------------------------------------------------------- + Step rail — oversized ghost numerals down a dotted centre line + Alternating sides, copy one side / visual the other. + -------------------------------------------------------------------------- */ + +.rail { position: relative; display: grid; gap: var(--space-10); } + +.rail::before { /* the dotted spine */ + content: ""; + position: absolute; + top: 0; bottom: 0; left: 50%; + border-left: 2px dotted var(--ink-200); +} + +.rail__row { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--space-6); + align-items: center; +} + +.rail__num { + font-family: var(--font-display); + font-size: clamp(3.5rem, 7vw, 6rem); + font-weight: var(--fw-black); + line-height: 1; + letter-spacing: -0.04em; + color: var(--ink-100); + background: var(--surface-page); + padding-block: var(--space-3); + font-variant-numeric: tabular-nums; + text-align: center; +} +.rail__num--label::before { + content: "STEP"; + display: block; + font-size: 0.75rem; + font-weight: var(--fw-semibold); + letter-spacing: 0.18em; + color: var(--ink-300); +} + +/* Every child needs an explicit order, or the numeral keeps its default 0 and + lands in the first column — pushing the visual into the narrow centre one. */ +.rail__row--flip .rail__visual { order: 1; } +.rail__row--flip .rail__num { order: 2; } +.rail__row--flip .rail__copy { order: 3; } + +@media (max-width: 800px) { + .rail::before { left: 18px; } + .rail__row { grid-template-columns: auto 1fr; gap: var(--space-4); } + .rail__row .rail__num { order: 1; font-size: 2.5rem; } + .rail__row .rail__copy { order: 2; } + .rail__row .rail__visual { order: 3; grid-column: 2; } +} + +/* -------------------------------------------------------------------------- + Pricing + -------------------------------------------------------------------------- */ + +.price-grid { display: grid; gap: var(--space-5); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); align-items: start; } + +.price-card { + position: relative; + padding: var(--space-6); + border: 1px solid var(--ink-100); + border-radius: var(--radius-lg); + background: var(--surface-page); +} +.price-card--featured { + border-color: var(--accent-500); + border-width: 2px; + box-shadow: var(--shadow-lg); +} +.price-card__flag { + position: absolute; + top: 0; left: 50%; + transform: translate(-50%, -50%); + padding: 0.3rem 0.9rem; + border-radius: var(--radius-pill); + background: var(--accent-500); + color: var(--accent-on); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + white-space: nowrap; +} +.price-card__amount { + font-family: var(--font-display); + font-size: 2.75rem; + font-weight: var(--fw-black); + letter-spacing: -0.035em; + color: var(--fg-strong); + line-height: 1; +} +.price-card__period { font-size: var(--fs-small); color: var(--fg-muted); } + +.checklist { list-style: none; display: grid; gap: var(--space-3); font-size: var(--fs-small); } +.checklist li { display: flex; gap: var(--space-3); align-items: flex-start; } +.checklist li::before { + content: ""; + flex: none; + width: 18px; height: 18px; + margin-top: 2px; + border-radius: var(--radius-pill); + background: var(--accent-500); + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m5 13 4 4L19 7' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/12px no-repeat, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m5 13 4 4L19 7' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/12px no-repeat, + linear-gradient(#000 0 0); + mask-composite: exclude; +} + +/* -------------------------------------------------------------------------- + FAQ accordion + -------------------------------------------------------------------------- */ + +.faq { display: grid; gap: var(--space-3); } + +.faq__item { + border: 1px solid var(--ink-100); + border-radius: var(--radius-md); + background: var(--surface-page); + padding: var(--space-4) var(--space-5); +} +.faq__item[open] { border-color: var(--accent-300); } + +.faq__q { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + cursor: pointer; + list-style: none; + font-family: var(--font-display); + font-size: var(--fs-small); + font-weight: var(--fw-semibold); + color: var(--fg-strong); +} +.faq__q::-webkit-details-marker { display: none; } +.faq__q::after { + content: "+"; + font-size: 1.25rem; + font-weight: var(--fw-regular); + color: var(--accent-600); + line-height: 1; +} +.faq__item[open] .faq__q::after { content: "\2212"; } +.faq__a { font-size: var(--fs-small); color: var(--fg-body); margin-top: var(--space-3); } + +/* -------------------------------------------------------------------------- + Marquee strip — the scrolling keyword band + -------------------------------------------------------------------------- */ + +.marquee { + overflow: hidden; + background: var(--accent-500); + color: var(--accent-on); + padding-block: var(--space-3); +} +.marquee__track { + display: flex; + gap: var(--space-6); + width: max-content; + animation: marquee-scroll 26s linear infinite; + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: var(--fw-semibold); +} +.marquee__track > span { display: flex; align-items: center; gap: var(--space-6); } +.marquee__track > span::after { content: "\2726"; opacity: 0.6; } + +@keyframes marquee-scroll { to { transform: translateX(-50%); } } +@media (prefers-reduced-motion: reduce) { + .marquee__track { animation: none; } +} + +/* -------------------------------------------------------------------------- + CTA band — full-width accent slab with copy left, action right + -------------------------------------------------------------------------- */ + +.cta-band { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-6); + flex-wrap: wrap; + padding: var(--space-7) var(--space-8); + border-radius: var(--radius-xl); + background: var(--accent-500); + color: var(--accent-on); +} +.cta-band--gradient { background: var(--gradient-hero); color: #fff; } +.cta-band .h3 { color: inherit; } + +/* Newsletter input group */ +.field-group { + display: flex; + gap: var(--space-2); + background: var(--surface-page); + border: 1px solid var(--ink-200); + border-radius: var(--radius-pill); + padding: 0.3rem 0.3rem 0.3rem 1.1rem; + max-width: 420px; +} +.field-group input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + font: inherit; + font-size: var(--fs-small); + color: var(--fg-strong); +} +.field-group input:focus { outline: none; } +.field-group:focus-within { border-color: var(--accent-500); } + +/* -------------------------------------------------------------------------- + Dark page variant + The favourites include a fully dark portfolio. `on-dark` flips foreground + roles; these few overrides carry the web components across with it. + -------------------------------------------------------------------------- */ + +.on-dark .card, +.on-dark .price-card, +.on-dark .faq__item, +.on-dark .float-card { + background: var(--ink-800); + border-color: rgba(255, 255, 255, 0.09); +} +.on-dark .tint-card { background: rgba(255, 255, 255, 0.05); } +.on-dark .rail__num { background: var(--ink-900); color: rgba(255, 255, 255, 0.12); } +.on-dark .rail::before { border-color: rgba(255, 255, 255, 0.16); } +.on-dark .trustbar__logos li { color: rgba(255, 255, 255, 0.45); } +.on-dark .badge-pill { background: rgba(255, 255, 255, 0.1); color: var(--accent-300); } +.on-dark .field-group { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.14); } +.on-dark .field-group input { color: #fff; } +.on-dark .underlined { color: var(--accent-300); } diff --git a/design-system/examples/deck.html b/design-system/examples/deck.html new file mode 100644 index 0000000..1c0edb6 --- /dev/null +++ b/design-system/examples/deck.html @@ -0,0 +1,420 @@ + + + + + +Corporate Deck Design System — Slide Deck + + + + + + + + +
+ +
+
+ Reference deck +

Fourteen slide archetypes

+

Every slide below is composed from system classes only — no bespoke CSS. Print this page to get one slide per landscape page.

+
+
+ + + + + + +
+
+ +
+ + +
+
+
+
+ + Inspirational business presentation +

+ Doing everything + Right +

+
+

+ Presented by Elizabeth Jones
+ 25 September 2025 · London, UK +

+
+
Cover photography
+
+
+

01 — Cover, split

+
+ + +
+
+
+
Full-bleed photo
+
+ Annual review +

Business Report 2025

+

Nullam eu tempor purus. Nunc a leo magna, sit amet consequat risus.

+
+
+
+

02 — Cover, full bleed

+
+ + +
+
+ +
Contents
+
02
+
+
+
+ Table of contents +

What we will cover

+
+
+
01Who we are
+
04The numbers
+
02Our services
+
05Roadmap
+
03How we work
+
06Next steps
+
+
+
+
+

03 — Agenda

+
+ + +
+
+
+
+ 01 +

Who we are

+

A short read on the firm, the bench, and how we staff.

+
+
Section photo
+
+
+

04 — Section divider

+
+ + +
+
+ +
Who we are
+
04
+
+
+
+ About us +

Let's talk about who we are

+
+

We are a twenty-person operating firm. Every engagement is led by a partner who stays on the work from diagnosis through delivery — there is no handoff to a junior bench, because there isn't one.

+

We take on six clients a year. That is the constraint, and we are not trying to solve it.

+
+
Team photo
+
+
+
+

05 — Split, media right

+
+ + +
+
+ +
Our services
+
05
+
+
+
+ What we do +

Three practices

+
+
+
+ +

Planning

+

Annual and quarterly plans that survive contact with the calendar.

+
+
+ +

Analytics

+

The three numbers that matter, reported the same way every week.

+
+
+ +

Delivery

+

We stay through execution. Plans without follow-through are decks.

+
+
+
+
+
+

06 — Three cards

+
+ + +
+
+ +
How we work
+
06
+
+
+
+ Our process +

Three phases, twelve weeks

+
+
+
+

Diagnose

+

Two weeks inside the business. Interviews, data pull, and an honest read on what is blocking the quarter.

+
+
+

Design

+

A plan with named owners and dated milestones, reviewed until there are no open questions.

+
+
+

Deliver

+

Eight weeks embedded, running the weekly cadence until it runs without us.

+
+
+
+
+
+

07 — Numbered steps

+
+ + +
+
+ + +
The numbers
+
07
+
+
+
+ By the numbers +

Four years, measured

+
+
+
$1.4BRevenue influenced
+
168Engagements
+
11.5Weeks to impact
+
94%Clients retained
+
+
+
+
+

08 — Stat row, dark

+
+ + +
+
+ +
Roadmap
+
08
+
+
+
+ Our story so far +

A little bit of history

+
+
+
2019

Founded with three partners and one client.

+
2021

Analytics practice opens; first retained engagement.

+
2023

London office. Bench grows to fourteen.

+
2025

Twenty people, six clients a year, capped deliberately.

+
+
+
+
+

09 — Timeline

+
+ + +
+
+ +
Position
+
09
+
+
+
+ Where we stand +

SWOT analysis

+
+
+
Strengths

Senior bench on every engagement; repeatable operating cadence.

+
Weaknesses

Deliberately small — capacity is the binding constraint.

+
Opportunities

Mid-market operators underserved by the major firms.

+
Threats

In-house strategy teams maturing quickly.

+
+
+
+
+

10 — SWOT

+
+ + +
+
+ +
Financials
+
10
+
+
+
+ Financial overview +

Revenue by year

+
+

Series one carries the accent; every other series recedes to grey so the eye lands where the argument is.

+
+ + + + + + + + + + + + + + + + + + + + + + 2022 + 2023 + 2024 + 2025 + + +
+
+
+

11 — Chart

+
+ + +
+
+ +
The team
+
11
+
+
+
+ Our people +

The team

+
+
+
+
IL
+

Isabella Linton

+

Managing Partner

+
+
+
AD
+

Arthur Dodger

+

Head of Analytics

+
+
+
EJ
+

Elizabeth Jones

+

Delivery Lead

+
+
+
+
+
+

12 — Team

+
+ + +
+
+ +
Testimonials
+
12
+
+
+
Client photo
+
+ They did not hand us a deck and leave. Eleven weeks later the cadence was still running, and it was our team running it. +
+ Marta ReyesCOO, Halden Group +
+
+
+
+
+

13 — Testimonial

+
+ + +
+
+ +
+ For watching +

Thank you

+

+ hello@northbrook.example · +44 20 7946 0100 · northbrook.example +

+
+
+

14 — Closing

+
+ +
+
+ + + + + diff --git a/design-system/examples/index.html b/design-system/examples/index.html new file mode 100644 index 0000000..dab4d2b --- /dev/null +++ b/design-system/examples/index.html @@ -0,0 +1,291 @@ + + + + + +Corporate Deck Design System — Web Template + + + + + + + +
+ +
+ + +
+
+ +
+ Strategy & Operations +

Doing everything right, from day one.

+

+ We help operators turn a messy quarter into a plan the whole company can + follow — then we stay on to make sure it actually ships. +

+ +
+
168Engagements
+
29Countries
+
94%Retained
+
+
+
+
Photography
+
+
+
+ + +
+
+
+ What we do +

Four practices, one operating rhythm

+
+
+
+
+ +

Planning

+

Annual and quarterly plans that survive contact with the calendar.

+
+
+ +

Analytics

+

The three numbers that matter, reported the same way every week.

+
+
+ +

Org design

+

Structures that match the work, not last year's headcount.

+
+
+ +

Delivery

+

We stay through execution. Plans without follow-through are decks.

+
+
+
+
+ + +
+
+
+ How it works +

Three phases, twelve weeks

+
+
+
+
+

Diagnose

+

Two weeks inside the business. Interviews, data pull, and an honest read on what is actually blocking the quarter.

+
+
+

Design

+

A plan with named owners and dated milestones. Reviewed with the leadership team until there are no open questions.

+
+
+

Deliver

+

Eight weeks embedded with your team, running the weekly cadence until it runs without us.

+
+
+
+
+ + +
+
+ +
+ By the numbers +

What the last four years looked like

+
+
+
+
+ $1.4B + Revenue influenced +
+
+ 168 + Engagements closed +
+
+ 11.5 + Avg. weeks to impact +
+
+ 94% + Clients who return +
+
+
+
+ + +
+
+
+ Capability mix +

Where the team is strongest

+

+ We publish our own capability scores twice a year and staff engagements + against them, so you know exactly what you are getting. +

+
+
+
+
Operating strategy92%
+
+
+
+
Financial modelling85%
+
+
+
+
Org & talent design78%
+
+
+
+
Go-to-market71%
+
+
+
+
+
+ + +
+
+
+ The team +

Senior people, on the work

+
+
+
+
+
IL
+

Isabella Linton

+

Managing Partner

+
+
+
AD
+

Arthur Dodger

+

Head of Analytics

+
+
+
EJ
+

Elizabeth Jones

+

Delivery Lead

+
+
+
+
+ + +
+
+
+ They did not hand us a deck and leave. Eleven weeks later the weekly + cadence was still running, and it was our team running it. +
+
MR
+ Marta ReyesCOO, Halden Group +
+
+
+
Strengths

Senior bench, repeatable operating cadence.

+
Weaknesses

Deliberately small — capacity is the constraint.

+
Opportunities

Mid-market operators underserved by the majors.

+
Threats

In-house strategy teams maturing quickly.

+
+
+
+ + +
+
+

Ready to fix the quarter?

+

+ Tell us what is stuck. We will come back within two days with a view on + whether we are the right people for it. +

+ +
+
+ +
+
+

Corporate Deck Design System — reference web template.

+
+ Switch accent theme +
+ + + + + + +
+
+
+
+ + + + + diff --git a/design-system/examples/intake-standalone.html b/design-system/examples/intake-standalone.html new file mode 100644 index 0000000..c4dbf81 --- /dev/null +++ b/design-system/examples/intake-standalone.html @@ -0,0 +1,2831 @@ + + + + + +Design Intake — Corporate Deck Design System + + + + + +
+ +
+ Before we design anything +

Six questions

+

+ Every answer changes a concrete setting. Answer these and you get a spec you + can hand to any designer — or paste into an AI tool — and get back something + that looks like it belongs to the rest of your work. +

+
+ +
+ + + + + + + + + + +
+
+
+ Your spec +
+
+
+ 0 of 6 +
+
Answer the questions above and your spec appears here.
+
+ + + +
+

One self-contained HTML file — your theme baked in, no repo or build needed.

+
+
+ +
+ + + + + diff --git a/design-system/examples/landing.html b/design-system/examples/landing.html new file mode 100644 index 0000000..3374f77 --- /dev/null +++ b/design-system/examples/landing.html @@ -0,0 +1,297 @@ + + + + + +Corporate Deck Design System — Landing Page (Favourites) + + + + + + + + + +
+
+ + +
+
+ New AI-powered productivity +

One platform.
Endless possibilities.

+

+ Flowora brings your tasks, projects and team together — so you can + focus on what actually matters. +

+ +
+ +
+ +
+
Dashboard UI
+
+
+ 99.9% + Uptime this quarter +
+
+
+
+ + +
+ + + + + +
+
+ Loved by innovative teams +
    +
  • Slack
  • Notion
  • Dropbox
  • Zoom
  • Airbnb
  • +
+
+
+ + +
+
+
+
+ Features +

Everything you need to get work done

+

Designed to boost productivity and drive real results.

+ +
+
+
+ +

Task management

+

Organise tasks, set deadlines, track progress.

+
+
+ +

Team collaboration

+

Communicate and work together in real time.

+
+
+ +

Smart automation

+

Automate repetitive work and save hours.

+
+
+ +

Analytics

+

Insights that help you make better calls.

+
+
+
+
+
+ + +
+
+
+ How it works +

Three steps to shipping

+
+
+
+
+
+

Connect your tools

+

Bring your existing stack in with one click. Nothing to migrate, nothing to rebuild.

+ Learn more → +
+
01
+
Illustration
+
+
+
+

Set the cadence

+

Owners, dates, and a weekly rhythm the whole team can actually follow.

+ Learn more → +
+
02
+
Illustration
+
+
+
+

Ship and measure

+

Every change tracked against the number it was meant to move.

+ Learn more → +
+
03
+
Illustration
+
+
+
+
+ + +
+
+
+
+
12K+Active users
+
240K+Tasks completed
+
99.9%Uptime
+
4.9/5User rating
+
+
+
+
+ + +
+
+
+ Pricing +

Simple, honest plans

+
+
+
+
+

Starter

+

For small teams

+
$0 / month
+
  • Up to 5 users
  • Core features
  • Community support
+ Get started +
+ +
+

Enterprise

+

For large organisations

+
Custom
+
  • Custom workflows
  • Dedicated support
  • SLA & security
+ Contact sales +
+
+
+
+ + +
+
+
+ Questions +

Everything you need to know

+
+
+
+ Is there a free plan? +

Yes — up to five users, with the core feature set and no time limit.

+
+
+ Can I use it without writing code? +

Entirely. Everything is configured from the interface.

+
+
+ Do you offer migration help? +

On Pro and Enterprise we migrate your existing projects for you.

+
+
+ What does support look like? +

Community on Starter, same-day on Pro, and a named contact on Enterprise.

+
+
+
+
+ + +
+
+
+
+

Ready to boost your team's productivity?

+

Join thousands of teams already using Flowora.

+
+ Start free trial +
+
+
+ + +
+
+
+ Flowora +

All-in-one platform to manage tasks, teams and projects.

+
+ + +
+
+
+ Switch accent theme +
+ + + + + + +
+
+
+
+ + + + + diff --git a/design-system/examples/picker.html b/design-system/examples/picker.html new file mode 100644 index 0000000..aa39ac9 --- /dev/null +++ b/design-system/examples/picker.html @@ -0,0 +1,1280 @@ + + + + + +Design Intake — Corporate Deck Design System + + + + + + + +
+ +
+ Before we design anything +

Six questions

+

+ Every answer changes a concrete setting. Answer these and you get a spec you + can hand to any designer — or paste into an AI tool — and get back something + that looks like it belongs to the rest of your work. +

+
+ +
+ + + + + + + + + + +
+
+
+ Your spec +
+
+
+ 0 of 6 +
+
Answer the questions above and your spec appears here.
+
+ + + +
+

One self-contained HTML file — your theme baked in, no repo or build needed.

+
+
+ +
+ + + + diff --git a/design-system/index.html b/design-system/index.html new file mode 100644 index 0000000..9ed8ea9 --- /dev/null +++ b/design-system/index.html @@ -0,0 +1,240 @@ + + + + + +Design System — start here + + + + + + + + + +
+ +
+ +
+
+ +
+ One house style, two layers +

Everything comes back looking like it belongs together.

+

+ A codified design system for presentations, PowerPoint templates and + websites. Answer a few plain questions and you get a page you can look at + — plus a brief precise enough to hand to a designer or paste into an AI + tool, and get consistent work back. +

+ +
+ Nine accent themes — one class swaps the whole system + +
+
+ +
+
+ + + + + + + + + + + + + + + +
+
+
+
+ + +
+ +
+ + +
+
+
+ What's in the box +

Two layers over one set of tokens

+
+
+ +
+
+ +

Deck layer — presentations & PowerPoint

+

+ Fourteen slide archetypes on a 16:9 canvas that composes identically as + a thumbnail or full-screen. Ships with a PowerPoint spec: theme colour + slots, the type scale in points, corner radii as adj values, + and the slide-master layouts. +

+ +
+ +
+ +

Web layer — landing pages & portfolios

+

+ Gradient heroes, device mockups, step rails, pricing, FAQ, marquee and a + dark variant. Everything the deck layer deliberately doesn't do, because + a printed slide and a scrolling page are not the same problem. +

+ +
+
+
+
+ + +
+
+
+ No repo, no build +

It's plain CSS. Nothing to install.

+

+ No framework, no build step, no dependencies, no network requests. Every + file here opens directly in a browser. If you'd rather not clone anything, + the intake will hand you a single self-contained HTML file with your theme + already baked in. +

+ +
+
    +
  • Nine accent themes — one class re-themes the whole system
  • +
  • Contrast checked per theme, so text on the accent always clears 4.5:1
  • +
  • Uploads stay in your browser; nothing is sent anywhere
  • +
  • Sourced photography constrained to free-licence libraries
  • +
  • Each commissioned job gets its own folder and a recorded spec
  • +
  • Clients approve their page, then get everything as one download
  • +
+
+
+ +
+
+
+ Design System +

+ Distilled from 44 reference templates — 32 corporate decks and 12 web + landing pages — into rules a person or a machine can follow. +

+
+ +
+
+ + + diff --git a/design-system/lib/package.js b/design-system/lib/package.js new file mode 100644 index 0000000..64c0ff3 --- /dev/null +++ b/design-system/lib/package.js @@ -0,0 +1,527 @@ +/* Turning a brief into a deliverable package. + * + * This module is the single source of truth for what a customer receives, and + * it runs unchanged in two places: in the browser, so they can preview their + * page instantly, and in the Worker, so the copy we actually deliver is built + * by the server. That split is the point — a download the browser assembles + * can never be gated, because the browser already has everything it needs. + * + * Nothing here touches the DOM, the network, or any global. Give it answers + * and stylesheet text; get back files. + */ + +/* ---- Escaping ------------------------------------------------------------- + Everything below interpolates customer text into HTML. In a single-user page + that was harmless — the only person you could attack was yourself. As a + multi-user service it is not: a brief is stored, and later rendered where + someone else (an admin reviewing it, or a teammate) is signed in. So every + value that reaches markup goes through here first. -------------------------- */ +const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +export function esc(value) { + return String(value ?? '').replace(/[&<>"']/g, c => HTML_ESCAPES[c]); +} + +/* A logo arrives as a data: URL read from the customer's own disk. Anything + else in that slot — javascript:, or a remote URL — is either an attack or a + silent external dependency, and the package promises to have neither. */ +export function safeImageSrc(src) { + return /^data:image\/(png|jpeg|gif|webp|svg\+xml|avif);base64,[A-Za-z0-9+/=]+$/.test(String(src || '')) + ? src + : null; +} + +/* ---- Themes --------------------------------------------------------------- + Read straight out of tokens.css so the stylesheet stays the one place a + colour is defined. The browser used to probe a hidden element for computed + styles; that has no meaning in a Worker, and this works in both. ----------- */ +export function parseRamps(tokensCss) { + const ramps = {}; + for (const [, name, body] of tokensCss.matchAll(/\.theme-([a-z]+)\s*\{([^}]*)\}/g)) { + const read = key => (body.match(new RegExp(`--accent-${key}:\\s*(#[0-9A-Fa-f]{3,8})`)) || [])[1]; + const ramp = { 100: read(100), 300: read(300), 500: read(500), 600: read(600), on: read('on') }; + if (ramp[500]) ramps[name] = ramp; + } + return ramps; +} + +export const PALETTES = [ + { id:'warm', theme:'amber', name:'Warm and confident', tag:'Most popular', ground:'#FFFFFF', dark:false, mood:'Approachable, energetic, human' }, + { id:'trusted', theme:'navy', name:'Calm and trusted', tag:'Classic', ground:'#FFFFFF', dark:false, mood:'Established, safe, serious' }, + { id:'modern', theme:'violet', name:'Modern and sharp', tag:'Tech', ground:'#FFFFFF', dark:false, mood:'Current, technical, premium' }, + { id:'natural', theme:'green', name:'Fresh and natural', tag:'', ground:'#F7F8FA', dark:false, mood:'Healthy, sustainable, calm' }, + { id:'bold', theme:'coral', name:'Bold and lively', tag:'', ground:'#FAF6EC', dark:false, mood:'Creative, warm, unmissable' }, + { id:'clean', theme:'teal', name:'Clean and clear', tag:'', ground:'#FFFFFF', dark:false, mood:'Precise, clinical, open' }, + { id:'money', theme:'indigo', name:'Sharp and precise', tag:'Finance', ground:'#FFFFFF', dark:false, mood:'Exact, dependable, quiet' }, + { id:'darkprem', theme:'violet', name:'Dark and premium', tag:'Dark', ground:'#14181F', dark:true, mood:'Striking, high-end, confident' }, + { id:'darkgold', theme:'amber', name:'Dark and warm', tag:'Dark', ground:'#14181F', dark:true, mood:'Rich, considered, editorial' }, +]; + +export const KIND_LABEL = { + website: 'A website', + landing: 'A page to sell something', + deck: 'A presentation', + portfolio: 'A portfolio', +}; + +export const paletteById = id => PALETTES.find(p => p.id === id) || PALETTES[0]; + +/* ---- Reading the answers -------------------------------------------------- */ + +/* The shape every function here expects. Answers arrive from a form in the + browser and from JSON in the Worker; normalising once means neither caller + can hand the generator a half-built object. */ +export function normalise(input = {}) { + const fields = input.fields || {}; + return { + kind: KIND_LABEL[input.kind] ? input.kind : null, + palId: paletteById(input.palId).id, + logoMode: ['have', 'describe', 'none'].includes(input.logoMode) ? input.logoMode : null, + logoStyle: input.logoStyle || null, + logo: input.logo && safeImageSrc(input.logo.src) + ? { name: String(input.logo.name || 'logo'), src: input.logo.src } + : null, + copyMode: ['file', 'answer', 'none'].includes(input.copyMode) ? input.copyMode : null, + copyText: String(input.copyText || ''), + fields: Object.fromEntries( + ['brandName', 'logoWords', 'cName', 'cWhat', 'cWho', 'cWhy', 'cOffer', 'cAction', 'cContact'] + .map(k => [k, String(fields[k] || '')])), + }; +} + +export function brandOf(a) { + return (a.fields.cName || a.fields.brandName || '').trim(); +} + +export function logoSummary(a) { + if (a.logoMode === 'have') return a.logo ? `Supplied — ${a.logo.name}` : 'Ready to upload'; + if (a.logoMode === 'describe') { + const bits = [a.fields.brandName, a.logoStyle].filter(Boolean); + return bits.length ? bits.join(' · ') : 'To be described'; + } + if (a.logoMode === 'none') return a.fields.cName || a.fields.brandName || 'Name only, for now'; + return null; +} + +export function copySummary(a) { + if (a.copyMode === 'file') { + const w = a.copyText.trim() ? a.copyText.trim().split(/\s+/).length : 0; + return w ? `Your document — ${w} words` : 'Ready to upload'; + } + if (a.copyMode === 'answer') { + const filled = ['cName', 'cWhat', 'cWho', 'cWhy', 'cOffer', 'cAction'] + .filter(k => (a.fields[k] || '').trim()).length; + return `We'll draft it — ${filled} of 6 answered`; + } + if (a.copyMode === 'none') return "We'll draft it from scratch"; + return null; +} + +/* Enough answered to be worth generating anything. */ +export const isReady = a => Boolean(a.kind && a.palId); + +/* Close a clause typed as a fragment, so joined answers don't run together. + Leaves existing terminal punctuation — including a question mark — alone. */ +function endSentence(text) { + const t = String(text).trim(); + return /[.!?…]$/.test(t) ? t : `${t}.`; +} + +/* ---- Turning answers into real copy --------------------------------------- + No model runs here, so this composes a first draft from the customer's own + words rather than inventing prose. It is labelled as a draft everywhere it + appears, because that is what it is. -------------------------------------- */ +export function draftCopy(a) { + const f = a.fields; + const name = (f.cName || f.brandName || 'Your name here').trim(); + const what = (f.cWhat || '').trim(); + const who = (f.cWho || '').trim(); + const why = (f.cWhy || '').trim(); + const act = (f.cAction || 'Get in touch').trim(); + const contact = (f.cContact || '').trim(); + const offers = (f.cOffer || '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 3); + + let headline = '', sub = ''; + let whatSpent = false; + if (a.copyMode === 'file' && a.copyText.trim()) { + const lines = a.copyText.trim().split('\n').map(s => s.trim()).filter(Boolean); + headline = lines[0] || what || name; + sub = lines.slice(1).join(' ').slice(0, 220) || ''; + whatSpent = what === headline; + } else { + headline = why || what || name; + whatSpent = what !== '' && what === headline; + /* People type answers, not sentences — "we plan quarters" has no full stop. + Two of those joined with a space read as one run-on line, so each clause + is closed before the next begins. */ + if (who) { + sub = `Made for ${who.toLowerCase()}.`; + } else if (what && what !== headline) { + sub = endSentence(what); + whatSpent = true; + } else { + sub = 'A line about what you do and who it helps.'; + } + } + + /* Each answer earns one place on the page. Repeating the same sentence as a + headline and again as the section title below it is the tell of generated + copy, so whichever slot has already used it releases the others. */ + const offersHeading = what && !whatSpent ? what : 'What we offer'; + const whyHeading = why && why !== headline ? why : null; + + return { name, headline, sub, offers, act, contact, who, what, why, offersHeading, whyHeading }; +} + +/* ---- Build the page ------------------------------------------------------- + `css` is the concatenated stylesheet text. The caller supplies it because + the two environments obtain it differently: the browser reads its own + + + +${body} + +`; +} + +/* ---- The package ---------------------------------------------------------- */ + +export function packageFiles(answers, pageHtml, { contact = 'hello@example.com', date = new Date() } = {}) { + const a = normalise(answers); + const d = draftCopy(a); + const p = paletteById(a.palId); + const offers = d.offers.length ? d.offers : ['—', '—', '—']; + + const copyMd = a.copyMode === 'file' && a.copyText.trim() + ? `# Your words\n\nThis is what we read from your document. Edit freely.\n\n---\n\n${a.copyText.trim()}\n` + : `# Your words + +A first draft, written from your answers. Change anything. + +## Headline +${d.headline} + +## Underneath it +${d.sub} + +## What you offer +${offers.map(o => '- ' + o).join('\n')} + +## What you want people to do +${d.act} + +## Contact +${d.contact || '(add your contact details)'} +`; + + const briefMd = `# Your brief + +| | | +|---|---| +${brandOf(a) ? `| For | ${brandOf(a)} |\n` : ''}| You needed | ${KIND_LABEL[a.kind] || '—'} | +| The look | ${p.name} — ${p.mood.toLowerCase()} | +| Logo | ${logoSummary(a) || '—'} | +| Words | ${copySummary(a) || '—'} | +${a.logoMode === 'describe' && a.fields.logoWords ? `\n**Logo notes:** ${a.fields.logoWords}\n` : ''} +${a.logoStyle ? `**Logo type:** ${a.logoStyle}\n` : ''} +Approved on ${date.toLocaleDateString('en-GB')}. +`; + + const readme = `# ${d.name} + +Everything here is yours to keep. + +## The files + +| File | What it is | +|---|---| +| \`index.html\` | Your page. Double-click it to open in any browser. | +| \`copy.md\` | Your words on their own, so you can edit them without touching the page. | +| \`BRIEF.md\` | What you chose, for your records. | + +## Changing the words + +Open \`index.html\` in any text editor and edit the text between the tags. The +wording in it is a first draft — replace it with your own. + +## Putting it online + +The page is a single file with nothing else to install, so almost anywhere works: + +- **Netlify Drop** — drag the folder onto netlify.com/drop +- **GitHub Pages** — put \`index.html\` in a repository and turn Pages on +- **Your own host** — upload \`index.html\` by FTP + +## Making changes later + +Nothing here expires and nothing phones home. If you'd rather we made the +changes, get in touch: ${contact} +`; + + return [ + { name: 'index.html', text: pageHtml }, + { name: 'copy.md', text: copyMd }, + { name: 'BRIEF.md', text: briefMd }, + { name: 'README.md', text: readme }, + ]; +} + +/* A filename that is safe on every platform and still recognisably theirs. */ +export function packageName(answers) { + const a = normalise(answers); + const slug = (brandOf(a) || 'my-project') + .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48); + return `${slug || 'my-project'}-files.zip`; +} + +/* ---- Building the .zip ----------------------------------------------------- + Written by hand so there is no dependency on either side: CRC-32, per-file + headers, a central directory and an end record. Entries are deflated with + the platform's own CompressionStream — present in both browsers and Workers + — and fall back to stored if that isn't available or doesn't actually help. + Returns bytes rather than a Blob, because a Worker has to stream them into + a Response and a Blob is the browser's concern. --------------------------- */ +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + t[n] = c >>> 0; + } + return t; +})(); + +export function crc32(u8) { + let c = 0xFFFFFFFF; + for (let i = 0; i < u8.length; i++) c = CRC_TABLE[(c ^ u8[i]) & 0xFF] ^ (c >>> 8); + return (c ^ 0xFFFFFFFF) >>> 0; +} + +async function deflateRaw(u8) { + if (typeof CompressionStream === 'undefined') return null; + try { + const cs = new CompressionStream('deflate-raw'); + const writer = cs.writable.getWriter(); + writer.write(u8); + writer.close(); + return new Uint8Array(await new Response(cs.readable).arrayBuffer()); + } catch { + return null; + } +} + +export async function makeZip(files) { + const enc = new TextEncoder(); + const parts = [], central = []; + let offset = 0; + + for (const f of files) { + const data = enc.encode(f.text); + const crc = crc32(data); + let comp = await deflateRaw(data), method = 8; + if (!comp || comp.length >= data.length) { comp = data; method = 0; } + const name = enc.encode(f.name); + + const lh = new Uint8Array(30 + name.length); + const dv = new DataView(lh.buffer); + dv.setUint32(0, 0x04034b50, true); dv.setUint16(4, 20, true); dv.setUint16(6, 0, true); + dv.setUint16(8, method, true); dv.setUint16(10, 0, true); dv.setUint16(12, 0x21, true); + dv.setUint32(14, crc, true); dv.setUint32(18, comp.length, true); dv.setUint32(22, data.length, true); + dv.setUint16(26, name.length, true); dv.setUint16(28, 0, true); + lh.set(name, 30); + parts.push(lh, comp); + + const cd = new Uint8Array(46 + name.length); + const cv = new DataView(cd.buffer); + cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); + cv.setUint16(8, 0, true); cv.setUint16(10, method, true); + cv.setUint16(12, 0, true); cv.setUint16(14, 0x21, true); + cv.setUint32(16, crc, true); cv.setUint32(20, comp.length, true); cv.setUint32(24, data.length, true); + cv.setUint16(28, name.length, true); + cv.setUint32(38, 0, true); cv.setUint32(42, offset, true); + cd.set(name, 46); + central.push(cd); + offset += lh.length + comp.length; + } + + const cdSize = central.reduce((n, c) => n + c.length, 0); + const eocd = new Uint8Array(22); + const ev = new DataView(eocd.buffer); + ev.setUint32(0, 0x06054b50, true); + ev.setUint16(8, files.length, true); ev.setUint16(10, files.length, true); + ev.setUint32(12, cdSize, true); ev.setUint32(16, offset, true); + + const all = [...parts, ...central, eocd]; + const total = all.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const chunk of all) { out.set(chunk, at); at += chunk.length; } + return out; +} + +/* Everything a delivery needs, in one call. */ +export async function buildPackage(answers, css, opts) { + const page = buildPage(answers, css); + const files = packageFiles(answers, page, opts); + return { page, files, zip: await makeZip(files), filename: packageName(answers) }; +} diff --git a/design-system/powerpoint/SPEC.md b/design-system/powerpoint/SPEC.md new file mode 100644 index 0000000..fa9d7d3 --- /dev/null +++ b/design-system/powerpoint/SPEC.md @@ -0,0 +1,195 @@ +# PowerPoint Implementation Spec + +Concrete numbers for building the system as a `.potx` template. All values assume +the **16:9 widescreen** slide size: **13.333″ × 7.5″** (33.87 cm × 19.05 cm). + +To convert to the older 10″ × 5.625″ canvas, multiply every length by `0.75`. + +--- + +## Slide setup + +| Setting | Value | +|---|---| +| Slide size | Widescreen 16:9 — 13.333″ × 7.5″ | +| Safe margin | 0.87″ all sides (6.5%) | +| Grid | 12 columns, 0.87″ outer margin, 0.17″ gutter → column width 0.93″ | +| Baseline | 0.042″ (4px at 96 DPI) | + +Content never crosses the safe margin. Full-bleed photos and colour blocks are +the only exception. + +--- + +## Theme colours + +Map these into **Design → Variants → Colors → Customize Colors**. Slots are named +by PowerPoint's own labels. + +| Slot | Amber (default) | Electric | Navy | Teal | Green | Coral | +|---|---|---|---|---|---|---| +| Text/Background – Dark 1 | `14181F` | `14181F` | `14181F` | `14181F` | `14181F` | `14181F` | +| Text/Background – Light 1 | `FFFFFF` | `FFFFFF` | `FFFFFF` | `FFFFFF` | `FFFFFF` | `FFFFFF` | +| Text/Background – Dark 2 | `233049` | `233049` | `152547` | `233049` | `233049` | `233049` | +| Text/Background – Light 2 | `F7F8FA` | `F7F8FA` | `F7F8FA` | `F7F8FA` | `F7F8FA` | `FAF6EC` | +| **Accent 1** (the accent) | `F5B21A` | `2B50EE` | `2B4A8B` | `22BCCE` | `17A673` | `E85D33` | +| Accent 2 (accent light) | `FFD24D` | `8DA5FF` | `8095C4` | `63D6E2` | `63CFA1` | `F29B80` | +| Accent 3 (accent pale) | `FFF8E6` | `EDF1FF` | `EEF2F9` | `E6F8FA` | `E7F7EF` | `FDEEEA` | +| Accent 4 (ink mid) | `3A4761` | `3A4761` | `3A4761` | `3A4761` | `3A4761` | `3A4761` | +| Accent 5 (grey) | `8993A6` | `8993A6` | `8993A6` | `8993A6` | `8993A6` | `8993A6` | +| Accent 6 (hairline) | `ECEFF3` | `ECEFF3` | `ECEFF3` | `ECEFF3` | `ECEFF3` | `ECEFF3` | +| Hyperlink | `E09A00` | `1D3ACC` | `1F3768` | `1897A6` | `11855C` | `C64720` | +| Followed hyperlink | `B87A00` | `152B99` | `152547` | `11707B` | `0B6244` | `993518` | + +**Chart series order** must be set on the theme so charts inherit it: +Accent 1 → Dark 2 → Accent 2 → Accent 5 → Accent 3 → Accent 6. + +--- + +## Theme fonts + +| Slot | Font | Fallback if unavailable | +|---|---|---| +| Headings | Poppins SemiBold / Bold | Montserrat → Segoe UI Semibold | +| Body | Inter Regular | Poppins Regular → Segoe UI | + +Embed fonts in the `.potx` (**File → Options → Save → Embed fonts in the file → +Embed only the characters used**) so the deck survives being opened elsewhere. + +--- + +## Type scale in points + +Slide-native sizes. These are the cqw values from `css/slides.css` resolved +against a 13.333″ canvas. + +| Style | Size | Weight | Tracking | Line spacing | +|---|---|---|---|---| +| Cover title | 66 pt | Black (800) | −0.03em ≈ −2.0 pt | 0.95 | +| Slide title (H1) | 50 pt | Black (800) | −0.025em ≈ −1.25 pt | 1.0 | +| Sub-head (H2) | 37 pt | Bold (700) | −0.02em ≈ −0.75 pt | 1.05 | +| Card title (H3) | 24 pt | Bold (700) | −0.01em | 1.15 | +| Feature label (H4) | 18 pt | SemiBold (600) | 0 | 1.25 | +| Body | 15 pt | Regular (400) | 0 | 1.5 | +| Small / card copy | 13 pt | Regular (400) | 0 | 1.45 | +| Caption | 11 pt | Medium (500) | 0 | 1.4 | +| **Eyebrow** | 12 pt | SemiBold (600) | **+0.18em ≈ +2.2 pt**, ALL CAPS | 1.15 | +| Section numeral | 100 pt | Black (800) | −0.04em ≈ −4 pt | 0.9 | +| Stat value | 46 pt | Black (800) | −0.035em | 1.0 | +| Stat label | 11 pt | SemiBold (600) | +0.06em, ALL CAPS | 1.3 | + +PowerPoint expresses tracking in points via **Format Text → Character Spacing → +Spacing: Expanded/Condensed By**. The pt equivalents above are pre-computed. + +--- + +## Shape geometry + +| Element | Size | Corner radius | +|---|---|---| +| Card | column-spanning, min 2.8″ × 1.9″ | 0.21″ (20px) | +| Media panel | varies | 0.33″ (32px) | +| Hero media | varies | 0.50″ (48px) | +| Icon chip | 0.55″ × 0.55″ | 0.125″ (12px) | +| Icon chip (large) | 0.75″ × 0.75″ | 0.21″ | +| Button | height 0.42″, pad 0.36″ horizontal | fully rounded | +| Accent rule | 0.58″ × 0.042″ | fully rounded | +| Meter bar | height 0.085″ | fully rounded | + +PowerPoint's rounded rectangle uses a *proportional* corner handle, so set radius +by dragging until the numeric adjustment reads the fraction below, or edit the +shape's `adj` value in the XML: + +| Target radius | On a 1.9″-tall shape | `adj` value | +|---|---|---| +| 0.21″ | 11% of the short side | `11000` | +| 0.33″ | 17% | `17000` | +| 0.50″ | 26% | `26000` | + +--- + +## Shadows + +One shadow preset only, applied to cards and floating panels: + +- **Outer shadow**, colour `14181F` at **9% opacity** +- Blur **24 pt**, Distance **9 pt**, Angle **90°**, Size 100% + +Never use PowerPoint's default shadow presets — they are far too heavy. + +--- + +## Slide layouts to build in the Slide Master + +Build these 14 layouts. Every one except Cover and Closing carries the furniture +described below. + +| # | Layout name | Structure | +|---|---|---| +| 1 | **Cover — split** | Type block left 52%, full-bleed photo right 48% | +| 2 | **Cover — bleed** | Full-bleed photo, dark scrim, type block over the left 62% | +| 3 | **Agenda** | Two columns × up to 4 numbered rows, hairline under each | +| 4 | **Section divider** | Accent panel left 50% with numeral + title; photo right | +| 5 | **Title + body** | H1 top-left, body block 7 columns | +| 6 | **Split — media right** | 5:7 type/media | +| 7 | **Split — media left** | 7:5 media/type | +| 8 | **Three cards** | 3 equal cards, icon chip + title + copy | +| 9 | **Four cards** | 4 equal cards | +| 10 | **Numbered steps** | 3–4 columns, ghost numeral above each | +| 11 | **Stat row** | Dark or accent fill, 3–4 stats, hairline dividers | +| 12 | **Timeline** | Horizontal rule with 4–6 accent dots | +| 13 | **SWOT** | 2×2 — S on accent, W on ink, O on accent-pale, T on light-2 | +| 14 | **Closing** | Centred display type, contact block, socials | + +### Furniture (place on the Slide Master, not per-slide) + +| Item | Position | Style | +|---|---|---| +| Logo | 0.87″ from left, 0.53″ from top | 12–16 pt, display SemiBold | +| Section kicker | right-aligned to 0.87″ from right, 0.58″ from top | 10 pt, +0.16em, CAPS, grey `8993A6` | +| Page number | right-aligned to 0.87″ from right, 0.53″ from bottom | 12 pt Bold, `B4BCC9`, tabular | + +Content placeholders on furnished layouts start at **1.47″ from the top** and end +**1.2″ from the bottom** so nothing collides with the chrome. + +--- + +## Picture placeholders + +Set every picture placeholder to **fill** (not fit) so photos crop rather than +letterbox. Apply the corner radius by using a rounded-rectangle *picture +placeholder*, not by cropping to shape after the fact — the latter breaks when +the user swaps the image. + +Two permitted photo treatments: + +1. **Accent wash** — place a rectangle of Accent 1 over the photo, set to + **Multiply** blend at **55%** transparency. Group it with the picture. +2. **Bottom scrim** — a rectangle filled with a linear gradient, `14181F` at 15% + opacity (top) → `14181F` at 72% opacity (bottom), 90°. + +--- + +## Charts + +- Delete: chart border, gridline major/minor on the category axis, tick marks, + chart area fill. +- Value-axis gridlines: `ECEFF3`, 0.75 pt. +- Axis labels: 11 pt, `8993A6`. +- Data labels: 11 pt SemiBold, `14181F`, positioned outside end. +- Bar gap width: **60%**. Bar corner radius: 0.06″ on the outer end only. +- Line charts: 2.5 pt line, no markers unless there are fewer than 8 points; then + circular markers 6 pt in Accent 1 with a white 1.5 pt outline. +- Pie/doughnut: doughnut hole **68%**, first slice Accent 1, remainder greys. + +--- + +## Export and handoff + +- Ship as **`.potx`** (template), not `.pptx`. Users then get a clean + New-from-template flow with all layouts available. +- Include a **`.thmx`** theme file so the colour and font set can be applied to + existing decks. +- Verify in **Slide Master view** that no layout has stray placeholders, and that + every layout's name matches the table above — designers pick layouts by name. +- Test at 100% zoom and as a printed handout (3-per-page) before sign-off. diff --git a/design-system/projects.html b/design-system/projects.html new file mode 100644 index 0000000..62fdcc9 --- /dev/null +++ b/design-system/projects.html @@ -0,0 +1,152 @@ + + + + + +My projects + + + + + + + + +
+ + + +
+ Your work +

My projects

+

Everything you've started. Pick one up again, or take the files.

+
+
+ + +
+ + + +
+ + + + + diff --git a/design-system/projects/001-portfolio-marin/SPEC.md b/design-system/projects/001-portfolio-marin/SPEC.md new file mode 100644 index 0000000..cb40fc1 --- /dev/null +++ b/design-system/projects/001-portfolio-marin/SPEC.md @@ -0,0 +1,79 @@ +# 001 — Portfolio, Marin Ellis + +Produced by `examples/picker.html` on 2026-08-03. + +## Intake answers + +| Question | Answer | +|---|---| +| What are you making? | Portfolio site | +| What should they feel in five seconds? | Trusted, established, safe | +| Brand colours / logo / fonts? | None — system chose | +| The one thing they should do? | Buy or sign up | +| Imagery available? | Illustration | +| Light or dark, where does it live? | Light, on screen | + +## Resolved spec + +| Setting | Value | +|---|---| +| **Deliverable** | Portfolio site | +| **Layer** | web | +| **Accent** | indigo `#4059F0` | +| **Ground** | white | +| **Primary action** | Hero CTA + pricing grid with a featured tier + closing CTA band | +| **Imagery** | Flat vector on tinted card grounds; no photo masks | +| **Mode** | Light, screen | +| **Sections** | Hero, trust bar, selected work, services, process rail, packages, testimonial, FAQ, CTA | + +**Accent note.** "Trusted, established" maps to navy *or* indigo. Indigo was +chosen because the page sells fixed-price packages — navy reads more +formal-consultant, indigo more modern-trustworthy. Swap by changing one class on +``. + +## Conflict resolved at intake + +The portfolio archetype normally opens on a **portrait hero with floating label +pins** (`.pin`, `.media--blob`). The imagery answer was *illustration, no +photography*. + +**Imagery wins.** No portrait, no photo masks anywhere on the page. Inline SVG +illustration on tinted grounds carries the visual load instead. + +Caught before anything was drawn. Left to the build, it would have surfaced as a +revision round. + +## Scope + +**Import (already written, do not modify):** +`tokens/tokens.css`, `css/base.css`, `css/components.css`, `css/web.css` + +**Files written:** `index.html` only. + +**Classes composed from:** +`.hero-sheen` `.badge-pill` `.orb` `.offset-block` `.stat-row` `.trustbar` +`.grid--2` `.card--raised` `.tint-grid` `.tint-card` `.rail` `.rail__num` +`.price-grid` `.price-card--featured` `.checklist` `.quote` `.faq` +`.cta-band--gradient` `.field-group` `.media--xl` + +**Theme:** `` + +**Read only:** `FAVORITES.md` §"Rules specific to this set" + +## Deviations from stock components + +Five lines of scoped CSS in `index.html`, no changes under `css/` or `tokens/`: + +- `.hero .stat-row` pinned to three columns. The hero column is narrower than + `.stat-row`'s auto-fit minimum, so the third figure wrapped. Pinned locally + rather than loosening the component for every other page that uses it. +- `.work-card` / `.illo` — padding and sizing for the illustration cards. + +## Imagery + +Inline SVG, authored for this project. No third-party assets, so no licence or +attribution obligations. + +If photography is added later, it goes through `PHOTOS.md` — and note that +adding it contradicts the intake answer, so re-run question 5 rather than +bolting photos onto an illustration-led layout. diff --git a/design-system/projects/001-portfolio-marin/index.html b/design-system/projects/001-portfolio-marin/index.html new file mode 100644 index 0000000..caa8340 --- /dev/null +++ b/design-system/projects/001-portfolio-marin/index.html @@ -0,0 +1,361 @@ + + + + + +Portfolio — Corporate Deck Design System + + + + + + + + +
+ +
+ + +
+
+
+ +
+ Open Two slots for Q4 +

Design that earns trust, not just attention.

+

+ Twelve years designing interfaces for finance, health and public-sector + teams — where getting it wrong is expensive and being clear matters more + than being clever. +

+ +
+
12Years
+
80+Projects
+
94%Repeat clients
+
+
+ +
+
+ + + + + + + + + + + + + + + +
+
+
+
+
+ + +
+
+ Trusted by teams at +
    +
  • Halden
  • Northbrook
  • Kestrel
  • Aven
  • Pulse
  • +
+
+
+ + +
+
+
+ Selected work +

Recent projects

+
+
+
+
+
+ + + + + + + + +
+
+

Kestrel — treasury dashboard

+

Product design · 2025

+
+
+ +
+
+ + + + + + + + + + + + +
+
+

Aven — onboarding rebuild

+

UX · 2024

+
+
+
+
+
+ + +
+
+
+ What I do +

Four ways I can help

+
+
+
+
+ +

Product design

+

End-to-end interface work, from flows to shipped screens.

+
+
+ +

Design systems

+

Tokens, components and the documentation that keeps them used.

+
+
+ +

Research & audit

+

An honest read on what is costing you conversions, with fixes ranked.

+
+
+ +

Implementation

+

Front-end build so the design ships as drawn, not as interpreted.

+
+
+
+
+ + +
+
+
+ How I work +

Three steps, no surprises

+
+
+
+
+
+

Understand

+

A week reading your data and talking to your users. You get a written diagnosis before anyone opens a design tool.

+
+
01
+
+
+ + + + + + + + +
+
+
+
+
+

Design

+

Flows first, then screens. Reviewed weekly against the diagnosis, so scope creep has somewhere to be caught.

+
+
02
+
+
+ + + + + + + + +
+
+
+
+
+

Ship

+

Built and handed over with the components documented, so your team can keep going without me.

+
+
03
+
+
+ + + + + + + +
+
+
+
+
+
+ + +
+
+
+ Packages +

Fixed scope, fixed price

+

No hourly billing and no change-request theatre. Pick a package, get a start date.

+
+
+
+
+

Audit

+

One product, two weeks

+
£4,500
+
    +
  • Heuristic and data review
  • +
  • Five user interviews
  • +
  • Ranked fix list with effort estimates
  • +
+ Book an audit +
+ + + +
+

Retained

+

Ongoing, monthly

+
£6,000 / month
+
    +
  • Two days a week
  • +
  • Design system ownership
  • +
  • Pause or stop with 30 days' notice
  • +
+ Enquire +
+
+
+
+ + +
+
+
+ Marin gave us the diagnosis before the designs, which nobody else did. + Six weeks later the drop-off we had lived with for two years was gone. +
+ Priya RamanHead of Product, Kestrel +
+
+
+
+ How soon can you start? +

Usually three to four weeks out. Two slots are open for Q4.

+
+
+ Do you work with in-house teams? +

Mostly, yes — the retained package exists for exactly that.

+
+
+ What if the audit says don't build it? +

Then that is the deliverable, and it has saved you the sprint fee.

+
+
+
+
+ + +
+
+
+
+

Two slots left for Q4

+

Tell me what is stuck. You'll get a straight answer within two days.

+
+ Book a call +
+
+
+ +
+
+
+ Marin Ellis +

Product design for teams where clarity matters more than novelty.

+
+ + +
+
+

© 2026 Marin Ellis · hello@marinellis.example

+
+
+ + + diff --git a/design-system/projects/README.md b/design-system/projects/README.md new file mode 100644 index 0000000..1d6d390 --- /dev/null +++ b/design-system/projects/README.md @@ -0,0 +1,61 @@ +# Projects + +One folder per request. `examples/` holds reference demos of the system itself +and does not change; **everything actually commissioned lives here.** + +``` +projects/ +└── NNN-short-name/ + ├── SPEC.md ← the intake answers and the scope they produced + ├── index.html ← the deliverable + └── assets/ ← only the subfolders the project actually has + ├── logo/ ← SVG preferred; keep the light/knockout variant too + ├── photos/ ← with a LICENCES.md line per file (see ../../PHOTOS.md) + ├── content/ ← copy.md — the real words the design was built against + └── reference/ ← current site, old deck, brand guidelines +``` + +Assets are separated by kind because the rules differ by kind. A logo needs +transparency and a knockout variant; a photo needs a licence record; copy needs +to be the real words. A single flat `assets/` folder loses all of that. + +The picker prints this tree for you once you've answered — copy the structure it +shows rather than inventing one. + +## The convention + +| Part | Rule | +|---|---| +| `NNN` | Three digits, sequential, never reused — `001`, `002`, `017` | +| `short-name` | Two or three words, lowercase, hyphenated. Client or subject first | +| `SPEC.md` | **Required.** Paste the picker output here before writing any markup | +| `index.html` | The page or deck. One file unless the project genuinely needs more | +| `assets/` | Only create it if the project has real assets. No empty folders | + +Examples: `001-portfolio-marin`, `002-kestrel-pitch-deck`, `003-aven-landing`. + +## Why SPEC.md is required + +It records *why* the design looks the way it does — which accent, which layer, +which sections, and the constraints that produced them. Six months later that is +the difference between editing a page and re-deriving it from scratch. + +It also carries the sourcing line for any imagery, so licence and attribution +stay attached to the project rather than living in someone's memory. + +## Starting a new project + +1. Run [`../examples/picker.html`](../examples/picker.html) — answer the six questions. +2. `mkdir projects/NNN-short-name`, paste the picker output into `SPEC.md`. +3. Write `index.html` against the scope in that file, and nothing beyond it. + +The scope names the exact files to import and classes to use. **Do not modify +anything under `css/` or `tokens/` for a single project** — if a project truly +needs a new component, that is a change to the system, made deliberately and +separately, not a side effect of one page. + +## Index + +| # | Project | Deliverable | Layer | Accent | Date | +|---|---|---|---|---|---| +| 001 | [portfolio-marin](001-portfolio-marin/) | Portfolio site | web | indigo | 2026-08-03 | diff --git a/design-system/signin.html b/design-system/signin.html new file mode 100644 index 0000000..107f3ed --- /dev/null +++ b/design-system/signin.html @@ -0,0 +1,184 @@ + + + + + +Sign in + + + + + + + + + +
+
+ + Design System + +

Sign in to start

+

Your first two projects are free. No card needed.

+
+ +
+ + + + +
+
+ + +
+ + +
+ + +
+ +

+ We only ever use your address to sign you in and send your files. +

+
+ + + + + diff --git a/design-system/tokens/tokens.css b/design-system/tokens/tokens.css new file mode 100644 index 0000000..b502c3c --- /dev/null +++ b/design-system/tokens/tokens.css @@ -0,0 +1,239 @@ +/* ========================================================================== + Corporate Deck Design System — Design Tokens + -------------------------------------------------------------------------- + Single source of truth for colour, type, space, radius, shadow and motion. + Import this first; every other stylesheet in the system depends on it. + + Re-theming: add a theme class to or any container. + -> whole document re-themes +
-> scoped re-theme + ========================================================================== */ + +:root { + /* --- Accent ramp (default: amber) ------------------------------------ */ + --accent-50: #FFF8E6; + --accent-100: #FFEDBF; + --accent-300: #FFD24D; + --accent-500: #F5B21A; + --accent-600: #9B6B00; + --accent-700: #6D4800; + + /* Text colour that stays legible on top of --accent-500 */ + --accent-on: #14181F; + + /* Text colour for the darker accent fill (--accent-600), used by the primary + button's hover state. Every theme's --accent-600 is chosen to clear 4.5:1 + against white — that is what makes it safe as link and eyebrow text on the + page ground — so white is the one value that works on it in all nine. */ + --accent-on-600: #FFFFFF; + + /* --- Ink (dark anchor) ---------------------------------------------- */ + --ink-900: #14181F; + --ink-800: #1B2333; + --ink-700: #233049; + --ink-600: #3A4761; + --ink-500: #5C6880; + --ink-400: #8993A6; + --ink-300: #B4BCC9; + --ink-200: #D8DDE5; + --ink-100: #ECEFF3; + + /* --- Surfaces -------------------------------------------------------- */ + --surface-page: #FFFFFF; + --surface-muted: #F7F8FA; + --surface-cream: #FAF6EC; + --surface-sunken: #EFF1F5; + --surface-inverse: #14181F; + + /* --- Foreground roles ------------------------------------------------ */ + --fg-strong: var(--ink-900); + --fg-body: var(--ink-600); + --fg-muted: var(--ink-400); + --fg-on-dark: #FFFFFF; + --fg-on-dark-muted: rgba(255, 255, 255, 0.72); + + /* --- Semantic -------------------------------------------------------- */ + --positive: #17A673; + --warning: #F5B21A; + --negative: #E04A3F; + --info: #2B50EE; + + /* --- Chart series ---------------------------------------------------- */ + --chart-1: var(--accent-500); + --chart-2: var(--ink-800); + --chart-3: var(--accent-300); + --chart-4: var(--ink-400); + --chart-5: var(--accent-100); + --chart-6: var(--ink-200); + --chart-grid: var(--ink-100); + --chart-axis: var(--ink-400); + + /* --- Typography ------------------------------------------------------ */ + --font-display: 'Poppins', 'Montserrat', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif; + --font-body: 'Inter', 'Poppins', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; + + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold: 600; + --fw-bold: 700; + --fw-black: 800; + + --fs-display: 4.5rem; + --fs-h1: 3rem; + --fs-h2: 2.25rem; + --fs-h3: 1.5rem; + --fs-h4: 1.125rem; + --fs-body: 1rem; + --fs-small: 0.875rem; + --fs-caption: 0.75rem; + --fs-eyebrow: 0.75rem; + --fs-numeral: 5rem; + + --lh-tight: 1.06; + --lh-snug: 1.2; + --lh-normal: 1.45; + --lh-loose: 1.65; + + --tracking-display: -0.03em; + --tracking-tight: -0.02em; + --tracking-normal: 0; + --tracking-eyebrow: 0.18em; + + /* --- Space (4px grid) ------------------------------------------------ */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-7: 2.5rem; + --space-8: 3rem; + --space-9: 4rem; + --space-10: 5rem; + --space-11: 6rem; + --space-12: 8rem; + + /* --- Radius ---------------------------------------------------------- */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-xl: 32px; + --radius-2xl: 48px; + --radius-pill: 999px; + --radius-blob: 42% 58% 63% 37% / 41% 44% 56% 59%; + + /* --- Shadow ---------------------------------------------------------- */ + --shadow-sm: 0 1px 2px rgba(20, 24, 31, 0.05); + --shadow-md: 0 4px 16px rgba(20, 24, 31, 0.07); + --shadow-lg: 0 12px 32px rgba(20, 24, 31, 0.09); + --shadow-xl: 0 24px 64px rgba(20, 24, 31, 0.12); + --shadow-slide: 0 18px 48px rgba(20, 24, 31, 0.14); + + /* --- Motion ---------------------------------------------------------- */ + --duration-fast: 140ms; + --duration-base: 220ms; + --duration-slow: 380ms; + --ease-standard: cubic-bezier(0.2, 0, 0.2, 1); + --ease-enter: cubic-bezier(0, 0, 0.2, 1); + --ease-exit: cubic-bezier(0.4, 0, 1, 1); + + /* --- Gradient & glow -------------------------------------------------- + Decks stay flat; the web-landing family uses these for hero surfaces and + the soft orbs behind content. Never as a brand colour — see the brief. */ + --gradient-hero: linear-gradient(135deg, var(--accent-600) 0%, var(--accent-500) 55%, var(--accent-300) 100%); + --gradient-sheen: linear-gradient(135deg, var(--accent-50) 0%, var(--surface-page) 60%); + --gradient-text: linear-gradient(90deg, var(--accent-500), var(--accent-300)); + --glow-accent: radial-gradient(circle, color-mix(in srgb, var(--accent-500) 38%, transparent) 0%, transparent 70%); + + /* --- Layout ---------------------------------------------------------- */ + --container-max: 1200px; + --section-py: 6rem; + --slide-safe: 6.5%; +} + +/* ========================================================================== + Accent themes — swap the ramp, everything downstream follows + ========================================================================== */ + +.theme-amber { + --accent-50: #FFF8E6; --accent-100: #FFEDBF; --accent-300: #FFD24D; + --accent-500: #F5B21A; --accent-600: #9B6B00; --accent-700: #6D4800; + --accent-on: #14181F; +} + +.theme-electric { + --accent-50: #EDF1FF; --accent-100: #D6E0FF; --accent-300: #8DA5FF; + --accent-500: #2B50EE; --accent-600: #1D3ACC; --accent-700: #152B99; + --accent-on: #FFFFFF; +} + +.theme-navy { + --accent-50: #EEF2F9; --accent-100: #D5DEEE; --accent-300: #8095C4; + --accent-500: #2B4A8B; --accent-600: #1F3768; --accent-700: #152547; + --accent-on: #FFFFFF; +} + +.theme-teal { + --accent-50: #E6F8FA; --accent-100: #C0EEF3; --accent-300: #63D6E2; + --accent-500: #22BCCE; --accent-600: #14818D; --accent-700: #11707B; + --accent-on: #14181F; +} + +.theme-green { + --accent-50: #E7F7EF; --accent-100: #C2EBD8; --accent-300: #63CFA1; + --accent-500: #17A673; --accent-600: #11855C; --accent-700: #0B6244; + --accent-on: #14181F; +} + +.theme-coral { + --accent-50: #FDEEEA; --accent-100: #FAD5CB; --accent-300: #F29B80; + --accent-500: #E85D33; --accent-600: #C64720; --accent-700: #993518; + --accent-on: #14181F; +} + +/* Added from the favourites set — the web-landing family. Violet is the + signature there; lime is the cream-ground outlier. */ + +.theme-violet { + --accent-50: #F3EEFF; --accent-100: #E2D6FF; --accent-300: #A98BFF; + --accent-500: #7C4DFF; --accent-600: #6534E0; --accent-700: #4B24A8; + --accent-on: #FFFFFF; +} + +.theme-indigo { + --accent-50: #EDF0FF; --accent-100: #D8DFFF; --accent-300: #93A5FF; + --accent-500: #4059F0; --accent-600: #2E42CC; --accent-700: #1F2E96; + --accent-on: #FFFFFF; +} + +.theme-lime { + --accent-50: #F7FBE8; --accent-100: #EAF5C2; --accent-300: #D8EC7E; + --accent-500: #C3DE4A; --accent-600: #6A7C1D; --accent-700: #4C5A11; + --accent-on: #14181F; +} + +/* ========================================================================== + Dark surface context + Any block that sits on ink flips its foreground roles here, so components + need no dark-specific variants of their own. + ========================================================================== */ + +.on-dark { + --fg-strong: #FFFFFF; + --fg-body: rgba(255, 255, 255, 0.78); + --fg-muted: rgba(255, 255, 255, 0.55); + --surface-page: var(--ink-900); + --surface-muted: var(--ink-800); + --chart-grid: rgba(255, 255, 255, 0.12); + --chart-axis: rgba(255, 255, 255, 0.45); + color: var(--fg-body); +} + +@media (prefers-reduced-motion: reduce) { + :root { + --duration-fast: 0ms; + --duration-base: 0ms; + --duration-slow: 0ms; + } +} diff --git a/design-system/tokens/tokens.json b/design-system/tokens/tokens.json new file mode 100644 index 0000000..80c8ecf --- /dev/null +++ b/design-system/tokens/tokens.json @@ -0,0 +1,236 @@ +{ + "$name": "Corporate Deck Design System", + "$version": "1.0.0", + "$description": "Platform-agnostic design tokens distilled from a 32-deck reference set of corporate presentation and web templates. Consume from CSS, PowerPoint theme XML, Figma variables, or any design tool.", + + "color": { + "$comment": "The system is monochrome-accent: ONE accent hue carries the whole deck. Swap the `accent` ramp to re-theme; never mix two accent families in one document.", + + "accent": { + "$default": "amber", + "amber": { + "50": "#FFF8E6", + "100": "#FFEDBF", + "300": "#FFD24D", + "500": "#F5B21A", + "600": "#E09A00", + "700": "#B87A00", + "$signature": "500" + }, + "electric": { + "50": "#EDF1FF", + "100": "#D6E0FF", + "300": "#8DA5FF", + "500": "#2B50EE", + "600": "#1D3ACC", + "700": "#152B99", + "$signature": "500" + }, + "navy": { + "50": "#EEF2F9", + "100": "#D5DEEE", + "300": "#8095C4", + "500": "#2B4A8B", + "600": "#1F3768", + "700": "#152547", + "$signature": "500" + }, + "teal": { + "50": "#E6F8FA", + "100": "#C0EEF3", + "300": "#63D6E2", + "500": "#22BCCE", + "600": "#1897A6", + "700": "#11707B", + "$signature": "500" + }, + "green": { + "50": "#E7F7EF", + "100": "#C2EBD8", + "300": "#63CFA1", + "500": "#17A673", + "600": "#11855C", + "700": "#0B6244", + "$signature": "500" + }, + "coral": { + "50": "#FDEEEA", + "100": "#FAD5CB", + "300": "#F29B80", + "500": "#E85D33", + "600": "#C64720", + "700": "#993518", + "$signature": "500" + }, + "violet": { + "$comment": "Signature of the favourites set — the web-landing family.", + "50": "#F3EEFF", + "100": "#E2D6FF", + "300": "#A98BFF", + "500": "#7C4DFF", + "600": "#6534E0", + "700": "#4B24A8", + "$signature": "500" + }, + "indigo": { + "50": "#EDF0FF", + "100": "#D8DFFF", + "300": "#93A5FF", + "500": "#4059F0", + "600": "#2E42CC", + "700": "#1F2E96", + "$signature": "500" + }, + "lime": { + "$comment": "Pairs with the cream ground, not white. Dark text on top.", + "50": "#F7FBE8", + "100": "#EAF5C2", + "300": "#D8EC7E", + "500": "#C3DE4A", + "600": "#A3BE2C", + "700": "#758A1A", + "$signature": "500" + } + }, + + "ink": { + "$comment": "The dark anchor. Used for headlines, dark-fill slides, and the deck's 'gravity'.", + "900": "#14181F", + "800": "#1B2333", + "700": "#233049", + "600": "#3A4761", + "500": "#5C6880", + "400": "#8993A6", + "300": "#B4BCC9", + "200": "#D8DDE5", + "100": "#ECEFF3" + }, + + "surface": { + "page": "#FFFFFF", + "muted": "#F7F8FA", + "cream": "#FAF6EC", + "sunken": "#EFF1F5", + "inverse": "#14181F" + }, + + "semantic": { + "positive": "#17A673", + "warning": "#F5B21A", + "negative": "#E04A3F", + "info": "#2B50EE" + }, + + "chart": { + "$comment": "Ordered series palette. Series 1 is always the accent; everything after it recedes into neutrals so a single bar/line reads as 'the point'.", + "series": [ + "var(--accent-500)", + "var(--ink-800)", + "var(--accent-300)", + "var(--ink-400)", + "var(--accent-100)", + "var(--ink-200)" + ], + "grid": "#ECEFF3", + "axis": "#8993A6" + } + }, + + "typography": { + "$comment": "One geometric-grotesque family, three weights. Headlines are heavy and tight; body is regular and roomy.", + "family": { + "display": "'Poppins', 'Montserrat', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif", + "body": "'Inter', 'Poppins', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif", + "mono": "'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace" + }, + "weight": { + "regular": 400, + "medium": 500, + "semibold": 600, + "bold": 700, + "black": 800 + }, + "scale": { + "$comment": "Web values in rem. See powerpoint/SPEC.md for the pt equivalents on a 13.333in x 7.5in slide.", + "display": { "size": "4.5rem", "lineHeight": 1.02, "weight": 800, "tracking": "-0.03em" }, + "h1": { "size": "3rem", "lineHeight": 1.06, "weight": 800, "tracking": "-0.025em" }, + "h2": { "size": "2.25rem", "lineHeight": 1.12, "weight": 700, "tracking": "-0.02em" }, + "h3": { "size": "1.5rem", "lineHeight": 1.2, "weight": 700, "tracking": "-0.01em" }, + "h4": { "size": "1.125rem","lineHeight": 1.3, "weight": 600, "tracking": "0" }, + "body": { "size": "1rem", "lineHeight": 1.65, "weight": 400, "tracking": "0" }, + "small": { "size": "0.875rem","lineHeight": 1.6, "weight": 400, "tracking": "0" }, + "caption": { "size": "0.75rem", "lineHeight": 1.5, "weight": 500, "tracking": "0.01em" }, + "eyebrow": { "size": "0.75rem", "lineHeight": 1.2, "weight": 600, "tracking": "0.18em", "transform": "uppercase" }, + "numeral": { "size": "5rem", "lineHeight": 0.9, "weight": 800, "tracking": "-0.04em" } + } + }, + + "space": { + "$comment": "4px base grid. Slide gutters and card padding always land on this scale.", + "0": "0", + "1": "0.25rem", + "2": "0.5rem", + "3": "0.75rem", + "4": "1rem", + "5": "1.5rem", + "6": "2rem", + "7": "2.5rem", + "8": "3rem", + "9": "4rem", + "10": "5rem", + "11": "6rem", + "12": "8rem" + }, + + "radius": { + "$comment": "Generous rounding is a signature of this system. Sharp corners are the exception, used only for full-bleed color blocks.", + "none": "0", + "sm": "6px", + "md": "12px", + "lg": "20px", + "xl": "32px", + "2xl": "48px", + "pill": "999px", + "blob": "42% 58% 63% 37% / 41% 44% 56% 59%" + }, + + "shadow": { + "$comment": "Soft, low-contrast, never harsh. Cards float a few millimetres, not centimetres.", + "sm": "0 1px 2px rgba(20, 24, 31, 0.05)", + "md": "0 4px 16px rgba(20, 24, 31, 0.07)", + "lg": "0 12px 32px rgba(20, 24, 31, 0.09)", + "xl": "0 24px 64px rgba(20, 24, 31, 0.12)", + "slide": "0 18px 48px rgba(20, 24, 31, 0.14)" + }, + + "border": { + "hairline": "1px solid var(--ink-200)", + "card": "1px solid var(--ink-100)", + "accent": "2px solid var(--accent-500)", + "rule": "4px solid var(--accent-500)" + }, + + "motion": { + "$comment": "Presentation-grade: quick, confident, never bouncy.", + "duration": { "fast": "140ms", "base": "220ms", "slow": "380ms" }, + "easing": { + "standard": "cubic-bezier(0.2, 0, 0.2, 1)", + "enter": "cubic-bezier(0, 0, 0.2, 1)", + "exit": "cubic-bezier(0.4, 0, 1, 1)" + } + }, + + "layout": { + "slide": { + "aspect": "16 / 9", + "safeMarginPct": 6.5, + "columns": 12, + "gutterPct": 2 + }, + "web": { + "containerMax": "1200px", + "sectionPaddingY": "6rem", + "breakpoints": { "sm": "640px", "md": "768px", "lg": "1024px", "xl": "1280px" } + } + } +} diff --git a/design-system/tools/build-shareable.js b/design-system/tools/build-shareable.js new file mode 100644 index 0000000..dde4bd8 --- /dev/null +++ b/design-system/tools/build-shareable.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * Builds the version of the brief that goes out as a shared link. + * + * It is the standalone file with two changes, both forced by where it runs: + * a shared page is embedded, so nothing may depend on a pop-up or on a + * download the host might block; and the people opening it are testers, so it + * has to say what it is and give them a way to send an opinion back. + * + * node design-system/tools/build-shareable.js + * + * Output is a fragment, not a document — whatever hosts it supplies the + * and . + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(HERE, '..'); +const SRC = path.join(ROOT, 'brief-standalone.html'); +const OUT = path.join(ROOT, 'brief-shareable.html'); + +const html = fs.readFileSync(SRC, 'utf8'); + +/* The shared build is opened by testers, not customers, so it gets its own + * title rather than inheriting "Tell us about your project". */ +const title = 'Try it — build your page in four questions'; +const style = (html.match(/ +${body} +${script} +`; + +const external = out.match(/(?:src|href)="https?:\/\/[^"]+"/g) || []; +if (external.length) { + console.error('External references found — a shared build must be self-contained:'); + external.forEach(e => console.error(' ' + e)); + process.exit(1); +} +/* Only the *outer* document matters. The generator's own page template + * legitimately contains the strings and as text, so a + * whole-file search reports a problem that isn't there — check the ends. */ +const head = out.trimStart().slice(0, 200).toLowerCase(); +if (head.startsWith('\s*$/i.test(out)) { + console.error('Output still closes a document — it must be a fragment.'); + process.exit(1); +} + +fs.writeFileSync(OUT, out); +console.log(`Wrote ${path.relative(process.cwd(), OUT)} — ${(out.length / 1024).toFixed(0)}KB, self-contained fragment.`); diff --git a/design-system/tools/build-standalone.js b/design-system/tools/build-standalone.js new file mode 100644 index 0000000..ea0dbab --- /dev/null +++ b/design-system/tools/build-standalone.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * Builds examples/intake-standalone.html — the whole intake as one file. + * + * Inlines every linked stylesheet and adds a token-level dark layer, so the + * page can be emailed, opened from a USB stick, or embedded somewhere that + * enforces a strict CSP. No dependencies; run it with plain node. + * + * node design-system/tools/build-standalone.js + * + * Re-run after changing anything in css/ or tokens/. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The repo is ESM ("type": "module"), so no __dirname — derive it. +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(HERE, '..'); +// Two pages get a one-file build: the technical intake, and the customer brief. +const TARGETS = [ + { src: path.join(ROOT, 'examples', 'picker.html'), + out: path.join(ROOT, 'examples', 'intake-standalone.html'), + theme: 'theme-violet' }, + { src: path.join(ROOT, 'brief.html'), + out: path.join(ROOT, 'brief-standalone.html'), + theme: 'theme-violet' }, +]; + +const SHEETS = [ + 'tokens/tokens.css', + 'css/base.css', + 'css/components.css', + 'css/web.css', +]; + +/* The system is light-first by design — decks stay on a light ground — so the + * dark layer lives here in the build rather than in tokens.css. Because every + * component reads from tokens, redefining surfaces, foregrounds and the ink + * steps that act as borders flips the whole page; nothing needs a dark variant. */ +const DARK = ` + --surface-page: #12161D; + --surface-muted: #191F2A; + --surface-sunken: #0D1116; + --surface-cream: #191F2A; + --surface-inverse: #FFFFFF; + --fg-strong: #F4F6F9; + --fg-body: rgba(244,246,249,0.76); + --fg-muted: rgba(244,246,249,0.52); + --ink-100: rgba(244,246,249,0.10); + --ink-200: rgba(244,246,249,0.17); + --ink-300: rgba(244,246,249,0.30); + --ink-400: rgba(244,246,249,0.48); + --ink-800: #191F2A; + --ink-900: #0D1116; + --chart-grid: rgba(244,246,249,0.12); + --chart-axis: rgba(244,246,249,0.45); + --shadow-sm: 0 1px 2px rgba(0,0,0,0.4); + --shadow-md: 0 4px 16px rgba(0,0,0,0.45); + --shadow-lg: 0 12px 32px rgba(0,0,0,0.5);`; + +const LIGHT = ` + --surface-page: #FFFFFF; --surface-muted: #F7F8FA; --surface-sunken: #EFF1F5; + --surface-cream: #FAF6EC; --surface-inverse: #14181F; + --fg-strong: #14181F; --fg-body: #3A4761; --fg-muted: #8993A6; + --ink-100:#ECEFF3; --ink-200:#D8DDE5; --ink-300:#B4BCC9; --ink-400:#8993A6; + --ink-800:#1B2333; --ink-900:#14181F; + --chart-grid:#ECEFF3; --chart-axis:#8993A6;`; + +const themeLayer = ` +/* ===== theme layer (standalone build only) ===== */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) {${DARK} + } +} +:root[data-theme="dark"] {${DARK} +} +:root[data-theme="light"] {${LIGHT} +} +input[type="color"], input[type="text"], textarea { + color: var(--fg-strong); background: var(--surface-page); +} +`; + +const css = SHEETS + .map(f => `/* ===== ${f} ===== */\n${fs.readFileSync(path.join(ROOT, f), 'utf8')}`) + .join('\n\n'); + +for (const T of TARGETS) build(T); + +function build({ src, out: OUT, theme }) { +const html = fs.readFileSync(src, 'utf8'); + +const ownStyle = (html.match(/ + + +${body} + + +`; + +fs.writeFileSync(OUT, out); + +// Guard the one property that makes this file worth having. +if (/^\s*import\s.+from\s/m.test(out) || /^export\s/m.test(out)) { + console.error('Module syntax survived bundling — the standalone build would not run:', OUT); + process.exit(1); +} + +const external = out.match(/(?:src|href)="https?:\/\/[^"]+"/g) || []; +if (external.length) { + console.error('External asset references found — the build is not self-contained:'); + external.forEach(e => console.error(' ' + e)); + process.exit(1); +} + +console.log(`Wrote ${path.relative(process.cwd(), OUT)} — ${(out.length / 1024).toFixed(0)}KB, self-contained.`); +} diff --git a/design-system/tools/check-contrast.js b/design-system/tools/check-contrast.js new file mode 100644 index 0000000..5fc211b --- /dev/null +++ b/design-system/tools/check-contrast.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/* Every accent pairing the CSS actually uses as text must clear WCAG AA (4.5:1). + Runs off the token file alone — no browser — so it is cheap enough to gate a + build on. See DESIGN-BRIEF.md §2 for which token pairs with which surface. */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const TOKENS = join(dirname(fileURLToPath(import.meta.url)), '..', 'tokens', 'tokens.css'); +const PAPER = '#FFFFFF'; + +const channel = v => (v /= 255) <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); +const luminance = hex => { + const h = hex.replace('#', ''); + const [r, g, b] = [0, 2, 4].map(i => channel(parseInt(h.slice(i, i + 2), 16))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const ratio = (a, b) => { + const [x, y] = [luminance(a), luminance(b)]; + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +}; + +const css = readFileSync(TOKENS, 'utf8'); +const themes = [...css.matchAll(/(?:\.theme-([a-z]+)|(:root))\s*\{([^}]*)\}/g)] + .map(([, name, root, body]) => ({ name: name || (root && 'root'), body })) + .filter(t => t.name && /--accent-500\s*:/.test(t.body)); + +const globalOn600 = (css.match(/--accent-on-600:\s*(#[0-9A-Fa-f]{6})/) || [])[1]; + +let failures = 0; +for (const { name, body } of themes) { + const token = k => (body.match(new RegExp(`--accent-${k}:\\s*(#[0-9A-Fa-f]{6})`)) || [])[1]; + const c500 = token(500), c600 = token(600); + const on = token('on'), on600 = token('on-600') || globalOn600; + + // [label, foreground, background] — every pair the stylesheets set as text. + const pairs = [ + ['--accent-on on --accent-500 (button label)', on, c500], + ['--accent-on-600 on --accent-600 (button hover)', on600, c600], + ['--accent-600 on white (links, eyebrows, .text-accent)', c600, PAPER], + ]; + + for (const [label, fg, bg] of pairs) { + if (!fg || !bg) continue; + const r = ratio(fg, bg); + if (r < 4.5) { + failures++; + console.error(`FAIL ${name.padEnd(9)} ${r.toFixed(2)}:1 ${label} (${fg} on ${bg})`); + } + } +} + +if (failures) { + console.error(`\n${failures} accent pairing(s) below 4.5:1. See DESIGN-BRIEF.md §2.`); + process.exit(1); +} +console.log(`All accent text pairings clear 4.5:1 across ${themes.length} themes.`); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..5c1c71b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,37 @@ +# Tests + +Both suites drive a real browser against a really running server. Every defect +worth having found in this project was found this way rather than by reading +the code — an unreadable colour, a squashed layout, a download that silently +built nothing. + +| File | Covers | Needs | +|---|---|---| +| `journey.js` | The offline path: answer, preview, approve, build the zip in the browser | a static server | +| `online.js` | The account path: register, save, download from the API, sign out | the Worker | +| `sandbox.js` | The shared link, in a sandboxed frame with no pop-ups and no clipboard | nothing | + +## Running them + +```bash +# offline path +cd design-system && python3 -m http.server 8899 & +DS_BASE=http://127.0.0.1:8899/ node tests/journey.js + +# account path +cd app && npm run dev & +node tests/online.js +``` + +`journey.js` also runs against the Worker origin, but the download assertions +will not pass there: served by the Worker, the page requires an account and +fetches its files from the API. That is the point of the split. + +`sandbox.js` is the one that matters for anything shared or embedded. It runs +the page inside `sandbox="allow-scripts allow-forms"`, which is why the preview +renders inline instead of opening a tab and why the feedback box puts its text +on screen as well as on the clipboard — a pop-up and the Clipboard API are both +refused there, and a feature that only works outside a frame is not shareable. + +All suites need Playwright (`npm i playwright`) and resolve Chromium from +`PLAYWRIGHT_BROWSERS_PATH`. diff --git a/tests/journey.js b/tests/journey.js new file mode 100644 index 0000000..a8c2aa7 --- /dev/null +++ b/tests/journey.js @@ -0,0 +1,268 @@ +// End-to-end client journey through brief.html: the path a paying customer walks. +const { chromium } = require('playwright'); +const fs = require('fs'); +const { execSync } = require('child_process'); +const DIR = __dirname; +const DS = process.env.DS_BASE || 'file:///home/user/first-pr-practice/design-system/'; + +let pass = 0, fail = 0; +const ok = (n, c, d = '') => { c ? (pass++, console.log(` PASS ${n}`)) : (fail++, console.log(` FAIL ${n}${d ? ' → ' + d : ''}`)); }; +const head = t => console.log(`\n=== ${t} ===`); + +(async () => { + const b = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium-1194/chrome-linux/chrome' }); + const ctx = await b.newContext({ viewport: { width: 1280, height: 1000 }, acceptDownloads: true }); + const p = await ctx.newPage(); + const errs = []; + p.on('pageerror', e => errs.push(e.message)); + p.on('console', m => { + if (m.type() !== 'error') return; + // favicon.ico is missing from the throwaway static server, and headless + // Chromium logs the mailto: hand-off as an error. Neither is the page's doing. + const t = m.text(), u = (m.location() || {}).url || ''; + // /api/me 404s here on purpose: it is how the page discovers there is no + // account layer behind it and falls back to building files locally. + if (!/favicon\.ico|\/api\/me/.test(u) && !/Launched external handler/.test(t)) errs.push(`${t} (${u})`); + }); + p.on('response', r => { if (r.status() >= 400) console.log(' HTTP', r.status(), r.url()); }); + + // Fixtures: a logo and a Word file, as a real client would arrive with. + fs.writeFileSync(DIR + '/j-logo.svg', + `Halevy`); + + await p.goto(DS + 'brief.html'); + await p.waitForTimeout(400); + + head('1. FRONT DOOR — nothing technical is shown'); + { + const txt = await p.evaluate(() => document.body.innerText); + ok('no code shown to the client', !/