diff --git a/.env.example b/.env.example index 87d5aef..59fa437 100644 --- a/.env.example +++ b/.env.example @@ -1,46 +1,24 @@ -# Web3Forms access keys. +# Web3Forms access key. # -# These values are PUBLISHABLE, not secret. They carry the NEXT_PUBLIC_ prefix, -# which means Next inlines them into the client bundle at build time, and anyone -# viewing source on the deployed site can read them. Keeping them out of the -# source tree is hygiene, not confidentiality: it stops the repository shipping a -# ready-made endpoint, and it keeps one environment's configuration out of -# another's. It does not make the values private, and nothing here should be -# described as though it does. +# Generate at https://web3forms.com. The key belongs to one Web3Forms form, and +# that form's recipient mailbox is set in its dashboard. # -# What that means in practice: +# Set this in Vercel's Environment Variables (Production + Preview) and in a +# local .env.local for development. Never commit a real key. # -# - The realistic risk is spam and quota consumption, not disclosure. -# - Web3Forms offers a domain restriction under Settings > Security Settings. -# It is enforced on a request header that any client can set, so it raises -# the effort required and does not prevent abuse. Set it; do not rely on it. -# - There is NO way to rotate a key. The access key is the form identifier. -# Deleting the form is the only way to invalidate one, and that almost -# certainly destroys the form's submission history, so export first. -# - Values are inlined at BUILD time. Changing one in the hosting dashboard -# does nothing to a deployment that already exists. Redeploy, or the old -# value keeps being served. -# - Paste carefully. A stray leading tab or trailing newline travels into the -# bundle with the key, and the service then rejects it as a malformed UUID. -# -# Each key belongs to one Web3Forms form, and a form's recipient mailbox is set -# in its dashboard, so routing enquiries to different mailboxes means one form -# per mailbox. +# NOTE: NEXT_PUBLIC_ values are inlined at BUILD time. Adding or changing one +# after a deployment requires a redeploy before it takes effect. Paste the value +# carefully — a stray leading tab or trailing newline makes Web3Forms reject +# the key as a malformed UUID. -# The fallback for every route below. Note that once all seven per-mailbox keys -# are set, nothing reads this one, so a fault in it is invisible to testing. +# Required. Used by every form on the site: /contact, /enquiries, and the +# testimonial form on /testimonials. +# Recipient: contact@invisionsolutions.co.uk NEXT_PUBLIC_WEB3FORMS_KEY= -# Per-mailbox keys used by /enquiries. Any left unset fall back to the key above. -NEXT_PUBLIC_WEB3FORMS_KEY_SALES= -NEXT_PUBLIC_WEB3FORMS_KEY_CONTACT= -NEXT_PUBLIC_WEB3FORMS_KEY_SUPPORT= -NEXT_PUBLIC_WEB3FORMS_KEY_BILLING= -NEXT_PUBLIC_WEB3FORMS_KEY_INVOICE= -NEXT_PUBLIC_WEB3FORMS_KEY_ADMIN= - -# Used by /enquiries and, read directly, by the testimonial form on /testimonials. -NEXT_PUBLIC_WEB3FORMS_KEY_HELLO= - -# /contact reads NEXT_PUBLIC_WEB3FORMS_KEY_CONTACT, the same key as the General -# enquiry desk, so the form it submits to is exercised on every /enquiries test. +# There were previously seven per-mailbox keys (NEXT_PUBLIC_WEB3FORMS_KEY_SALES, +# _CONTACT, _SUPPORT, _BILLING, _INVOICE, _ADMIN, _HELLO), one per Web3Forms +# form. Those forms have been deleted from the account and nothing in the code +# reads those variables any more. Delete them from Vercel too: a variable +# naming a form that no longer exists is the failure that took /contact down in +# August, and leaving them set invites someone to wire one back up. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d47f89..a6becde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,31 +60,6 @@ jobs: - name: Build run: npm run build - # The brand assets in public/brand/ are generated, committed, and served - # to the public from /brand. Nothing verified that the generator still - # produced them until this step existed. - # - # That gap is not hypothetical. `npm run brand` was broken on main and - # stayed broken through two merges, because no job ran it: the generator - # located its webfont by CSS *filename*, and Next emits one hash-named - # stylesheet carrying every family. A manual run days later is what - # surfaced it. - # - # `git diff --exit-code` is the assertion. Regenerating is not enough on - # its own: the generator can run to completion and still emit different - # bytes, which is exactly the risk when the imaging library underneath it - # moves a major version. - - name: Brand assets reproduce byte-identically - run: | - npm run brand - git diff --exit-code public/brand/ || { - echo "::error::Regenerating produced different bytes from the committed brand assets." - echo "Either the generator changed behaviour, or a dependency under it did." - echo "If the change is intended, commit the regenerated files in the same PR." - git diff --stat public/brand/ - exit 1 - } - secrets: name: Secret scan runs-on: ubuntu-latest diff --git a/README.md b/README.md index 32dcb5a..1e9c247 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,10 @@ Open [http://localhost:3000](http://localhost:3000). | `npm run start` | Serve the production build | | `npm run lint` | ESLint | | `npm run check:install-scripts` | Fail if a dependency ships an install script not on the allowlist | -| `npm run brand` | Regenerate the downloadable brand assets (run `build` first) | ## Environment variables -Copy `.env.example` to `.env.local` and fill in the Web3Forms access keys: +Copy `.env.example` to `.env.local` and fill in the Web3Forms access key: ```bash cp .env.example .env.local @@ -34,6 +33,10 @@ Web3Forms access keys are **publishable, not secret**. They are prefixed `NEXT_P Deployment and mailbox configuration is documented privately. +There is one Web3Forms form and one key. `/contact`, `/enquiries` and the testimonial form on `/testimonials` all submit with `NEXT_PUBLIC_WEB3FORMS_KEY`, which delivers to `contact@invisionsolutions.co.uk`. Each form sets its own subject line, so mail rules can still tell submissions apart. + +This was previously seven forms and eight keys, one per department mailbox. Those forms have been deleted from the Web3Forms account, and nothing reads the old `NEXT_PUBLIC_WEB3FORMS_KEY_*` variables any more. Delete them from the hosting dashboard too: a variable naming a form that no longer exists is the fault that stopped `/contact` delivering in August, and leaving them set invites someone to wire one back up. + ### When a submission fails Three unrelated causes used to produce one identical message, which made a failure report carry no information. The form now names the cause on screen, so a screenshot is enough to tell them apart without asking anyone to open a browser console: @@ -51,22 +54,15 @@ The mark is a cloud drawn as one continuous line: the stroke traces the silhouet There is **one version and no variants**. No dark colourway, no inverted form, and no heavier stand-in at small sizes. The tile, favicon and app icon all carry the same artwork on a white field. On anything other than white or near-white, use the white tile rather than recolouring the mark. -Geometry and colour roles live in `src/lib/brand.ts` and are mirrored in `scripts/generate-brand-assets.mjs`. Change both together. +Geometry and colour roles live in `src/lib/brand.ts`, which is the single source for the mark. `src/components/logo.tsx`, `src/app/icon.tsx` and `src/app/opengraph-image.tsx` all draw from it. The mark and the wordmark are trademarked material. See `LICENSE.md`. -### Regenerating the downloadable files - -```bash -npm run build # must run first; the generator reads Next's webfont files -npm run brand -``` - -This writes 15 files to `public/brand/`, surfaced on `/brand`. Wordmark type is converted to outlines so the SVGs render correctly without Space Grotesk installed. +There is no public brand-assets page and no downloadable logo files. There was a `/brand` route serving 15 generated files from `public/brand/`, produced by `scripts/generate-brand-assets.mjs`; the page, the files and the generator have all been removed. Send anyone who needs artwork a file directly. ### Two things worth knowing before editing the lockup -**The descriptor is width-matched.** "SOLUTIONS" is tracked so it spans exactly the width of "INVISION" above it. Size is the lever, not tracking: at 25% of the wordmark it would need 1.28em of tracking to reach that width, which scatters the letters, so it sits at 45% and needs 0.4615em. The generator re-solves the value from real font metrics rather than reading the constant, so the two cannot drift apart. Both figures are recorded in `WORDMARK` in `src/lib/brand.ts`. +**The descriptor is width-matched.** "SOLUTIONS" is tracked so it spans exactly the width of "INVISION" above it. Size is the lever, not tracking: at 25% of the wordmark it would need 1.28em of tracking to reach that width, which scatters the letters, so it sits at 45% and needs 0.4615em. Both figures are recorded in `WORDMARK` in `src/lib/brand.ts`. They were previously re-solved from real font metrics by the generator, which no longer exists, so the constants are now the only record — check them against rendered output if the wordmark is ever retyped. **`src/app/_fonts/` exists for the Open Graph image.** `next/font` ships Space Grotesk as a single variable file covering all three weights, and Satori does not apply variable axes, so `ImageResponse` silently falls back to a generic sans. Those three static font instances are loaded explicitly in `src/app/opengraph-image.tsx` to stop that happening. Do not delete them. @@ -82,14 +78,14 @@ HSTS is sent with `max-age` and `includeSubDomains` but deliberately **without ` Routes, one folder per page under `src/app/`: -`/` · `/services` · `/approach` · `/case-studies` · `/testimonials` · `/enquiries` · `/contact` · `/brand` · `/payreckon` +`/` · `/services` · `/approach` · `/case-studies` · `/testimonials` · `/enquiries` · `/contact` · `/payreckon` - `src/app/icon.tsx`, `src/app/opengraph-image.tsx`: favicon and social card, generated at build time - `src/app/globals.css`: design tokens (colour, font, focus states) as CSS custom properties, mapped into Tailwind's `@theme` - `src/components/*`: header, footer, logo, surface primitives, verification-ledger motif, scroll-reveal wrapper, social icons, Web3Forms form - `src/lib/brand.ts`: mark geometry, colour roles, wordmark metrics - `src/lib/content.ts`: services and case study copy, shared between the Home teasers and the full pages -- `src/lib/mailboxes.ts`: the department mailboxes behind `/enquiries` +- `src/lib/mailboxes.ts`: the single Web3Forms access key and the addresses the site publishes - `src/lib/metadata.ts`: canonical URL and per-page metadata helper - `src/lib/payreckon.ts`: PayReckon product copy, with sourcing rules in the file header diff --git a/package-lock.json b/package-lock.json index c8a1993..8aeee21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,8 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.3.3", - "fontkit": "^2.0.4", - "sharp": "^0.35.4", "tailwindcss": "^4", - "typescript": "^5", - "wawoff2": "^2.0.1" + "typescript": "^5" } }, "node_modules/@alloc/quick-lru": { @@ -297,7 +294,6 @@ "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": { @@ -529,8 +525,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "devOptional": true, "license": "MIT", + "optional": true, "engines": { "node": ">=18" } @@ -542,7 +538,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -565,7 +560,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -585,7 +579,6 @@ "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -605,7 +598,6 @@ "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -625,7 +617,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -642,7 +633,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -659,7 +649,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -676,7 +665,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -693,7 +681,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -710,7 +697,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -727,7 +713,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -744,7 +729,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -761,7 +745,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -778,7 +761,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -795,7 +777,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -818,7 +799,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -841,7 +821,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -864,7 +843,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -887,7 +865,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -910,7 +887,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -933,7 +909,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -956,7 +931,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -979,7 +953,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -996,7 +969,6 @@ "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1016,7 +988,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1036,7 +1007,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1056,7 +1026,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2693,27 +2662,6 @@ "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.11.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.11.tgz", @@ -2750,16 +2698,6 @@ "node": ">=8" } }, - "node_modules/brotli": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", - "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "base64-js": "^1.1.2" - } - }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -2897,16 +2835,6 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3095,13 +3023,6 @@ "node": ">=8" } }, - "node_modules/dfa": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", - "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3911,24 +3832,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fontkit": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", - "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@swc/helpers": "^0.5.12", - "brotli": "^1.3.2", - "clone": "^2.1.2", - "dfa": "^1.2.0", - "fast-deep-equal": "^3.1.3", - "restructure": "^3.0.0", - "tiny-inflate": "^1.0.3", - "unicode-properties": "^1.4.0", - "unicode-trie": "^2.0.0" - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -5716,13 +5619,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", - "dev": true, - "license": "MIT" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5990,13 +5886,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/restructure": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", - "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", - "dev": true, - "license": "MIT" - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6156,8 +6045,8 @@ "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", - "devOptional": true, "license": "Apache-2.0", + "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -6206,8 +6095,8 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "devOptional": true, "license": "ISC", + "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -6551,13 +6440,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tiny-inflate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", - "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6819,28 +6701,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicode-properties": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", - "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.0", - "unicode-trie": "^2.0.0" - } - }, - "node_modules/unicode-trie": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", - "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "pako": "^0.2.5", - "tiny-inflate": "^1.0.0" - } - }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -6920,20 +6780,6 @@ "punycode": "^2.1.0" } }, - "node_modules/wawoff2": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz", - "integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "woff2_compress.js": "bin/woff2_compress.js", - "woff2_decompress.js": "bin/woff2_decompress.js" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 393db94..2ca4cc5 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "check:install-scripts": "node scripts/check-install-scripts.mjs", - "brand": "node scripts/generate-brand-assets.mjs" + "check:install-scripts": "node scripts/check-install-scripts.mjs" }, "dependencies": { "lucide-react": "^1.28.0", @@ -24,10 +23,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.3.3", - "fontkit": "^2.0.4", - "sharp": "^0.35.4", "tailwindcss": "^4", - "typescript": "^5", - "wawoff2": "^2.0.1" + "typescript": "^5" } } diff --git a/public/brand/invision-favicon.svg b/public/brand/invision-favicon.svg deleted file mode 100644 index 08d2855..0000000 --- a/public/brand/invision-favicon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/public/brand/invision-icon-1024.png b/public/brand/invision-icon-1024.png deleted file mode 100644 index b1409a0..0000000 Binary files a/public/brand/invision-icon-1024.png and /dev/null differ diff --git a/public/brand/invision-icon-2048.png b/public/brand/invision-icon-2048.png deleted file mode 100644 index 177faff..0000000 Binary files a/public/brand/invision-icon-2048.png and /dev/null differ diff --git a/public/brand/invision-icon-256.png b/public/brand/invision-icon-256.png deleted file mode 100644 index 38a7c1f..0000000 Binary files a/public/brand/invision-icon-256.png and /dev/null differ diff --git a/public/brand/invision-icon-512.png b/public/brand/invision-icon-512.png deleted file mode 100644 index ba91ba0..0000000 Binary files a/public/brand/invision-icon-512.png and /dev/null differ diff --git a/public/brand/invision-icon-square-1024.png b/public/brand/invision-icon-square-1024.png deleted file mode 100644 index 606935f..0000000 Binary files a/public/brand/invision-icon-square-1024.png and /dev/null differ diff --git a/public/brand/invision-icon-square-256.png b/public/brand/invision-icon-square-256.png deleted file mode 100644 index d513946..0000000 Binary files a/public/brand/invision-icon-square-256.png and /dev/null differ diff --git a/public/brand/invision-icon-square-512.png b/public/brand/invision-icon-square-512.png deleted file mode 100644 index f40005d..0000000 Binary files a/public/brand/invision-icon-square-512.png and /dev/null differ diff --git a/public/brand/invision-icon-square.svg b/public/brand/invision-icon-square.svg deleted file mode 100644 index 08d2855..0000000 --- a/public/brand/invision-icon-square.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/public/brand/invision-icon.svg b/public/brand/invision-icon.svg deleted file mode 100644 index 4fbafc4..0000000 --- a/public/brand/invision-icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/public/brand/invision-logo-2048.png b/public/brand/invision-logo-2048.png deleted file mode 100644 index 5e25c94..0000000 Binary files a/public/brand/invision-logo-2048.png and /dev/null differ diff --git a/public/brand/invision-logo-4096.png b/public/brand/invision-logo-4096.png deleted file mode 100644 index f5e4708..0000000 Binary files a/public/brand/invision-logo-4096.png and /dev/null differ diff --git a/public/brand/invision-logo.svg b/public/brand/invision-logo.svg deleted file mode 100644 index 7e28af9..0000000 --- a/public/brand/invision-logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/public/brand/invision-wordmark-2048.png b/public/brand/invision-wordmark-2048.png deleted file mode 100644 index 4a336d4..0000000 Binary files a/public/brand/invision-wordmark-2048.png and /dev/null differ diff --git a/public/brand/invision-wordmark.svg b/public/brand/invision-wordmark.svg deleted file mode 100644 index 9db5fbf..0000000 --- a/public/brand/invision-wordmark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/scripts/check-install-scripts.mjs b/scripts/check-install-scripts.mjs index 55f20c9..2fd6ac6 100644 --- a/scripts/check-install-scripts.mjs +++ b/scripts/check-install-scripts.mjs @@ -31,19 +31,17 @@ const HOOKS = ["preinstall", "install", "postinstall"]; * `verified` records how that was established, because reading a script's name * is not verification. */ +// sharp was listed here while the tree carried sharp 0.34.x, which shipped an +// install script (install/check.js, deciding whether to build against a global +// libvips). 0.35.x dropped that script entirely, so from the 0.34.5 -> 0.35.4 +// bump onward there has been nothing to permit. sharp is still installed, as an +// optional dependency of next; it simply no longer runs anything on install. +// +// The entry is removed rather than kept "just in case". This is a permit list: +// an unused permit is not harmless, because if a future sharp reintroduces an +// install script it should fail this check and be re-reviewed, not sail through +// on a rationale written for a script that no longer exists. const ALLOWED = { - sharp: { - reason: - "Arrives as a dependency of next, which uses it for image optimisation. " + - "The install script checks whether a system-wide libvips should be used " + - "and, if so, exits non-zero to trigger a source build. On a normal " + - "install it is a no-op and the prebuilt platform binary is used instead, " + - "so skipping it costs nothing.", - verified: - "Read node_modules/sharp/install/check.js. It requires ../lib/libvips, " + - "calls useGlobalLibvips(), and exits 1 only when a global libvips or " + - "npm_config_build_from_source is present. It writes nothing.", - }, "unrs-resolver": { reason: "Development only. Arrives via eslint-config-next through " + diff --git a/scripts/generate-brand-assets.mjs b/scripts/generate-brand-assets.mjs deleted file mode 100644 index 7da2084..0000000 --- a/scripts/generate-brand-assets.mjs +++ /dev/null @@ -1,303 +0,0 @@ -/** - * Generates the downloadable brand assets in public/brand/. - * - * Run with: npm run brand - * - * Wordmark type is converted to outlines rather than left as , so the - * SVGs render identically on machines that do not have Space Grotesk or IBM - * Plex Mono installed. The fonts are read from the webfont files Next.js has - * already downloaded into .next/static/media, so `next build` must have run at - * least once before this script. - */ - -import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import * as fontkit from "fontkit"; -import * as wawoff2 from "wawoff2"; -import sharp from "sharp"; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -const OUT = join(ROOT, "public", "brand"); -const TMP = join(ROOT, ".next", "cache", "brand-fonts"); - -const GOLD = "#C9A227"; -const GOLD_INK = "#7E6212"; -const INK = "#16181D"; -const PAPER = "#FFFFFF"; -const SLATE = "#5C6068"; - -// Geometry — keep in sync with src/lib/brand.ts -const CLOUD_ARC = "M30 70 A14 14 0 0 1 30 42 A20 20 0 0 1 68 34 A16 16 0 0 1 96 44 A13 13 0 0 1 96 70"; -const RETURN_ARC = "M96 70 L46 70 A11 11 0 0 1 46 48 A9 9 0 0 1 62 53"; -const STROKE = 5.5; -const ART = { x: 13.2, y: 19.9, width: 98.6, height: 52.9 }; -const VIEWBOX = `${ART.x} ${ART.y} ${ART.width} ${ART.height}`; -const TILE = { radiusRatio: 0.22, fillRatio: 0.66 }; -const CAP_RATIO = 0.7; -// Descriptor sizing/tracking — mirrors WORDMARK in src/lib/brand.ts. -const SUBLINE_RATIO = 0.45; - -const ln = (d, c, w) => - ``; - -/** - * The mark: gold line, deep-gold return. One colourway, no tone variants — - * the tile and favicon put this exact artwork on white rather than inverting - * it or swapping in a solid form. Keep in sync with MARK_COLOURS in - * src/lib/brand.ts. - */ -const markInner = () => - ` ${ln(CLOUD_ARC, GOLD, STROKE)}\n ${ln(RETURN_ARC, GOLD_INK, STROKE)}`; - -/** - * Find a Next-downloaded webfont by the font family it declares. - * - * Resolved from the CSS *content* rather than the CSS filename, and from the - * `@font-face` block's own `url()` rather than an assumed media directory. - * Both of those were previously assumed and both assumptions were wrong: - * - * - Next emits ONE hash-named stylesheet carrying every family. Matching a - * filename against a module name like "space_grotesk" never hits, because - * no filename contains it; the module name survives only inside the file, - * in generated class names. - * - The `url()` values are UNQUOTED, `url(../media/x.woff2)`. A pattern - * requiring `url("...")` matches nothing. - * - * Neither was caused by a framework upgrade. Both hold on 16.2.12 and 16.3.3. - * - * The path resolves relative to the stylesheet that declares it, because - * `../media/` means different things from `.next/static/chunks` and - * `.next/dev/static/chunks`; hardcoding the production path pointed the dev - * branch at a file that need not exist. - */ -function findWoff2(family) { - const chunkDirs = [ - join(ROOT, ".next", "dev", "static", "chunks"), - join(ROOT, ".next", "static", "chunks"), - ]; - - // Kept for the error message. A diagnostic that cannot say what it looked at - // is how the previous version came to advise running a build that had - // already succeeded. - const searched = []; - const familiesSeen = new Set(); - - for (const dir of chunkDirs) { - let files; - try { - files = readdirSync(dir); - } catch { - searched.push(`${dir} (absent)`); - continue; - } - const stylesheets = files.filter((f) => f.endsWith(".css")); - searched.push(`${dir} (${stylesheets.length} stylesheet(s))`); - - for (const sheet of stylesheets) { - const text = readFileSync(join(dir, sheet), "utf8"); - for (const block of text.split("@font-face").slice(1)) { - const declared = block.match(/font-family:\s*([^;}]+)/); - if (!declared) continue; - const name = declared[1].trim().replace(/^["']|["']$/g, ""); - familiesSeen.add(name); - if (name !== family) continue; - - // `U+??` is the latin subset. The other blocks are Cyrillic, Greek and - // Vietnamese subsets, carrying none of the glyphs the wordmark uses. - const range = block.match(/unicode-range:\s*([^;}]+)/); - if (!range || !range[1].includes("U+??")) continue; - - // Quoted or unquoted, both accepted. - const url = block.match(/url\(\s*["']?([^"')]+)["']?\s*\)/); - if (!url) continue; - - return resolve(dir, url[1].trim()); - } - } - } - - throw new Error( - `Could not locate the webfont for font-family "${family}".\n` + - `Searched:\n ${searched.join("\n ")}\n` + - `Families declared there: ` + - `${familiesSeen.size ? [...familiesSeen].sort().join(", ") : "(none)"}\n` + - `A latin (U+??) @font-face block with a url() is required. If the list ` + - `above is empty, run \`npm run build\` first. If it names families but ` + - `not this one, the font configuration in src/app/layout.tsx has changed.`, - ); -} - -async function loadFontAsync(family, label) { - mkdirSync(TMP, { recursive: true }); - const ttfPath = join(TMP, `${label}.ttf`); - const woff2 = readFileSync(findWoff2(family)); - const ttf = await wawoff2.decompress(woff2); - writeFileSync(ttfPath, Buffer.from(ttf)); - return fontkit.openSync(ttfPath); -} - -/** - * Lay out a string and return outlined path data plus its advance width, - * both expressed at the requested font size. - */ -function outline(font, text, fontSize, { letterSpacing = 0 } = {}) { - const scale = fontSize / font.unitsPerEm; - const run = font.layout(text); - let x = 0; - const parts = []; - run.glyphs.forEach((glyph, i) => { - const d = glyph.path - .scale(scale, -scale) // flip: font Y is up, SVG Y is down - .translate(x, 0) - .toSVG(); - if (d) parts.push(d); - x += run.positions[i].xAdvance * scale + letterSpacing; - }); - return { d: parts.join(" "), width: x - letterSpacing }; -} - -/** Bare mark, cropped to the artwork so the SVG's box is the visible mark. */ -function markSvg() { - return ` -${markInner()} -`; -} - -/** Square tile — the mark on a white field, for icons and avatars. */ -function tileSvg() { - const box = 100; - const w = box * TILE.fillRatio; - const s = w / ART.width; - const h = ART.height * s; - const dx = (box - w) / 2 - ART.x * s; - const dy = (box - h) / 2 - ART.y * s; - return ` - - -${markInner()} - -`; -} - -/** Favicon. The white tile — the field is what holds it on a dark tab strip. */ -function faviconSvg() { - return tileSvg(); -} - -/** - * Wordmark: the full company name set as type, for places that already carry - * the mark. It includes the descriptor — a letterhead or an email signature - * using this should still read "Invision Solutions", not just "Invision". - */ -function wordmarkSvg({ display, displayMid }) { - const SIZE = 100; - const w = outline(display, "INVISION", SIZE); - const subSize = SIZE * SUBLINE_RATIO; - const natural = outline(displayMid, "SOLUTIONS", subSize).width; - const ls = (w.width - natural) / ("SOLUTIONS".length - 1); - const sub = outline(displayMid, "SOLUTIONS", subSize, { letterSpacing: ls }); - const cap = SIZE * CAP_RATIO; - const gapSub = SIZE * 0.22; - const pad = 16; - const W = Math.round(Math.max(w.width, sub.width) + pad * 2); - const H = Math.round(cap + gapSub + subSize + pad * 2); - return ` - - -`; -} - -/** Stacked lockup: mark over wordmark over a tracked subline, all centred. */ -function lockupSvg({ display, displayMid }) { - const SIZE = 100; - const w = outline(display, "INVISION", SIZE); - // Track the descriptor so it spans the name exactly. Solved here from the - // real advance widths rather than using the constant, so the two can never - // drift apart if the face changes. - const subSize = SIZE * SUBLINE_RATIO; - const natural = outline(displayMid, "SOLUTIONS", subSize).width; - const ls = (w.width - natural) / ("SOLUTIONS".length - 1); - const sub = outline(displayMid, "SOLUTIONS", subSize, { letterSpacing: ls }); - const cap = SIZE * CAP_RATIO; - - const markH = SIZE * 1.05; - const markS = markH / ART.height; - const markW = ART.width * markS; - - const gapMark = SIZE * 0.34; - const gapSub = SIZE * 0.22; - const pad = 20; - const W = Math.round(Math.max(markW, w.width, sub.width) + pad * 2); - const H = Math.round(markH + gapMark + cap + gapSub + subSize + pad * 2); - - const markY = pad; - const wordBase = markY + markH + gapMark + cap; - const subBase = wordBase + gapSub + subSize; - - return ` - -${markInner()} - - - -`; -} - -async function png(svg, file, width, height) { - const opts = height ? { width, height } : { width }; - await sharp(Buffer.from(svg)).resize(opts).png({ compressionLevel: 9 }).toFile(join(OUT, file)); -} - -const main = async () => { - mkdirSync(OUT, { recursive: true }); - const grotesk = await loadFontAsync("Space Grotesk", "space-grotesk"); - const display = grotesk.getVariation({ wght: 700 }); - // The descriptor is set in the display face at 500, not the mono — a tracked - // monospace reads as a code label rather than part of the name. - const displayMid = grotesk.getVariation({ wght: 500 }); - - const written = []; - const write = (name, content) => { - writeFileSync(join(OUT, name), content); - written.push(name); - }; - - // ---- Icon ------------------------------------------------------------- - const icon = markSvg(); - const tile = tileSvg(); - write("invision-icon.svg", icon); - write("invision-icon-square.svg", tile); - write("invision-favicon.svg", faviconSvg()); - for (const px of [2048, 1024, 512, 256]) { - // Width only — the mark is ~1.86:1, and forcing it square would crop or - // stretch it. Only the tile below is genuinely square. - await png(icon, `invision-icon-${px}.png`, px); - written.push(`invision-icon-${px}.png`); - } - for (const px of [1024, 512, 256]) { - await png(tile, `invision-icon-square-${px}.png`, px, px); - written.push(`invision-icon-square-${px}.png`); - } - - // ---- Wordmark --------------------------------------------------------- - const wm = wordmarkSvg({ display, displayMid }); - write("invision-wordmark.svg", wm); - await png(wm, "invision-wordmark-2048.png", 2048); - written.push("invision-wordmark-2048.png"); - - // ---- Full lockup ------------------------------------------------------ - const lock = lockupSvg({ display, displayMid }); - write("invision-logo.svg", lock); - for (const px of [4096, 2048]) { - await png(lock, `invision-logo-${px}.png`, px); - written.push(`invision-logo-${px}.png`); - } - - console.log(`Wrote ${written.length} files to public/brand/`); -}; - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/app/brand/page.tsx b/src/app/brand/page.tsx deleted file mode 100644 index 58109e7..0000000 --- a/src/app/brand/page.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import type { Metadata } from "next"; -import Image from "next/image"; -import { pageMetadata } from "@/lib/metadata"; -import { Reveal } from "@/components/reveal"; -import { Section, Eyebrow, SurfaceCard } from "@/components/ui"; -import { BRAND_COLOURS } from "@/lib/brand"; - -export const metadata: Metadata = pageMetadata({ - title: "Brand assets", - description: - "Download the Invision Solutions logo, icon, and wordmark in SVG and PNG, plus the brand colour values and usage guidelines.", - path: "/brand", -}); - -type Asset = { - file: string; - title: string; - note: string; - /** Constrain preview height for wide lockups. */ - wide?: boolean; - /** Show on a neutral plate — a white tile is invisible on a white one. */ - plate?: boolean; -}; - -const FULL_LOGO: Asset[] = [ - { file: "invision-logo.svg", title: "SVG — vector", note: "Scales to any size without quality loss", wide: true }, - { file: "invision-logo-4096.png", title: "PNG — 4096px", note: "Print and large displays · transparent", wide: true }, - { file: "invision-logo-2048.png", title: "PNG — 2048px", note: "Screen and documents · transparent", wide: true }, -]; - -const ICON: Asset[] = [ - { file: "invision-icon.svg", title: "SVG — mark only", note: "Transparent · for white and near-white surfaces" }, - { plate: true, file: "invision-icon-square.svg", title: "SVG — white tile", note: "For app icons, avatars, and platform masks" }, - { plate: true, file: "invision-favicon.svg", title: "SVG — favicon", note: "The white tile, at favicon size" }, - { file: "invision-icon-2048.png", title: "PNG — 2048px", note: "Mark only · transparent" }, - { file: "invision-icon-1024.png", title: "PNG — 1024px", note: "Mark only · transparent" }, - { file: "invision-icon-512.png", title: "PNG — 512px", note: "Mark only · transparent" }, - { file: "invision-icon-256.png", title: "PNG — 256px", note: "Mark only · transparent" }, - { plate: true, file: "invision-icon-square-1024.png", title: "PNG tile — 1024px", note: "White tile · app icon size" }, - { plate: true, file: "invision-icon-square-512.png", title: "PNG tile — 512px", note: "White tile · app icon size" }, - { plate: true, file: "invision-icon-square-256.png", title: "PNG tile — 256px", note: "White tile · small icon size" }, -]; - -const WORDMARK: Asset[] = [ - { file: "invision-wordmark.svg", title: "SVG — vector", note: "Type converted to outlines", wide: true }, - { file: "invision-wordmark-2048.png", title: "PNG — 2048px", note: "Transparent", wide: true }, -]; - -const COLOURS = [ - { name: "Gold", hex: BRAND_COLOURS.gold, role: "The mark, and fills where type sits on top" }, - { name: "Deep gold", hex: BRAND_COLOURS.goldInk, role: "Gold as text — meets AA on light backgrounds" }, - { name: "Ink", hex: BRAND_COLOURS.ink, role: "Primary text and buttons" }, - { name: "Slate", hex: BRAND_COLOURS.slate, role: "Secondary text and captions" }, - { name: "Panel", hex: BRAND_COLOURS.panel, role: "Cards and alternating sections" }, - { name: "Paper", hex: BRAND_COLOURS.paper, role: "Page background" }, -]; - -function AssetCard({ asset }: { asset: Asset }) { - return ( - - {/* Paper plate by default. Tile assets get a neutral one, or their white - field would be invisible against it. */} -
- {asset.title} -
-
-

{asset.title}

-

{asset.note}

- - Download - -
-
- ); -} - -function AssetGrid({ assets }: { assets: Asset[] }) { - return ( -
- {assets.map((a, i) => ( - - - - ))} -
- ); -} - -export default function BrandPage() { - return ( - <> -
- - Brand -

- Brand assets -

-

- Every Invision Solutions logo file, ready to download. SVG scales to - any size without losing quality — use it wherever it is accepted. The - PNGs are transparent and go up to 4096px for print and large - displays. -

-

- The mark is a cloud drawn as one continuous line. The stroke traces - the silhouette in gold, runs back along the base, then turns inward - in deep gold and stops. The outward pass is the platform — the - estate running somewhere you cannot point at. The return is the - consultant coming back through it. One line, two tones, no second - object. -

-

- There is one version and no variants. Where the mark cannot sit on - white — an app icon, an avatar, a dark surface — the white tile - carries it, rather than the artwork being recoloured or swapped for - a heavier one. -

-
-
- -
- -

- Full logo -

-

- Mark and wordmark together. The default choice wherever there is room - for it. -

-
- -
- -
- -

- Icon -

-

- The mark on its own — app icons, favicons, social avatars, and - anywhere the full logo would be too small to read. -

-
- -
- -
- -

- Wordmark -

-

- The full name set as type, for places that already carry the mark — - letterheads, invoice footers, email signatures. -

-
- -
- -
- -

- Colours -

-

- Gold changes between uses — the bright tone lacks contrast as text on - a light background, so the deeper one is used there. -

-
-
- {COLOURS.map((c, i) => ( - - - - - ))} -
-
- -
- - -

- Using the logo -

-
    -
  • - There is one version of the mark. No dark colourway, no - inverted variant, and no heavier stand-in at small sizes — the - tile, favicon and app icon all carry this exact artwork. -
  • -
  • - On anything other than white or near-white, use the white tile - rather than recolouring the mark. The white field is part of - the logo. -
  • -
  • - Do not stretch, recolour, rotate, or add effects to the logo. - Do not place it on a busy photograph. -
  • -
  • - Leave clear space around it of at least the height of the mark. - The gold line measures 2.42:1 against paper — it is a soft mark - by design, and space is what lets it hold its own. -
  • -
-
-
-
- - ); -} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index d8cd025..9e38f47 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,10 +1,13 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { pageMetadata } from "@/lib/metadata"; import { Reveal } from "@/components/reveal"; import { Web3Form, type Field } from "@/components/web3forms"; import { Section, Eyebrow } from "@/components/ui"; -import { CONTACT_ACCESS_KEY, CONTACT_KEY_NAME } from "@/lib/mailboxes"; +import { + CONTACT_ACCESS_KEY, + CONTACT_KEY_NAME, + PUBLIC_CONTACT_ADDRESS, +} from "@/lib/mailboxes"; export const metadata: Metadata = pageMetadata({ title: "Contact", @@ -49,15 +52,14 @@ export default function ContactPage() { I read and respond to every message personally.

- Have something more specific — pricing, invoicing, contracts, or - support on live work?{" "} - - Send it to the right desk - {" "} - and it will reach the correct mailbox first time. + {PUBLIC_CONTACT_ADDRESS} + + .

diff --git a/src/app/enquiries/page.tsx b/src/app/enquiries/page.tsx index 6eebf2d..c8e0c9c 100644 --- a/src/app/enquiries/page.tsx +++ b/src/app/enquiries/page.tsx @@ -2,13 +2,17 @@ import type { Metadata } from "next"; import { pageMetadata } from "@/lib/metadata"; import { Reveal } from "@/components/reveal"; import { Web3Form, type Field } from "@/components/web3forms"; -import { Section, Eyebrow, SurfaceCard } from "@/components/ui"; -import { MAILBOXES } from "@/lib/mailboxes"; +import { Section, Eyebrow } from "@/components/ui"; +import { + CONTACT_ACCESS_KEY, + CONTACT_KEY_NAME, + PUBLIC_CONTACT_ADDRESS, +} from "@/lib/mailboxes"; export const metadata: Metadata = pageMetadata({ title: "Enquiries", description: - "Send your enquiry straight to the right desk at Invision Solutions — sales, support, billing, invoices, contracts, or partnerships.", + "Send a general enquiry to Invision Solutions. Every message reaches David Levi directly.", path: "/enquiries", }); @@ -16,57 +20,46 @@ const FIELDS: Field[] = [ { name: "name", label: "Name", required: true }, { name: "email", label: "Email", type: "email", required: true }, { name: "company", label: "Company" }, - { name: "message", label: "Message", type: "textarea", required: true, rows: 4 }, + { name: "message", label: "Message", type: "textarea", required: true, full: true }, ]; export default function EnquiriesPage() { return ( - <> -
+
+
Enquiries -

- Send it to the right desk. +

+ Send an enquiry.

-

- Each form below goes to its own mailbox, so your enquiry lands where - it belongs instead of queuing behind everything else. It still - reaches David — this only decides which inbox it arrives in. +

+ Sales, pricing, invoicing, contracts, support on live work, or an + introduction — it all comes to the same place, and it comes to David + directly. +

+

+ Prefer email? Send it to{" "} + + {PUBLIC_CONTACT_ADDRESS} + + .

-
- -
-
- {MAILBOXES.map((box, i) => ( - - -

- {box.label} -

-

- {box.description} -

-

- {box.address} -

-
- -
-
-
- ))} -
-
- + + + + +
); } diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 55a177d..1e43abf 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -4,7 +4,7 @@ import { pageMetadata } from "@/lib/metadata"; import { Reveal } from "@/components/reveal"; import { Section, Eyebrow, SurfaceCard, PrimaryButton } from "@/components/ui"; import { SERVICES } from "@/lib/content"; -import { SALES_ADDRESS, PUBLIC_CONTACT_ADDRESS } from "@/lib/mailboxes"; +import { PUBLIC_CONTACT_ADDRESS } from "@/lib/mailboxes"; export const metadata: Metadata = pageMetadata({ title: "Services", @@ -86,10 +86,10 @@ export default function ServicesPage() {

Pricing question? Day rates and retainer costs go to{" "} - {SALES_ADDRESS} + {PUBLIC_CONTACT_ADDRESS} .

diff --git a/src/app/testimonials/page.tsx b/src/app/testimonials/page.tsx index 6ac8243..457a726 100644 --- a/src/app/testimonials/page.tsx +++ b/src/app/testimonials/page.tsx @@ -3,7 +3,11 @@ import { pageMetadata } from "@/lib/metadata"; import { Reveal } from "@/components/reveal"; import { Web3Form, type Field } from "@/components/web3forms"; import { Section, Eyebrow, SurfaceCard } from "@/components/ui"; -import { TESTIMONIALS_ADDRESS } from "@/lib/mailboxes"; +import { + CONTACT_ACCESS_KEY, + CONTACT_KEY_NAME, + PUBLIC_CONTACT_ADDRESS, +} from "@/lib/mailboxes"; export const metadata: Metadata = pageMetadata({ title: "Testimonials", @@ -150,10 +154,10 @@ export default function TestimonialsPage() {

Prefer email? Send it to{" "} - {TESTIMONIALS_ADDRESS} + {PUBLIC_CONTACT_ADDRESS} .

@@ -161,7 +165,8 @@ export default function TestimonialsPage() { -
-

- Session: closed — log {year} — access: granted -

+

© {year} Invision Solutions Ltd. All rights reserved.

diff --git a/src/lib/mailboxes.ts b/src/lib/mailboxes.ts index 85df429..ef08020 100644 --- a/src/lib/mailboxes.ts +++ b/src/lib/mailboxes.ts @@ -1,119 +1,42 @@ /** - * Enquiry routing. + * Contact routing. * - * Web3Forms binds one access key to one form, and a form's recipients are set - * in its dashboard — there is no client-side recipient override (that would - * make the endpoint an open relay). So routing an enquiry to a specific mailbox - * means a separate Web3Forms form, with its own access key, per mailbox. + * There is one Web3Forms form behind this site, and one access key for it: + * NEXT_PUBLIC_WEB3FORMS_KEY. Every form on the site — /contact, /enquiries and + * the testimonial form on /testimonials — submits to it, and it delivers to + * PUBLIC_CONTACT_ADDRESS. * - * Each entry reads its own NEXT_PUBLIC_WEB3FORMS_KEY_* variable and falls back - * to the general key when that variable is unset. The fallback delivers to - * info@, which is the catch-all, so the page works before every form exists and - * gets more precise as keys are added. Nothing silently disappears. + * This used to be seven forms and eight keys, one per mailbox, because + * Web3Forms binds one access key to one form and sets recipients per form in + * its dashboard rather than from the client (a client-side recipient override + * would make the endpoint an open relay). Those forms have been deleted from + * the Web3Forms account, so the per-mailbox NEXT_PUBLIC_WEB3FORMS_KEY_* + * variables now name forms that no longer exist. * - * NEXT_PUBLIC_ values are inlined at build time, so process.env cannot be - * indexed dynamically here — each key must be written out literally. + * Nothing here reads those variables any more, deliberately. A stale + * NEXT_PUBLIC_WEB3FORMS_KEY_* left set in Vercel is therefore inert rather than + * live: it cannot be picked up and silently pointed at a deleted form. That is + * the exact shape of the August /contact outage, where a key resolved to a + * form that had been removed and submissions stopped delivering without any + * visible error. + * + * Submissions are told apart by subject line, set per form at the call site. */ -export type Mailbox = { - slug: string; - address: string; - label: string; - description: string; - accessKey: string | undefined; - /** The variable this mailbox's key comes from, for diagnostics. */ - keyName: string; -}; - -const GENERAL = process.env.NEXT_PUBLIC_WEB3FORMS_KEY; +/** The access key every form on the site submits with. */ +export const CONTACT_ACCESS_KEY = process.env.NEXT_PUBLIC_WEB3FORMS_KEY; -export const MAILBOXES: Mailbox[] = [ - { - slug: "sales", - address: "sales@invisionsolutions.co.uk", - label: "Sales & pricing", - description: - "Day rates, retainer pricing, and what a given scope is likely to cost.", - accessKey: process.env.NEXT_PUBLIC_WEB3FORMS_KEY_SALES ?? GENERAL, - keyName: "NEXT_PUBLIC_WEB3FORMS_KEY_SALES", - }, - { - slug: "contact", - address: "contact@invisionsolutions.co.uk", - label: "General enquiry", - description: - "Anything that does not fit the other routes — including introductions.", - accessKey: process.env.NEXT_PUBLIC_WEB3FORMS_KEY_CONTACT ?? GENERAL, - keyName: "NEXT_PUBLIC_WEB3FORMS_KEY_CONTACT", - }, - { - slug: "support", - address: "support@invisionsolutions.co.uk", - label: "Client support", - description: - "Existing engagements: questions on delivered work, handover, or documentation.", - accessKey: process.env.NEXT_PUBLIC_WEB3FORMS_KEY_SUPPORT ?? GENERAL, - keyName: "NEXT_PUBLIC_WEB3FORMS_KEY_SUPPORT", - }, - { - slug: "billing", - address: "billing@invisionsolutions.co.uk", - label: "Billing", - description: "Payment terms, purchase orders, and billing arrangements.", - accessKey: process.env.NEXT_PUBLIC_WEB3FORMS_KEY_BILLING ?? GENERAL, - keyName: "NEXT_PUBLIC_WEB3FORMS_KEY_BILLING", - }, - { - slug: "invoice", - address: "invoice@invisionsolutions.co.uk", - label: "Invoices", - description: "Invoice queries, copies, remittance advice, and corrections.", - accessKey: process.env.NEXT_PUBLIC_WEB3FORMS_KEY_INVOICE ?? GENERAL, - keyName: "NEXT_PUBLIC_WEB3FORMS_KEY_INVOICE", - }, - { - slug: "admin", - address: "admin@invisionsolutions.co.uk", - label: "Contracts & admin", - description: - "Supplier onboarding, NDAs, insurance certificates, and compliance paperwork.", - accessKey: process.env.NEXT_PUBLIC_WEB3FORMS_KEY_ADMIN ?? GENERAL, - keyName: "NEXT_PUBLIC_WEB3FORMS_KEY_ADMIN", - }, - { - slug: "hello", - address: "hello@invisionsolutions.co.uk", - label: "Partnerships & press", - description: - "Collaborations, speaking, podcasts, and anything media-related.", - accessKey: process.env.NEXT_PUBLIC_WEB3FORMS_KEY_HELLO ?? GENERAL, - keyName: "NEXT_PUBLIC_WEB3FORMS_KEY_HELLO", - }, -]; +/** Named in the error state when CONTACT_ACCESS_KEY resolves to nothing. */ +export const CONTACT_KEY_NAME = "NEXT_PUBLIC_WEB3FORMS_KEY"; /** - * The key the /contact form uses. - * - * It reuses the contact desk's form rather than reading the general key - * directly, because every mailbox above has its own key set in production. That - * means the `?? GENERAL` fallbacks never fire, and the general key was left - * exercised by exactly one page: /contact. A fault in it was therefore invisible - * on /enquiries, which is how the contact form came to fail while all seven - * enquiry forms passed. + * The address that key delivers to, and the only address the site publishes. * - * Pointing /contact at the contact desk removes that blind spot. The form it - * submits to is now the same one /enquiries exercises on every test, and it - * delivers to PUBLIC_CONTACT_ADDRESS below, which is the address the contact - * page already gives out. + * Every `mailto:` link on the site points here too. /services previously + * published sales@ and /testimonials published hello@; both mailboxes are being + * retired along with the per-department forms, so advertising them would leave + * dead addresses on a live site. A mailto does not touch the Web3Forms account + * at all, so this is about where mail can actually be read, not about the + * linked-email allowance. */ -export const CONTACT_ACCESS_KEY = - MAILBOXES.find((box) => box.slug === "contact")?.accessKey ?? GENERAL; - -/** Named in the error state when CONTACT_ACCESS_KEY resolves to nothing. */ -export const CONTACT_KEY_NAME = - "NEXT_PUBLIC_WEB3FORMS_KEY_CONTACT (or NEXT_PUBLIC_WEB3FORMS_KEY)"; - -/** Address shown in the footer and as the general-purpose public address. */ export const PUBLIC_CONTACT_ADDRESS = "contact@invisionsolutions.co.uk"; -export const SALES_ADDRESS = "sales@invisionsolutions.co.uk"; -export const TESTIMONIALS_ADDRESS = "hello@invisionsolutions.co.uk";