Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76873808ce | ||
|
|
710d3d06e6 | ||
|
|
2cb15608dd | ||
|
|
af66f61c26 | ||
|
|
7a71296e6d | ||
|
|
f3797bfe47 | ||
|
|
bee03398d0 | ||
|
|
1b6a96225e | ||
|
|
f0c25b802b | ||
|
|
affd4b7ee7 | ||
|
|
3e0c9a1605 | ||
|
|
90120b60db | ||
|
|
e6f00cbbb6 | ||
|
|
f50bb553e8 | ||
|
|
c7f8672cd0 | ||
|
|
6fcf893903 | ||
|
|
5cbcb2eac4 | ||
|
|
2dab728129 | ||
|
|
4bfbcbcacd | ||
|
|
83616433e4 | ||
|
|
c70833a725 | ||
|
|
11e991e54a | ||
|
|
c67c13d82f | ||
|
|
ca0eddfde8 | ||
|
|
5b037d7e0c | ||
|
|
6f6e52dd63 | ||
|
|
666b1d7547 | ||
|
|
60f094a754 | ||
|
|
f04e8c8463 | ||
|
|
9ed436330b | ||
|
|
494f69db85 | ||
|
|
75993100fa | ||
|
|
e89a248ca5 | ||
|
|
e9f05b02fc |
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: video-film
|
||||
description: Make or change a social media film or image post in apps/video (Remotion) in the approved mintel.me style - one continuous shot, minimal, with a camera pull-back as the reveal, in German and English. Use when asked for a new video, a new topic, changes to an existing film, new formats, or to re-render the video library.
|
||||
---
|
||||
|
||||
# Making a film for mintel.me
|
||||
|
||||
The videos in `apps/video` are short looping films for LinkedIn and ads. The owner approved this
|
||||
style after rejecting several others. Read `apps/video/CLAUDE.md` first; this skill is the procedure.
|
||||
|
||||
## What the owner rejected, so do not go back to it
|
||||
|
||||
- Slides with a panel, a progress bar, labels and a frame around everything.
|
||||
- Much text, small text, text sizes that change from line to line.
|
||||
- Hectic motion: hops of a third of a second, six statements in 15 seconds.
|
||||
- Busy drawings: icons, labels, counters, stacks, several effects at once.
|
||||
- A tiny logo in a black box.
|
||||
- German that reads translated.
|
||||
|
||||
## The style
|
||||
|
||||
One continuous shot on a dark stage. Only squares, dots, lines and bars. One statement at a time,
|
||||
two short lines, large and centred under the drawing. Calm: nothing moves faster than it has to, every
|
||||
statement stays for about three seconds.
|
||||
|
||||
Every film tells the same kind of story in six beats, on the shared timeline `T` in
|
||||
`src/films/shot.ts` (690 frames at 30 fps, 23 seconds):
|
||||
|
||||
| Beat | Caption id | What happens |
|
||||
| :--- | :--------- | :----------- |
|
||||
| Hook | `hook` | Close on something small and abstract that looks like something else. "Looks like ping-pong." |
|
||||
| Reveal | `reveal` | The camera pulls back and shows what it really is. "It's your office." This is the aha moment. |
|
||||
| Problem | `problem` | The whole picture keeps going, and it is clearly too much. |
|
||||
| Turn | `turn` | Everything slows to a halt, then the form changes: arcs become a line, a pile leaves by itself. |
|
||||
| Calm | `calm` | The result at rest, with one quiet movement. |
|
||||
| Call to action | `cta` | A question that picks up the hook ("Done with ping-pong?") and the website's button. |
|
||||
|
||||
Then the picture folds back into its opening, so the last frame equals the first and the film loops.
|
||||
Frame 0 is the preview image: it must already look good and carry the hook line.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Bring the worktree up to date with `main`** and read the service in
|
||||
`apps/web/src/content/de.ts` and `en.ts`. Claims, prices and terms come from there and nowhere else.
|
||||
2. **Find the picture before writing code.** Answer in one line each:
|
||||
- What does the viewer see close up, and what harmless thing does it look like?
|
||||
- What does the camera reveal when it pulls back?
|
||||
- Which single change of form resolves it?
|
||||
If the reveal does not make you think "oh", look for another picture. Existing ones:
|
||||
ping-pong / office, skyscraper / pile of invoices, star / unanswered enquiries,
|
||||
blinking cursor / empty website, Jenga / fragile app, loading bar / backlog.
|
||||
3. **Write the six statements** in `src/content/videos.ts`, German and English, plus the post text.
|
||||
Two lines each, as short as possible: one font size is used for the whole film, set by the longest
|
||||
line. The hook is "Sieht aus wie ..." / "Looks like ...". A little sarcasm is welcome. German uses
|
||||
"Sie" and has to sound like a native wrote it; write it first, do not translate it from English.
|
||||
The post text opens with the hook. Copy rules of the root `CLAUDE.md` apply.
|
||||
4. **Write the film** as `src/films/<Name>Film.tsx`. Copy the structure of `AppFilm.tsx` (simplest)
|
||||
or `OrderFilm.tsx` (several moving things):
|
||||
- `cameraAt(frame, zoomIn, zoomOut, focus)` gives `transform`, `zoom`, `hair` and `others`.
|
||||
Everything that is not part of the opening picture gets `opacity={others}`.
|
||||
- Draw in stage coordinates, (0, 0) in the centre, 1000 x 700. Use `hair` for stroke widths and
|
||||
divide glow sizes by `zoom`, so lines look the same at every zoom.
|
||||
- All motion is a pure function of the frame: `progress`, `inOut`, `out`, `mix` from
|
||||
`src/illustrations/motion.ts`. No timers, no randomness, no CSS animations.
|
||||
- For the loop: after `T.fold` the opening picture has to be rebuilt exactly. Anything periodic
|
||||
needs a period that divides 690 (23, 30, 46). Compare frame 0 and frame 689.
|
||||
- Logic worth testing goes into `src/lib` with a spec, written first (see `hop.ts`).
|
||||
5. **Register it**: `src/compositions/<VideoId>.tsx` exporting a `VideoEntry` with `Film`, and the
|
||||
list in `src/Root.tsx`. A new id also goes into `VIDEO_IDS` in `videos.ts`.
|
||||
6. **Check**: `pnpm typecheck` and `pnpm test` in `apps/video`.
|
||||
7. **Render and look**: `node scripts/export.ts --only <VideoId>`, then pull frames from the MP4
|
||||
(start, middle of the pull-back, problem, turn, calm, call to action, last frame) in `feed` and
|
||||
`wide`, and read them. Check: nothing overlaps the text, the drawing fades at the stage edges
|
||||
during the camera move, frame 689 equals frame 0, German lines fit.
|
||||
8. **Report** what was rendered, what you looked at, and what you did not. You cannot play video:
|
||||
say so, and leave the judgement of pace and feel to the owner.
|
||||
|
||||
## Traps that have cost time here
|
||||
|
||||
- **A render can silently not happen.** Redirect logs with `>|`, not `>` (the shell refuses to
|
||||
overwrite an existing file and the command never starts). After every export, compare the file times
|
||||
with the clock before sending anything. Run one export at a time.
|
||||
- **Glows**: `drop-shadow` on SVG shapes is fine. Blur filters under a CSS mask, blend modes and dashed
|
||||
strokes came out with hard edges; build soft light from gradients that fade to transparent.
|
||||
- **The stage is not clipped by itself.** While the camera is close, the drawing is larger than the
|
||||
stage; `FilmStage` fades it at the edges. Do not rely on things being "off screen".
|
||||
- **Do not name a helper script `copy.py`, `json.py` or similar**: it shadows the Python module.
|
||||
- **Text from an old worktree is stale.** The website changed language and wording during this work.
|
||||
|
||||
## Image posts
|
||||
|
||||
Every film gets a poster, and the plan pairs it with a card (see `apps/web/plans/content-strategy.md`).
|
||||
|
||||
- Poster: add an entry to `src/content/images.ts` with `poster: { video, frame }`. Pick the frame where the
|
||||
whole picture is in view and the problem is obvious (around frame 250 to 300). Two lines, as short as
|
||||
possible: "Kein Pingpong." / "Ihr Büro."
|
||||
- Card: a question a client asks before the first call (`label`) and the answer in two or three lines.
|
||||
The answer must be on the website already.
|
||||
- Add the new posts to `src/content/plan.ts`, run `pnpm test`, then `node scripts/export.ts --images`
|
||||
and look at the PNGs in `feed` and `square`.
|
||||
- A poster is not a frozen frame inside a one-frame image: `Freeze` did not work there. The poster is a
|
||||
composition as long as the film, rendered at that frame.
|
||||
|
||||
## Formats and output
|
||||
|
||||
Compositions are `<VideoId>-<de|en>-<feed|wide|square|story>`. `node scripts/export.ts` renders
|
||||
`feed` (4:5, LinkedIn) and `wide` (16:9, YouTube and ads) in both languages into
|
||||
`out/library/<lang>/week-<n>/<slot>-<day>-<id>/`, with `post.txt` and an overview page
|
||||
`out/library/index.html`. `out/` is not committed. Keep it tidy: check frames, contact sheets and logs
|
||||
go into `out/tmp`, nothing loose into `out/`, and `out/tmp` is emptied before you report.
|
||||
@@ -477,8 +477,8 @@ jobs:
|
||||
run: |
|
||||
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
# The legal pages must be reachable.
|
||||
for PAGE in /imprint /privacy; do
|
||||
# The freelance page and the legal pages must be reachable in both languages.
|
||||
for PAGE in /freelance /en/freelance /impressum /datenschutz /en/imprint /en/privacy; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$PAGE")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ $PAGE failed! (Status: $STATUS)"
|
||||
@@ -488,7 +488,7 @@ jobs:
|
||||
done
|
||||
|
||||
# URLs of the previous site must redirect instead of returning 404.
|
||||
for OLD in /blog /contact /websites /case-studies/klz-cables /impressum; do
|
||||
for OLD in /blog /contact /websites /case-studies/klz-cables; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$OLD")
|
||||
if [ "$STATUS" != "308" ]; then
|
||||
echo "❌ $OLD does not redirect! (Status: $STATUS)"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# mintel.me
|
||||
|
||||
Personal website of a senior freelance engineer. Next.js 16 (App Router), pnpm monorepo.
|
||||
App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.md` (read it first).
|
||||
App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, standalone, own `CLAUDE.md`). Concept and scope: `apps/web/plans/freelance-relaunch.md` (read it first).
|
||||
|
||||
## Commands (run from repo root)
|
||||
|
||||
@@ -25,22 +25,62 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
|
||||
- Never mention AI as the owner's tool, and no internal method vocabulary (specs, TDD, mutation, coverage,
|
||||
pull requests). The automation service may name AI as part of what the client gets. Plain language.
|
||||
- Modest and factual: no promises the owner did not confirm, no superlatives. See the concept, section 1.
|
||||
- One page: everything lives on the homepage. The header has only "Get in touch", which jumps to the contact
|
||||
section (`#contact`). Contact is one email address: no calendar integration, no form. The only other
|
||||
pages are `/imprint` and `/privacy`. URLs of the previous site redirect to `/` (see `next.config.mjs`).
|
||||
- Title, description and link previews come from `SITE_TITLE` and `SITE_DESCRIPTION` in `src/content/site.ts`.
|
||||
The share image is a screenshot of the real hero: after changing the hero headline or its design, run
|
||||
`pnpm --filter @mintel/web share-image` and commit the two PNGs in `app/(site)/`.
|
||||
- Two content pages. The homepage tells one story from top to bottom (who, what you need, examples, how
|
||||
working together goes, results, questions, contact); each section opens with a `bridge` sentence from
|
||||
`story` in the content files that leads over from the one before. The technical sections (project fit,
|
||||
tools, how a change is secured) live on `/freelance` (`/en/freelance`), for people who develop themselves
|
||||
or lead a team. Do not move technical detail back onto the homepage.
|
||||
- The header has the link to the freelance page, the language switch and "Get in touch", which jumps to the
|
||||
contact section (`#contact`). Contact is one email address: no calendar integration, no form. The only
|
||||
other pages are the imprint and the privacy notice. URLs of the previous site redirect to `/`
|
||||
(see `next.config.mjs`).
|
||||
- Title, description and link previews come from `site` in `src/content/de.ts` and `en.ts`.
|
||||
The share image is a screenshot of the real hero, one per language: after changing the hero headline or
|
||||
its design, run `pnpm --filter @mintel/web share-image` and commit the PNGs in `src/assets/share/`.
|
||||
- The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`.
|
||||
Never write the full address into markup, metadata or copy.
|
||||
- The hero design (binary-digit headline) is approved: do not change its design.
|
||||
- Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind.
|
||||
|
||||
## Two languages (German is the main one)
|
||||
|
||||
- German lives at the root (`/`, `/freelance`, `/impressum`, `/datenschutz`), English under `/en`
|
||||
(`/en/freelance`, `/en/imprint`, `/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses
|
||||
onto it. All routing and detection rules are pure functions in `src/domain/routes.ts` and `locale.ts`.
|
||||
- Detection: an address without a language follows the `lang` cookie (set only by the language switch), then
|
||||
the browser's `Accept-Language`, then German. `/en/...` is never redirected.
|
||||
- Every visible text is data in `src/content/de.ts` and `en.ts` (type `Content` in `types.ts`). Components
|
||||
hold no texts: server components take them from `contentFor(locale)`, client components from
|
||||
`useContent()`. A new text goes into both files; German uses the formal "Sie".
|
||||
- Never name a file `src/i18n.ts` or `i18n/request.ts`: `@mintel/next-config` would switch on next-intl.
|
||||
- The privacy notice describes the language cookie; keep it true when the detection changes.
|
||||
|
||||
## Motion (binary micro-interactions)
|
||||
|
||||
- Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits),
|
||||
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
|
||||
`BinaryTicker`, `ScrollBits`, `BitBurst`. Reuse them instead of inventing new effects.
|
||||
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (light
|
||||
along the edge of an element, digits drifting off it; glow only on dark surfaces, on light ones just a
|
||||
few blue digits), `BitSeam` (lit top edge of a dark section), `BitGlow` (light following the pointer on
|
||||
`data-glow` surfaces). Reuse them instead of inventing new effects.
|
||||
- The homepage is one story with a visible thread (`apps/web/src/components/story/`). Every chapter opens
|
||||
with one sentence from `story` in the content files; read one after the other they tell the way of a
|
||||
project from the idea to the first email. `StoryThread` is one trace down the whole page, in the look of a circuit
|
||||
board (45 degree bends, solder points, a second parallel track), that draws itself as the visitor scrolls:
|
||||
under each sentence it crosses the page, stands a component with the chapter number in binary on the track
|
||||
and continues on the other side, with a bit (1/0) travelling at its head. `Block` prop `chapter` gives a section its sentence and
|
||||
figure; how much of the line is drawn is decided in `src/domain/thread.ts`. A new section on the homepage
|
||||
needs a sentence that continues the story, or it breaks the thread. No rails, no "next question" links
|
||||
(owner rejected both).
|
||||
- Drawings use the circuit parts in `apps/web/src/components/illustrations/circuit.tsx`: `Chip` (a system
|
||||
or person, with pins), `Track` (straight runs, 45 degree bends), `Pad` (solder point), `Bit` (what travels).
|
||||
New drawings are built from these, not from pills, curves and round dots.
|
||||
- Two audiences, two pages (owner, 2026-10-05). The homepage is for people who commission a result and have
|
||||
no software of their own: a website or process automation (plus web presentations). It shows no project prices
|
||||
and no hourly terms, only "ask for an offer"; the one price there is the care offer for websites (89 EUR
|
||||
per month, see the concept, section 5). Everything for companies that book by the hour is on the
|
||||
freelance page only: the developer case, making an existing app reliable, how working together goes,
|
||||
project fit, tools, safeguards, rates and booking. Do not put any of that back on the homepage.
|
||||
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
|
||||
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
|
||||
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
out
|
||||
.remotion
|
||||
@@ -0,0 +1,102 @@
|
||||
# apps/video
|
||||
|
||||
Social media videos (LinkedIn first, 16:9 for YouTube and Google Ads) in the mintel.me look, made with Remotion.
|
||||
Standalone package: it is **not** in `pnpm-workspace.yaml`, so it never ends up in the web build, the Dockerfile
|
||||
or `pnpm -r`. `apps/web` must never import from here, and this package imports nothing from `apps/web`.
|
||||
|
||||
## Commands (run from this folder)
|
||||
|
||||
- Install: `pnpm install --ignore-workspace`
|
||||
- Studio (the playground, http://localhost:3000): `pnpm studio`
|
||||
- One frame as PNG: `pnpm still WebsiteDone-de-feed out/check.png --frame=40`
|
||||
- One video: `pnpm render WebsiteDone-de-feed out/website.mp4`
|
||||
- The whole library: `pnpm library` (= `node scripts/export.ts`; `--only <VideoId>`, `--formats feed,wide`,
|
||||
`--page` to rewrite only post texts and the overview). Writes `out/library/index.html` (watch, copy the post
|
||||
text, download) and `out/library/<language>/week-<n>/<slot>-<day>-<id>/` with the files and `post.txt`, in plan order.
|
||||
- `pnpm typecheck`, `pnpm test`
|
||||
|
||||
Compositions are named `<VideoId>-<language>-<format>`: languages `en`, `de`; formats `feed` (4:5, LinkedIn),
|
||||
`wide` (16:9), `square`, `story` (9:16). `out/` is gitignored and holds only two things: `out/library`
|
||||
(what gets posted) and `out/tmp` (your check frames, sheets and logs; delete it when you are done).
|
||||
|
||||
## Where things are
|
||||
|
||||
- `src/content/videos.ts`: every word. Headlines per slide, the post text per video, both languages, and the
|
||||
German for labels inside drawings (`LABELS_DE`). Drawing code writes labels in English; they are translated
|
||||
when rendered. German addresses the viewer as "Sie".
|
||||
- `src/compositions/<Video>.tsx`: the slides of one video (ids, durations, illustrations). No text.
|
||||
- `src/slides`: `SlideDeck` (panel, headline, curtain, loop, portrait and 16:9 layout), `ServiceVideo`
|
||||
(frame + language + deck), `ctaSlide` (closing slide with the button).
|
||||
- `src/illustrations`: `parts.tsx` (tile, icon, cursor, data chip, window, page, badges, day box), `scenes.tsx`
|
||||
(scenes several videos share), `motion.ts` (easing helpers).
|
||||
- `src/brand`: tokens, fonts, `BrandFrame` (logo, labels, digit texture), `Panel` (the dark panel with the
|
||||
website's light effects), `BrandButton`.
|
||||
- `src/lib`: pure, tested logic (`bits.ts`, `aura.ts`).
|
||||
|
||||
## How to make a new video
|
||||
|
||||
1. Add the texts to `src/content/videos.ts` (new `VideoId`, both languages, post text).
|
||||
2. Add `src/compositions/<Name>.tsx` with its slides and register it in `src/Root.tsx`.
|
||||
3. Look before reporting: render the MP4, pull one frame per slide and read them, in `feed` and `wide`, in both
|
||||
languages (German lines are longer). Do not hand over a video you have not seen.
|
||||
4. `pnpm library --only <Name>` and tell the owner where the files are.
|
||||
|
||||
## Films (the current format, owner approved 2026-10-04)
|
||||
|
||||
Every video is a film in `src/films`: one continuous shot on a dark stage, not a deck of slides.
|
||||
- Shared: `FilmStage` (backdrop, one centred statement at a time, signature, address) and `shot.ts`
|
||||
(one timeline `T`, the camera move `cameraAt`, the six caption slots hook/reveal/problem/turn/calm/cta).
|
||||
- The pattern: close on something abstract that looks like something else ("Looks like ping-pong."), the
|
||||
camera pulls back and shows what it really is ("It's your office."), it stops, resolves, rests, and
|
||||
returns to the opening picture so the film loops. Frame 0 is the preview image.
|
||||
- Minimal and calm (owner feedback: earlier versions were hectic). Only squares, dots, lines and bars; no
|
||||
icons or labels in the drawing. One statement of two short lines, held for about three seconds.
|
||||
A little sarcasm in the hook is welcome. German has to read like a native wrote it.
|
||||
- One font size per film: the largest at which the longest line fits, so keep lines short.
|
||||
- The logo is the white signature (`public/logo-mark.svg`): bottom left, and very large and faint behind.
|
||||
- The slide deck code (`src/slides/SlideDeck`, `src/illustrations`, `BrandFrame`, `Panel`) is no longer used
|
||||
by any video and can be removed once the owner confirms.
|
||||
|
||||
## Image posts and the posting plan
|
||||
|
||||
- Image posts are made the same way as films: `src/content/images.ts` holds every word and, for a poster, which
|
||||
film and frame it shows; `src/images/ImagePost.tsx` draws them. A poster is a film rendered at one frame with
|
||||
two lines ("Not ping-pong. Your office."); a card is a client's question with the answer in large type.
|
||||
Cards say only what the website says. Compositions: `<ImageId>-<language>-<feed|square>`.
|
||||
- `src/content/plan.ts` is the posting plan (which film or image in which week); the reasoning is in
|
||||
`apps/web/plans/content-strategy.md`. The spec checks that every film and image is planned exactly once
|
||||
and that every video post opens with the hook of its film.
|
||||
- `pnpm library` renders films and images and writes the overview page in plan order;
|
||||
`--images`, `--videos`, `--only <id>`, `--page`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Before any work: bring the worktree up to date with `main` (owner feedback, twice). Take wording, styles and
|
||||
effects from the website as it is **now**: texts in `apps/web/src/content/de.ts` and `en.ts`, styles in
|
||||
`apps/web/app/globals.css`, drawings in `apps/web/src/components/illustrations`. Do not state prices, terms
|
||||
or durations that are not in the texts.
|
||||
- Picture first, few words (owner feedback): 6 to 8 slides of about 2 s, each one large animated illustration
|
||||
plus a headline of two to four words. No paragraphs, no captions.
|
||||
- It has to look stunning (owner feedback, repeated: "not enough yet"). The videos carry every effect of the
|
||||
website: the dark `Panel` with `BitAura` (light along the edge, digits drifting off), `BitGlow` (light over
|
||||
the dot grid) and `bit-aurora` (indigo light drifting behind the drawing); slides change with the `BitCurtain`
|
||||
(ragged front of digits, `src/lib/curtain.ts`); the button has the `bit-cta` light running round its edge.
|
||||
When the website gets a new effect, bring it here as a pure function of the frame in `src/lib`.
|
||||
- Drawings follow `PathScenes.tsx`: fine light lines, tinted glass surfaces (`DRAW.tint`, `DRAW.glass`) instead
|
||||
of solid blocks, amber packets with a halo (`DRAW.mark`), emerald for done, soft glows on what is active.
|
||||
- UI in a video must be the website's real UI, rebuilt faithfully (owner feedback): read the component in
|
||||
`apps/web/src/components` first. The button is `BrandButton` (twin of `Button.tsx`); do not draw your own.
|
||||
- Almost no frame (owner feedback: the drawing matters far more than what surrounds it). No border, no name,
|
||||
no topic label, no progress bar. Only one small line at the bottom: the logo, small and dimmed, and
|
||||
`www.mintel.me` (with `www.` so that it reads as an address). The stage is scaled as large as the frame allows.
|
||||
- Light is drawn with plain gradients that fade to transparent. No `filter: blur`, `mix-blend-mode` or dashed
|
||||
strokes for glows: they came out with hard edges in the rendered video (owner feedback).
|
||||
- Every video loops: the first slide is complete on frame 0 (it is the preview image) and fades back in at the
|
||||
end. Anything that moves outside the slides (`Panel`) must repeat exactly over the length of the video.
|
||||
- Frame-driven only: all motion comes from `useCurrentFrame()` / `interpolate()`. No `setTimeout`,
|
||||
`setInterval`, CSS animations, `Math.random()` or framer-motion: they do not render correctly frame by frame.
|
||||
Pure logic goes in `src/lib` with a `*.spec.ts`.
|
||||
- 30 fps, about 15 seconds. The video must work without sound and make sense in the first two seconds.
|
||||
- Copy rules as on the website (see root `CLAUDE.md`): no AI as the owner's tool, no internal method vocabulary,
|
||||
modest and factual, no promises the owner did not confirm. Never write the full email address.
|
||||
- The hero design of the website (binary-digit headline) is approved: do not recreate it here with changes.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@mintel/video",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Social media videos in the mintel.me look. Standalone: not part of the pnpm workspace, never imported by apps/web.",
|
||||
"scripts": {
|
||||
"studio": "remotion studio src/index.ts",
|
||||
"render": "remotion render src/index.ts",
|
||||
"still": "remotion still src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"library": "node scripts/export.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.532",
|
||||
"@remotion/google-fonts": "4.0.532",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"remotion": "4.0.532"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "~4.1.11"
|
||||
}
|
||||
}
|
||||
Generated
+3632
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 745 744" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Favicon" x="0.855" y="0.277" width="743.633" height="743.633" style="fill:none;"/><path d="M739.657,49.961c-0,-24.755 -20.098,-44.852 -44.853,-44.852l-644.264,-0c-24.755,-0 -44.853,20.097 -44.853,44.852l-0,644.265c-0,24.755 20.098,44.853 44.853,44.853l644.264,-0c24.755,-0 44.853,-20.098 44.853,-44.853l-0,-644.265Z"/><path d="M135.778,286.282c0.157,0.236 0.52,0.761 0.852,1.092c1.2,1.192 2.956,2.358 5.296,3.587l0.005,0.003l2,1.327l1.693,1.752l1.239,2.072l0.713,2.215l0.211,2.192l-0.208,2.068l-1.385,3.646l-2.46,2.971l-1.634,1.187l-1.911,0.914l-2.161,0.551l-2.323,0.093l-2.343,-0.412l-2.211,-0.892c-11.589,-6.087 -16.814,-12.713 -18.39,-18.621c-1.835,-6.879 0.575,-13.751 6.509,-19.965c12.824,-13.43 44.389,-24.257 57.136,-27.454l0.001,-0c30.563,-7.658 62.165,-12.34 93.648,-13.693c25.54,-1.098 54.423,-1.803 78.831,7.271c15.51,5.768 26.031,14.726 32.759,25.559c56.484,-46.707 118.893,-93.116 187.414,-119.225c10.958,-4.178 27.246,-8.604 40.339,-7.415c7.908,0.718 14.744,3.442 19.461,8.573c4.662,5.072 7.541,12.672 6.521,23.945c-3.634,40.091 -31.543,80.109 -52.575,112.911c-25.848,40.321 -53.663,79.557 -82.723,117.821c29.191,-23.305 58.382,-46.61 87.573,-69.913l1.731,-1.145l1.85,-0.796l3.75,-0.585l3.526,0.562l3.17,1.552l2.672,2.595l1.028,1.728l0.705,1.995l0.285,2.178l-0.185,2.22l-0.631,2.115l-0.992,1.91c-10.505,16.533 -21.014,33.063 -31.523,49.592l-0.001,0.003c-1.852,2.909 -11.995,19.195 -18.14,30.842c-0.096,0.182 -0.192,0.366 -0.288,0.553c13.673,-3.721 27.615,-13.517 38.776,-19.936c10.441,-6.004 20.778,-12.208 30.865,-18.787l0.003,-0.002l2.134,-1.1l2.356,-0.626l2.421,-0.072l2.289,0.463l2.022,0.893l1.703,1.201l2.495,3.003l1.373,3.598l0.232,1.999l-0.139,2.106l-0.573,2.146l-1.048,2.067l-1.498,1.843l-1.854,1.496l-0.007,0.005c-15.715,10.242 -31.914,19.975 -48.604,28.526c-6.986,3.579 -18.808,10.744 -29.918,13.789c-9.41,2.579 -18.37,2.143 -24.958,-2.988c-5.648,-4.398 -7.104,-11.077 -5.042,-18.895c3.104,-11.773 15.551,-27.001 19.276,-32.858l2.604,-4.095c-37.274,29.759 -74.551,59.518 -111.826,89.274l-1.82,1.214l-2.004,0.868l-2.111,0.471l-2.12,0.061l-2.039,-0.329l-1.892,-0.676l-3.201,-2.224l-2.259,-3.202l-0.689,-1.912l-0.33,-2.068l0.077,-2.148l0.5,-2.129l0.902,-2.005l1.242,-1.804c59.233,-71.996 118.215,-147.452 163.946,-228.856l0.002,-0.003c3.532,-6.277 19.498,-32.912 25.637,-54.458c1.456,-5.11 2.365,-9.885 2.213,-13.918c-0.128,-3.403 -1.052,-6.169 -4.397,-6.847c-6.883,-1.395 -14.409,-0.182 -21.911,2.075c-12.591,3.787 -25.072,10.598 -34.871,15.502l-0.002,0.001c-37.202,18.606 -72.519,40.846 -106.083,65.396c-19.253,14.077 -38.067,28.773 -56.448,44.009c5.948,31.922 -8.725,71.663 -25.261,97.617c-26.624,41.789 -61.114,78.396 -97.084,112.241c-35.155,33.081 -71.676,66.504 -111.219,94.355l-0.002,0.001c-4.905,3.453 -13.056,9.944 -21.245,13.763c-7.008,3.268 -14.124,4.488 -20.021,2.432c-7.666,-2.674 -10.711,-8.481 -11.105,-15.23c-0.288,-4.928 1.117,-10.472 2.745,-14.802l0.001,-0.003c17.424,-46.26 54.722,-88.018 86.091,-125.21c52.005,-61.657 108.894,-119.681 170.402,-171.929c-5.142,-9.861 -13.608,-17.675 -25.833,-20.957c-27.596,-7.404 -57.826,-6.098 -86.019,-3.428c-30.452,2.883 -61.745,7.625 -90.667,17.984c-6.667,2.388 -17.118,6.215 -22.892,11.134c-0.89,0.758 -1.884,2.115 -2.149,2.485Zm-6.046,295.699c3.853,-1.713 7.757,-4.116 11.623,-6.805c12.067,-8.393 23.538,-19.805 31.614,-26.433c45.641,-37.472 90.707,-76.66 129.793,-121.083c26.644,-30.283 57.988,-66.814 65.641,-107.833c1.02,-5.466 1.414,-11.09 1.137,-16.634c-41.419,35.852 -80.575,74.39 -117.54,114.67c-41.853,45.61 -85.416,93.945 -115.619,148.393c-0.671,1.213 -4.049,9.375 -6.649,15.725Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,7 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setCodec("h264");
|
||||
Config.setPixelFormat("yuv420p");
|
||||
Config.setCrf(16);
|
||||
Config.setOverwriteOutput(true);
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Renders the content library: every film and every image post in every language, with the text
|
||||
* to post next to it, and an overview page in the order of the posting plan.
|
||||
*
|
||||
* node scripts/export.ts everything
|
||||
* node scripts/export.ts --images only the image posts
|
||||
* node scripts/export.ts --videos only the films
|
||||
* node scripts/export.ts --only WebsiteDone one film or one image
|
||||
* node scripts/export.ts --formats feed one video format (feed, wide, square, story)
|
||||
* node scripts/export.ts --page only rewrite post texts and the overview page
|
||||
*
|
||||
* Output, in the order of the posting plan:
|
||||
* out/library/index.html
|
||||
* out/library/<language>/week-<n>/<slot>-<day>-<id>/ the files to upload and post.txt
|
||||
* Anything else in out/ is scratch: put checks and logs into out/tmp, which can be deleted at any time.
|
||||
* Run one export at a time, and redirect its log with `>|`, not `>`.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { IMAGES, IMAGE_IDS, type ImageId } from "../src/content/images.ts";
|
||||
import { PLAN, POSTING_DAYS, type PlannedPost } from "../src/content/plan.ts";
|
||||
import { COPY, LANGS, VIDEO_IDS, type Lang, type VideoId } from "../src/content/videos.ts";
|
||||
|
||||
const LIBRARY = join("out", "library");
|
||||
const DEFAULT_VIDEO_FORMATS = ["feed", "wide"];
|
||||
const IMAGE_FORMATS = ["feed", "square"];
|
||||
const FORMAT_NOTE: Readonly<Record<string, string>> = {
|
||||
feed: "4:5, LinkedIn feed",
|
||||
wide: "16:9, YouTube and Google Ads",
|
||||
square: "1:1",
|
||||
story: "9:16, vertical",
|
||||
};
|
||||
|
||||
const option = (name: string): string | undefined => {
|
||||
const index = process.argv.indexOf(`--${name}`);
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
};
|
||||
const flag = (name: string): boolean => process.argv.includes(`--${name}`);
|
||||
|
||||
const only = option("only");
|
||||
const videoFormats = option("formats")?.split(",") ?? DEFAULT_VIDEO_FORMATS;
|
||||
const pageOnly = flag("page");
|
||||
const wanted = (id: string): boolean => only === undefined || id === only;
|
||||
const videoIds = flag("images") ? [] : VIDEO_IDS.filter(wanted);
|
||||
const imageIds = flag("videos") ? [] : IMAGE_IDS.filter(wanted);
|
||||
if (videoIds.length + imageIds.length === 0) {
|
||||
throw new Error(`Nothing to export for "${only}". Known: ${[...VIDEO_IDS, ...IMAGE_IDS].join(", ")}`);
|
||||
}
|
||||
|
||||
/** Where a post lives inside the library: by language, then week, then day, as it will be posted. */
|
||||
const place = (id: string, lang: Lang): string => {
|
||||
for (const [weekIndex, week] of PLAN.entries()) {
|
||||
const slot = week.posts.findIndex((post) => post.id === id);
|
||||
if (slot !== -1) {
|
||||
return `${lang}/week-${weekIndex + 1}/${slot + 1}-${(POSTING_DAYS[slot] ?? "").toLowerCase()}-${id}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`${id} is not in the posting plan (src/content/plan.ts)`);
|
||||
};
|
||||
const folder = (id: string, lang: Lang): string => join(LIBRARY, place(id, lang));
|
||||
const fileName = (id: string, lang: Lang, format: string, extension: string): string =>
|
||||
`${id}-${lang}-${format}.${extension}`;
|
||||
|
||||
/** Runs a Remotion command and makes sure it really wrote the file. */
|
||||
const render = (command: "render" | "still", composition: string, target: string, frame = 0): void => {
|
||||
console.log(`Rendering ${target}`);
|
||||
const startedAt = Date.now();
|
||||
const extra = command === "still" ? [`--frame=${frame}`] : [];
|
||||
execFileSync("pnpm", ["exec", "remotion", command, "src/index.ts", composition, target, "--log=error", ...extra], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
// A render can end without an error and without a new file (seen with two renders started at once).
|
||||
if (!existsSync(target) || statSync(target).mtimeMs < startedAt) {
|
||||
throw new Error(`${target} was not written. Run one export at a time.`);
|
||||
}
|
||||
};
|
||||
|
||||
const postText = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
|
||||
kind === "video" ? COPY[id as VideoId][lang].post : IMAGES[id as ImageId].copy[lang].post;
|
||||
const title = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
|
||||
kind === "video" ? COPY[id as VideoId][lang].title : IMAGES[id as ImageId].copy[lang].title;
|
||||
|
||||
for (const lang of LANGS) {
|
||||
for (const id of videoIds) {
|
||||
mkdirSync(folder(id, lang), { recursive: true });
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("video", id, lang)}\n`);
|
||||
if (pageOnly) continue;
|
||||
for (const format of videoFormats) {
|
||||
render("render", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "mp4")));
|
||||
}
|
||||
}
|
||||
for (const id of imageIds) {
|
||||
mkdirSync(folder(id, lang), { recursive: true });
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("image", id, lang)}\n`);
|
||||
if (pageOnly) continue;
|
||||
for (const format of IMAGE_FORMATS) {
|
||||
render("still", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "png")), IMAGES[id].poster?.frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
const media = (post: PlannedPost, lang: Lang): string => {
|
||||
const extension = post.kind === "video" ? "mp4" : "png";
|
||||
const shown = Object.keys(FORMAT_NOTE)
|
||||
.filter((format) => existsSync(join(folder(post.id, lang), fileName(post.id, lang, format, extension))))
|
||||
.map((format) => {
|
||||
const src = `${place(post.id, lang)}/${fileName(post.id, lang, format, extension)}`;
|
||||
const element =
|
||||
post.kind === "video"
|
||||
? `<video src="${src}" controls loop muted playsinline preload="metadata"></video>`
|
||||
: `<img src="${src}" alt="" loading="lazy">`;
|
||||
return `<figure class="${format}">${element}<figcaption>${FORMAT_NOTE[format]} · <a href="${src}" download>Download</a></figcaption></figure>`;
|
||||
})
|
||||
.join("\n");
|
||||
return shown || "<p>Not rendered yet.</p>";
|
||||
};
|
||||
|
||||
const card = (post: PlannedPost, day: string, lang: Lang): string => `<article lang="${lang}" data-lang="${lang}">
|
||||
<header><span class="tag">${day} · ${post.kind}</span><h3>${escapeHtml(title(post.kind, post.id, lang))}</h3></header>
|
||||
<div class="media">${media(post, lang)}</div>
|
||||
<div class="post">
|
||||
<div class="post-head"><span class="tag">Post text</span><button type="button" class="copy">Copy</button></div>
|
||||
<textarea readonly rows="12">${escapeHtml(postText(post.kind, post.id, lang))}</textarea>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
const weeks = PLAN.map(
|
||||
(week, index) => `<section>
|
||||
<h2><span class="tag">Week ${index + 1}</span>${escapeHtml(week.topic)}</h2>
|
||||
${week.posts.flatMap((post, slot) => LANGS.map((lang) => card(post, POSTING_DAYS[slot] ?? "", lang))).join("\n")}
|
||||
</section>`,
|
||||
).join("\n");
|
||||
|
||||
const page = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Content library</title>
|
||||
<style>
|
||||
:root { --ink: #0f172a; --muted: #64748b; --line: #e2e8f0; --wash: #f8fafc; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #fff; color: var(--ink); font-family: Inter, system-ui, sans-serif; }
|
||||
main { max-width: 1400px; margin: 0 auto; padding: 48px 20px 96px; }
|
||||
h1 { font-size: clamp(40px, 7vw, 88px); font-weight: 900; letter-spacing: -0.05em; line-height: 0.95; margin: 0 0 12px; }
|
||||
.intro { color: var(--muted); font-size: 18px; max-width: 62ch; margin: 0 0 24px; }
|
||||
.switch { display: flex; gap: 8px; margin-bottom: 40px; }
|
||||
section { border-top: 2px solid var(--ink); padding-top: 20px; margin-top: 56px; }
|
||||
h2 { font-size: 34px; letter-spacing: -0.03em; margin: 0 0 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
h3 { font-size: 22px; letter-spacing: -0.02em; margin: 0; }
|
||||
article { border-top: 1px solid var(--line); padding: 28px 0; display: grid; gap: 20px; }
|
||||
@media (min-width: 900px) { article { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); } article header { grid-column: 1 / -1; } }
|
||||
body[data-show="de"] article[data-lang="en"], body[data-show="en"] article[data-lang="de"] { display: none; }
|
||||
header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.tag, figcaption { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
|
||||
.media { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
|
||||
figure { margin: 0; }
|
||||
video, img { display: block; width: 100%; border-radius: 16px; background: var(--ink); }
|
||||
figure.feed, figure.square { width: min(100%, 300px); }
|
||||
figure.story { width: min(100%, 230px); }
|
||||
figure.wide { width: min(100%, 460px); }
|
||||
figcaption { margin-top: 10px; }
|
||||
a { color: var(--ink); }
|
||||
.post-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
textarea { width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 16px; font: 15px/1.5 Inter, system-ui, sans-serif; color: var(--ink); background: var(--wash); resize: vertical; }
|
||||
button { border: 1px solid var(--ink); border-radius: 999px; background: var(--ink); color: #fff; padding: 10px 20px; font: 700 11px Inter, system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
|
||||
button[aria-pressed="false"] { background: #fff; color: var(--ink); }
|
||||
</style>
|
||||
</head>
|
||||
<body data-show="de">
|
||||
<main>
|
||||
<h1>Content library</h1>
|
||||
<p class="intro">Six weeks, three posts each: the film on Tuesday, a card on Thursday, the poster on Saturday. The plan is in <code>src/content/plan.ts</code>, the reasoning in <code>apps/web/plans/content-strategy.md</code>.</p>
|
||||
<div class="switch">
|
||||
<button type="button" data-set="de" aria-pressed="true">Deutsch</button>
|
||||
<button type="button" data-set="en" aria-pressed="false">English</button>
|
||||
</div>
|
||||
${weeks}
|
||||
</main>
|
||||
<script>
|
||||
for (const button of document.querySelectorAll("[data-set]")) {
|
||||
button.addEventListener("click", () => {
|
||||
document.body.dataset.show = button.dataset.set;
|
||||
for (const other of document.querySelectorAll("[data-set]")) other.setAttribute("aria-pressed", String(other === button));
|
||||
});
|
||||
}
|
||||
for (const button of document.querySelectorAll(".copy")) {
|
||||
button.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(button.closest(".post").querySelector("textarea").value);
|
||||
button.textContent = "Copied";
|
||||
setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
mkdirSync(LIBRARY, { recursive: true });
|
||||
writeFileSync(join(LIBRARY, "index.html"), page);
|
||||
console.log(`Overview: ${join(LIBRARY, "index.html")}`);
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FC } from "react";
|
||||
import { Composition } from "remotion";
|
||||
import { FORMATS, FPS, type FormatName } from "./brand/tokens";
|
||||
import { IMAGES, IMAGE_IDS } from "./content/images";
|
||||
import { LANGS } from "./content/videos";
|
||||
import { ImagePost } from "./images/ImagePost";
|
||||
import { VIDEOS } from "./registry";
|
||||
import { SHOT_FRAMES } from "./films/shot";
|
||||
import { ServiceVideo, videoDuration } from "./slides/ServiceVideo";
|
||||
|
||||
const FORMAT_NAMES = Object.keys(FORMATS) as FormatName[];
|
||||
/** Images are posted in the feed: 4:5, and square for places that crop. */
|
||||
const IMAGE_FORMATS = ["feed", "square"] as const;
|
||||
|
||||
/** Videos are `<VideoId>-<language>-<format>`, images `<ImageId>-<language>-<feed|square>`. */
|
||||
export const Root: FC = () => (
|
||||
<>
|
||||
{VIDEOS.flatMap((video) =>
|
||||
LANGS.flatMap((lang) =>
|
||||
FORMAT_NAMES.map((format) => (
|
||||
<Composition
|
||||
key={`${video.id}-${lang}-${format}`}
|
||||
id={`${video.id}-${lang}-${format}`}
|
||||
component={() => <ServiceVideo video={video} lang={lang} />}
|
||||
durationInFrames={videoDuration(video)}
|
||||
fps={FPS}
|
||||
width={FORMATS[format].width}
|
||||
height={FORMATS[format].height}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
{IMAGE_IDS.flatMap((id) =>
|
||||
LANGS.flatMap((lang) =>
|
||||
IMAGE_FORMATS.map((format) => (
|
||||
// A poster is a film at one of its frames, so it needs the film's length; a card is a single frame.
|
||||
<Composition
|
||||
key={`${id}-${lang}-${format}`}
|
||||
id={`${id}-${lang}-${format}`}
|
||||
component={() => <ImagePost id={id} lang={lang} />}
|
||||
durationInFrames={IMAGES[id].poster ? SHOT_FRAMES : 1}
|
||||
fps={FPS}
|
||||
width={FORMATS[format].width}
|
||||
height={FORMATS[format].height}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Easing, interpolate, useCurrentFrame } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { COLOR } from "../brand/tokens";
|
||||
import { bitString } from "../lib/bits";
|
||||
|
||||
const COLUMN_DIGITS = 48;
|
||||
const SCRAMBLE_EVERY_FRAMES = 2;
|
||||
const EDGE_PCT = 2;
|
||||
|
||||
/**
|
||||
* Content revealed left to right behind a narrow column of binary digits (video twin of
|
||||
* BitWipe on the site). The content keeps its place, so nothing reflows.
|
||||
*/
|
||||
export const BitWipe: FC<{
|
||||
children: ReactNode;
|
||||
/** Frame (inside the current sequence) where the wipe starts. */
|
||||
from?: number;
|
||||
durationInFrames?: number;
|
||||
/** Size of the digit column in px. */
|
||||
digitSize?: number;
|
||||
tone?: "dark" | "light";
|
||||
}> = ({ children, from = 0, durationInFrames = 24, digitSize = 22, tone = "dark" }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const t = interpolate(frame, [from, from + durationInFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.bezier(0.65, 0, 0.35, 1),
|
||||
});
|
||||
const done = t >= 1;
|
||||
const rightInset = 100 - t * (100 + EDGE_PCT);
|
||||
const columnOpacity = interpolate(t, [0, 0.08, 0.86, 1], [0, 1, 1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block", whiteSpace: "nowrap" }}>
|
||||
<div
|
||||
style={{
|
||||
clipPath: done
|
||||
? "none"
|
||||
: `inset(-12% ${Math.max(0, rightInset)}% -12% -2%)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{t > 0 && !done && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `${t * 100}%`,
|
||||
width: digitSize * 1.1,
|
||||
overflow: "hidden",
|
||||
opacity: columnOpacity,
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 700,
|
||||
fontSize: digitSize,
|
||||
lineHeight: 1.05,
|
||||
writingMode: "vertical-rl",
|
||||
wordBreak: "break-all",
|
||||
color: tone === "light" ? COLOR.paper : COLOR.ink,
|
||||
}}
|
||||
>
|
||||
{bitString(COLUMN_DIGITS, Math.floor(frame / SCRAMBLE_EVERY_FRAMES))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { CSSProperties, FC } from "react";
|
||||
import { Easing, interpolate, useCurrentFrame } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { COLOR } from "../brand/tokens";
|
||||
import { decodeText } from "../lib/bits";
|
||||
|
||||
const SCRAMBLE_EVERY_FRAMES = 2;
|
||||
|
||||
/** Mono label resolving from digits (video twin of DecodeText on the site). Monospaced only. */
|
||||
export const DecodeLabel: FC<{
|
||||
text: string;
|
||||
/** Frame (inside the current sequence) where decoding starts. */
|
||||
from?: number;
|
||||
durationInFrames?: number;
|
||||
style?: CSSProperties;
|
||||
}> = ({ text, from = 0, durationInFrames = 18, style }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const progress = interpolate(frame, [from, from + durationInFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
});
|
||||
const tick = Math.floor(frame / SCRAMBLE_EVERY_FRAMES);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: COLOR.muted,
|
||||
whiteSpace: "pre",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{decodeText(text, progress, tick)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties, FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { useT } from "../content/CopyContext";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR, DRAW } from "./tokens";
|
||||
|
||||
/** One lap of the light round the edge, as on the website (6 s at 30 fps). */
|
||||
const LAP_FRAMES = 180;
|
||||
const SWEEP_FRAMES = 60;
|
||||
|
||||
/** Shows only a ring of the given thickness along the edge of its box. */
|
||||
const ring = (thickness: number): CSSProperties => ({
|
||||
padding: thickness,
|
||||
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
|
||||
});
|
||||
|
||||
/**
|
||||
* The primary button of the website (apps/web Button.tsx with `bit-cta`): dark pill, bold uppercase
|
||||
* label, the arrow in a circle, a light travelling round the edge with a soft glow behind it, and
|
||||
* the spot of light that follows the pointer there. Here that spot sweeps across on its own.
|
||||
*/
|
||||
export const BrandButton: FC<{
|
||||
label: string;
|
||||
/** Font size in px; padding, arrow and light scale with it. */
|
||||
size?: number;
|
||||
/** `light` is the white pill the website uses on dark surfaces. */
|
||||
tone?: "dark" | "light";
|
||||
}> = ({ label, size = 28, tone = "dark" }) => {
|
||||
const light = tone === "light";
|
||||
const frame = useCurrentFrame();
|
||||
const t = useT();
|
||||
const circle = size * 2.7;
|
||||
const angle = (frame / LAP_FRAMES) * 360;
|
||||
const travelling = `conic-gradient(from ${angle}deg, transparent 0 58%, rgba(${DRAW.light}, 0.95) 76%, #ffffff 86%, rgba(${DRAW.light}, 0.95) 93%, transparent 100%)`;
|
||||
const sweep = ((frame % SWEEP_FRAMES) / SWEEP_FRAMES) * 140 - 20;
|
||||
const edge: CSSProperties = { position: "absolute", borderRadius: 9999, background: travelling };
|
||||
|
||||
return (
|
||||
<span style={{ position: "relative", display: "inline-flex", borderRadius: 9999 }}>
|
||||
<span style={{ position: "absolute", inset: -14, filter: "blur(12px)", opacity: 0.8 }}>
|
||||
<span style={{ ...edge, inset: 8, ...ring(12) }} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: size * 1.1,
|
||||
overflow: "hidden",
|
||||
borderRadius: 9999,
|
||||
backgroundColor: light ? COLOR.paper : COLOR.ink,
|
||||
color: light ? COLOR.ink : COLOR.paper,
|
||||
padding: `${size * 0.8}px ${size * 0.8}px ${size * 0.8}px ${size * 2.3}px`,
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 700,
|
||||
fontSize: size,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
boxShadow: "0 20px 30px -10px rgba(15, 23, 42, 0.35)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `radial-gradient(${size * 5}px circle at ${sweep}% 50%, rgba(${DRAW.light}, 0.5), transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<span style={{ position: "relative" }}>{t(label)}</span>
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: circle,
|
||||
height: circle,
|
||||
borderRadius: 9999,
|
||||
backgroundColor: light ? COLOR.ink : "rgba(255, 255, 255, 0.1)",
|
||||
color: COLOR.paper,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={size * 1.2}
|
||||
height={size * 1.2}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ ...edge, inset: -2, ...ring(4) }} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { bitString } from "../lib/bits";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR } from "./tokens";
|
||||
|
||||
const LOGO_SIZE = 36;
|
||||
/** Width of one digit of the texture including its letter spacing, in px. */
|
||||
const RAIN_COLUMN_WIDTH = 28.6;
|
||||
const RAIN_ROW_HEIGHT = 40;
|
||||
const RAIN_EVERY_FRAMES = 6;
|
||||
const RAIN_OPACITY = 0.03;
|
||||
|
||||
/** Slowly changing field of digits behind everything, as quiet texture. Fills its box at any format. */
|
||||
const BitRain: FC<{ inset: number }> = ({ inset }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const columns = Math.ceil((width - inset * 2) / RAIN_COLUMN_WIDTH);
|
||||
const tick = Math.floor(frame / RAIN_EVERY_FRAMES);
|
||||
const rowCount = Math.ceil((height - inset * 2) / RAIN_ROW_HEIGHT);
|
||||
const rows = Array.from({ length: rowCount }, (_, row) =>
|
||||
bitString(columns, tick + row * 7),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: inset,
|
||||
left: inset,
|
||||
right: inset,
|
||||
bottom: inset,
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 26,
|
||||
lineHeight: `${RAIN_ROW_HEIGHT}px`,
|
||||
letterSpacing: "0.5em",
|
||||
color: COLOR.ink,
|
||||
opacity: RAIN_OPACITY,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre",
|
||||
padding: "0 24px",
|
||||
}}
|
||||
>
|
||||
{rows.join("\n")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Space kept free at the sides, the top and (for the footer) the bottom of the frame, in px. */
|
||||
export const FRAME = { side: 48, top: 48, bottom: 104 } as const;
|
||||
|
||||
/**
|
||||
* What surrounds the drawing: paper, a quiet digit texture, and one small line at the bottom with
|
||||
* the logo and the address. Deliberately little (owner feedback): the drawing is what matters.
|
||||
*/
|
||||
export const BrandFrame: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<AbsoluteFill style={{ backgroundColor: COLOR.paper }}>
|
||||
<BitRain inset={0} />
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: FRAME.side,
|
||||
right: FRAME.side,
|
||||
bottom: 36,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile("logo.svg")} style={{ width: LOGO_SIZE, height: LOGO_SIZE, opacity: 0.55 }} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: COLOR.muted,
|
||||
}}
|
||||
>
|
||||
www.mintel.me
|
||||
</span>
|
||||
</div>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
padding: `${FRAME.top}px ${FRAME.side}px ${FRAME.bottom}px`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { CSSProperties, FC, ReactNode } from "react";
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { cometAt, outlinePoint, sparkAt } from "../lib/aura";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR, DRAW } from "./tokens";
|
||||
|
||||
const RADIUS = 40;
|
||||
const SPARKS = 150;
|
||||
const DOT_GRID = 28;
|
||||
const LIGHT_REACH = 360;
|
||||
/** How far the light on the edge reaches into the panel and along the edge, in px. */
|
||||
const EDGE_HAZE = 300;
|
||||
const EDGE_LINE = 380;
|
||||
const EDGE_WIDTH = 3;
|
||||
/** Two lights travel the edge, opposite each other; the second is fainter. */
|
||||
const COMETS = [
|
||||
{ offset: 0, power: 1 },
|
||||
{ offset: 0.5, power: 0.6 },
|
||||
] as const;
|
||||
|
||||
/** Shows only a ring of the given thickness along the edge of its box. */
|
||||
const ring = (thickness: number): CSSProperties => ({
|
||||
padding: thickness,
|
||||
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
|
||||
});
|
||||
|
||||
const fill: CSSProperties = { position: "absolute", inset: 0 };
|
||||
|
||||
/**
|
||||
* The dark panel the drawings sit on, with the website's effects (BitAura, BitGlow, bit-aurora):
|
||||
* light travelling round its edge, binary digits drifting off it, a soft light wandering over
|
||||
* the dot grid, and slow indigo light drifting behind the drawing.
|
||||
*
|
||||
* All light is drawn with plain gradients that fade to transparent: no blur filters, blend modes
|
||||
* or dashed strokes, which render with hard edges frame by frame. Everything repeats exactly when
|
||||
* the video loops. Must be rendered outside any Sequence, so that it sees the frame of the whole video.
|
||||
*/
|
||||
export const Panel: FC<{
|
||||
width: number;
|
||||
height: number;
|
||||
/** Corner radius in px; 0 for a panel that fills the whole frame. */
|
||||
radius?: number;
|
||||
children: ReactNode;
|
||||
}> = ({ width, height, radius = RADIUS, children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
const box = { width, height };
|
||||
const turn = (frame / durationInFrames) * Math.PI * 2;
|
||||
const lightX = width * (0.5 + 0.34 * Math.sin(turn * 2));
|
||||
const lightY = height * (0.5 + 0.3 * Math.cos(turn * 3));
|
||||
const comets = COMETS.map((comet) => ({
|
||||
...outlinePoint(cometAt(frame, durationInFrames) + comet.offset, box),
|
||||
power: comet.power,
|
||||
}));
|
||||
const sparks = Array.from({ length: SPARKS }, (_, index) => sparkAt(index, frame, durationInFrames, box));
|
||||
const auroraX = 38 + Math.sin(turn) * 8;
|
||||
const auroraY = 42 + Math.cos(turn) * 7;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width, height }}>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
borderRadius: radius,
|
||||
overflow: "hidden",
|
||||
backgroundColor: DRAW.bg,
|
||||
backgroundImage: `radial-gradient(${DRAW.raised} 2px, transparent 2px)`,
|
||||
backgroundSize: `${DOT_GRID}px ${DOT_GRID}px`,
|
||||
boxShadow: "0 40px 80px -30px rgba(15, 23, 42, 0.55)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
background: `radial-gradient(55% 65% at ${auroraX}% ${auroraY}%, rgba(${DRAW.glow}, 0.34), transparent 72%), radial-gradient(45% 55% at ${102 - auroraX}% ${112 - auroraY}%, rgba(${DRAW.light}, 0.2), transparent 72%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
backgroundImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, rgba(148, 163, 184, 0.2), transparent 70%), radial-gradient(#e2e8f0 1.75px, transparent 1.75px)`,
|
||||
backgroundSize: `auto, ${DOT_GRID}px ${DOT_GRID}px`,
|
||||
maskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
|
||||
WebkitMaskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
{comets.map((comet) => (
|
||||
<div
|
||||
key={comet.power}
|
||||
style={{
|
||||
...fill,
|
||||
background: `radial-gradient(${EDGE_HAZE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.light}, ${0.5 * comet.power}), rgba(${DRAW.glow}, ${0.22 * comet.power}) 40%, transparent 72%)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div style={fill}>{children}</div>
|
||||
{comets.map((comet) => (
|
||||
<div
|
||||
key={comet.power}
|
||||
style={{
|
||||
...fill,
|
||||
borderRadius: radius,
|
||||
...ring(EDGE_WIDTH),
|
||||
background: `radial-gradient(${EDGE_LINE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.core}, ${comet.power}), rgba(${DRAW.light}, ${0.7 * comet.power}) 35%, transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg width={width} height={height} style={{ ...fill, overflow: "visible" }}>
|
||||
{sparks.map((spark, index) =>
|
||||
spark === null ? null : (
|
||||
<text
|
||||
key={index}
|
||||
x={spark.x}
|
||||
y={spark.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={spark.large ? 700 : 500}
|
||||
fontSize={spark.large ? 24 : 18}
|
||||
fill={COLOR.ink}
|
||||
opacity={spark.opacity * 0.8}
|
||||
>
|
||||
{spark.digit}
|
||||
</text>
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
|
||||
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";
|
||||
import { loadFont as loadSerif } from "@remotion/google-fonts/Newsreader";
|
||||
|
||||
const inter = loadInter("normal", { weights: ["400", "600", "700", "900"], subsets: ["latin"] });
|
||||
const mono = loadMono("normal", { weights: ["500", "700"], subsets: ["latin"] });
|
||||
const serif = loadSerif("normal", { weights: ["400"], subsets: ["latin"] });
|
||||
|
||||
/** Same three families as the website: Inter for headlines, JetBrains Mono for labels, Newsreader for reading text. */
|
||||
export const FONT = {
|
||||
sans: inter.fontFamily,
|
||||
mono: mono.fontFamily,
|
||||
serif: serif.fontFamily,
|
||||
} as const;
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Brand tokens, mirrored from apps/web (Tailwind slate scale, white paper, mono labels). */
|
||||
export const COLOR = {
|
||||
paper: "#ffffff",
|
||||
ink: "#0f172a", // slate-900
|
||||
inkSoft: "#1e293b", // slate-800
|
||||
body: "#475569", // slate-600
|
||||
muted: "#64748b", // slate-500
|
||||
faint: "#94a3b8", // slate-400
|
||||
line: "#e2e8f0", // slate-200
|
||||
wash: "#f8fafc", // slate-50
|
||||
active: "#fbbf24", // amber-400: work in progress
|
||||
done: "#10b981", // emerald-500: finished
|
||||
} as const;
|
||||
|
||||
/** Colours for drawings, which sit on a dark panel like the animated panels of the website. */
|
||||
export const DRAW = {
|
||||
bg: "#0f172a", // slate-900: the panel
|
||||
raised: "#1e293b", // slate-800: quiet surfaces on the panel
|
||||
soft: "#334155", // slate-700: placeholder lines and blocks
|
||||
label: "#94a3b8", // slate-400: mono labels
|
||||
fg: "#f8fafc", // slate-50: lines, tiles, text
|
||||
rim: "#64748b", // slate-500: fine outlines of quiet things
|
||||
tint: "rgba(248, 250, 252, 0.14)", // a lit surface: a light tint instead of a solid block
|
||||
glass: "rgba(15, 23, 42, 0.55)", // windows and cards: the dot grid shows through
|
||||
mark: "#fde68a", // amber-200: work that is travelling
|
||||
glow: "99, 102, 241", // indigo-500 as r, g, b: the light along the edge
|
||||
light: "129, 140, 248", // indigo-400 as r, g, b: the light on buttons and nodes
|
||||
core: "224, 231, 255", // indigo-100 as r, g, b: its bright centre
|
||||
} as const;
|
||||
|
||||
/** Output formats. LinkedIn feed default is 4:5; the others are variants of the same video. */
|
||||
export const FORMATS = {
|
||||
feed: { width: 1080, height: 1350 },
|
||||
square: { width: 1080, height: 1080 },
|
||||
story: { width: 1080, height: 1920 },
|
||||
/** 16:9 for YouTube and Google Ads video campaigns. */
|
||||
wide: { width: 1920, height: 1080 },
|
||||
} as const;
|
||||
|
||||
export type FormatName = keyof typeof FORMATS;
|
||||
|
||||
export const FPS = 30;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AppDrawing, AppFilm } from "../films/AppFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const appRescue: VideoEntry = {
|
||||
id: "AppRescue",
|
||||
Film: AppFilm,
|
||||
Drawing: AppDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DocumentsDrawing, DocumentsFilm } from "../films/DocumentsFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationDocuments: VideoEntry = {
|
||||
id: "AutomationDocuments",
|
||||
Film: DocumentsFilm,
|
||||
Drawing: DocumentsDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EnquiryDrawing, EnquiryFilm } from "../films/EnquiryFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationEnquiry: VideoEntry = {
|
||||
id: "AutomationEnquiry",
|
||||
Film: EnquiryFilm,
|
||||
Drawing: EnquiryDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ORDER_FILM_FRAMES, OrderDrawing, OrderFilm } from "../films/OrderFilm";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationOrder: VideoEntry = {
|
||||
id: "AutomationOrder",
|
||||
Film: OrderFilm,
|
||||
Drawing: OrderDrawing,
|
||||
durationInFrames: ORDER_FILM_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DeveloperDrawing, DeveloperFilm } from "../films/DeveloperFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const productDeveloper: VideoEntry = {
|
||||
id: "ProductDeveloper",
|
||||
Film: DeveloperFilm,
|
||||
Drawing: DeveloperDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebsiteDrawing, WebsiteFilm } from "../films/WebsiteFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const websiteDone: VideoEntry = {
|
||||
id: "WebsiteDone",
|
||||
Film: WebsiteFilm,
|
||||
Drawing: WebsiteDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { translate, type Lang, type VideoCopy } from "./videos";
|
||||
|
||||
type CopyValue = { readonly lang: Lang; readonly copy: VideoCopy };
|
||||
|
||||
const CopyContext = createContext<CopyValue | null>(null);
|
||||
|
||||
export const CopyProvider = CopyContext.Provider;
|
||||
|
||||
const useCopyValue = (): CopyValue => {
|
||||
const value = useContext(CopyContext);
|
||||
if (!value) throw new Error("This component must be rendered inside a ServiceVideo");
|
||||
return value;
|
||||
};
|
||||
|
||||
/** The texts of the video that is being rendered, in its language. */
|
||||
export const useCopy = (): VideoCopy => useCopyValue().copy;
|
||||
|
||||
/** Translates a label that is written in English in the drawing code. */
|
||||
export const useT = (): ((text: string) => string) => {
|
||||
const { lang } = useCopyValue();
|
||||
return (text) => translate(lang, text);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Image posts: every word, in English and German, and for posters which film and which frame they
|
||||
* show. Two kinds:
|
||||
*
|
||||
* - a poster is one picture of a film (the composition is rendered at that frame) with the film's idea in two lines
|
||||
* - a card answers one question a client asks before the first call, in large type
|
||||
*
|
||||
* Everything said here is on the website (`apps/web/src/content/de.ts`, questions and principles).
|
||||
* This file imports only types, so the export script can read it with plain Node.
|
||||
*/
|
||||
import type { Lang, VideoId } from "./videos.ts";
|
||||
|
||||
export type ImageCopy = {
|
||||
/** What the image is about, for the overview page. */
|
||||
readonly title: string;
|
||||
/** Cards only: the question, set small above the answer. */
|
||||
readonly label?: string;
|
||||
/** The large lines: at most three, as short as possible. */
|
||||
readonly lines: readonly string[];
|
||||
/** Text to post with the image. */
|
||||
readonly post: string;
|
||||
};
|
||||
|
||||
export type ImagePost = {
|
||||
/** Posters only: the film whose picture is shown, and at which frame. */
|
||||
readonly poster?: { readonly video: VideoId; readonly frame: number };
|
||||
readonly copy: Readonly<Record<Lang, ImageCopy>>;
|
||||
};
|
||||
|
||||
export const IMAGE_IDS = [
|
||||
"PosterOrder",
|
||||
"PosterDocuments",
|
||||
"PosterEnquiry",
|
||||
"PosterWebsite",
|
||||
"PosterApp",
|
||||
"PosterDeveloper",
|
||||
"CardNotTechnical",
|
||||
"CardOneContact",
|
||||
"CardOwnership",
|
||||
"CardHours",
|
||||
"CardNotice",
|
||||
"CardNotMyField",
|
||||
] as const;
|
||||
export type ImageId = (typeof IMAGE_IDS)[number];
|
||||
|
||||
const CALL_EN = "A first call takes about 20 minutes.\n\nwww.mintel.me";
|
||||
const CALL_DE = "Ein erstes Gespräch dauert etwa 20 Minuten.\n\nwww.mintel.me";
|
||||
|
||||
export const IMAGES: Readonly<Record<ImageId, ImagePost>> = {
|
||||
PosterOrder: {
|
||||
poster: { video: "AutomationOrder", frame: 285 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: ping-pong",
|
||||
lines: ["Not ping-pong.", "Your office."],
|
||||
post: `Every dot is someone carrying an order from one tool to the next. By hand.\n\nThe tools could pass it on themselves. I connect them.\n\n${CALL_EN}\n\n#automation #processautomation`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Pingpong",
|
||||
lines: ["Kein Pingpong.", "Ihr Büro."],
|
||||
post: `Jeder Punkt ist jemand, der eine Bestellung von einem Tool ins nächste trägt. Von Hand.\n\nDie Tools könnten das unter sich ausmachen. Ich verbinde sie.\n\n${CALL_DE}\n\n#automatisierung #prozessautomatisierung`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterDocuments: {
|
||||
poster: { video: "AutomationDocuments", frame: 300 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: skyscraper",
|
||||
lines: ["Not a skyscraper.", "Your inbox."],
|
||||
post: `Every floor is an invoice waiting to be typed in.\n\nIt can be read automatically. Clear cases are booked directly, unclear ones go to a person.\n\n${CALL_EN}\n\n#automation #accounting`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Hochhaus",
|
||||
lines: ["Kein Hochhaus.", "Ihr Posteingang."],
|
||||
post: `Jedes Stockwerk ist eine Rechnung, die noch jemand abtippen muss.\n\nDas lässt sich automatisch auslesen. Klare Fälle werden direkt gebucht, unklare gehen an einen Menschen.\n\n${CALL_DE}\n\n#automatisierung #buchhaltung`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterEnquiry: {
|
||||
poster: { video: "AutomationEnquiry", frame: 250 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: stars",
|
||||
lines: ["Not a night sky.", "Your enquiries."],
|
||||
post: `The faint ones are the enquiries nobody followed up on.\n\nThe steps can be connected: the contact is created, the right person is told, and a reminder appears when nobody has replied.\n\n${CALL_EN}\n\n#automation #sales`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Sterne",
|
||||
lines: ["Kein Sternenhimmel.", "Ihre Anfragen."],
|
||||
post: `Die blassen sind die Anfragen, bei denen niemand nachgefasst hat.\n\nDie Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn niemand geantwortet hat, erscheint eine Erinnerung.\n\n${CALL_DE}\n\n#automatisierung #vertrieb`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterWebsite: {
|
||||
poster: { video: "WebsiteDone", frame: 260 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: empty page",
|
||||
lines: ["Not a beginning.", "Your website."],
|
||||
post: `The website that has been on the list since last year.\n\nYou tell me what your business does. I design it, build it and put it online. Changes afterwards: you send me an email.\n\n${CALL_EN}\n\n#website #webdesign`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: leere Seite",
|
||||
lines: ["Kein Anfang.", "Ihre Website."],
|
||||
post: `Die Website, die seit letztem Jahr auf der Liste steht.\n\nSie erzählen mir, was Ihr Unternehmen macht. Ich gestalte sie, baue sie und bringe sie online. Änderungen danach: Sie schreiben mir eine E-Mail.\n\n${CALL_DE}\n\n#website #webdesign`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterApp: {
|
||||
poster: { video: "AppRescue", frame: 285 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: Jenga",
|
||||
lines: ["Not Jenga.", "Your app."],
|
||||
post: `It works. But bugs come back, and small changes take longer than they should.\n\nI take a week to go through the code, fix what is most urgent and write down what I would do next. In plain language.\n\n${CALL_EN}\n\n#webdevelopment #softwarequality`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Jenga",
|
||||
lines: ["Kein Jenga.", "Ihre App."],
|
||||
post: `Sie läuft. Aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten.\n\nIch nehme mir eine Woche, gehe den Code durch, behebe das Dringendste und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache.\n\n${CALL_DE}\n\n#webentwicklung #softwarequalität`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterDeveloper: {
|
||||
poster: { video: "ProductDeveloper", frame: 300 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: loading bars",
|
||||
lines: ["Not loading bars.", "Your backlog."],
|
||||
post: `More work than your team can handle.\n\nI join on agreed days of the week and work on what is next on your list. No package to buy, no minimum term.\n\n${CALL_EN}\n\n#webdevelopment #freelance`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Ladebalken",
|
||||
lines: ["Keine Ladebalken.", "Ihr Backlog."],
|
||||
post: `Mehr Arbeit, als Ihr Team schafft.\n\nIch komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes auf Ihrer Liste steht. Kein Paket, keine Mindestlaufzeit.\n\n${CALL_DE}\n\n#webentwicklung #freelancer`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotTechnical: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: not technical",
|
||||
label: "Not a technical person?",
|
||||
lines: ["Doesn't matter.", "You never have", "to read code."],
|
||||
post: `I explain what I am doing in plain language, and you can ask at any time.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: kein Technikmensch",
|
||||
label: "Kein Technikmensch?",
|
||||
lines: ["Macht nichts.", "Code müssen Sie", "nicht lesen."],
|
||||
post: `Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardOneContact: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: one contact",
|
||||
label: "Who does the work?",
|
||||
lines: ["The person", "you talk to."],
|
||||
post: `Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: ein Ansprechpartner",
|
||||
label: "Wer macht die Arbeit?",
|
||||
lines: ["Der, mit dem", "Sie sprechen."],
|
||||
post: `Oft geht eine Anfrage durch mehrere Hände, bevor sie beim Entwickler ankommt. Bei mir gibt es eine direkte Leitung zwischen Ihnen und dem, der die Arbeit macht.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardOwnership: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: ownership",
|
||||
label: "Who owns the code?",
|
||||
lines: ["You do.", "The design too."],
|
||||
post: `Code and design belong to you, and you can hand both to another developer.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Eigentum",
|
||||
label: "Wem gehört der Code?",
|
||||
lines: ["Ihnen.", "Das Design auch."],
|
||||
post: `Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardHours: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: hours",
|
||||
label: "What do you pay for?",
|
||||
lines: ["Hours you", "can check."],
|
||||
post: `I track my time. The monthly invoice shows what the hours were spent on.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Stunden",
|
||||
label: "Wofür zahlen Sie?",
|
||||
lines: ["Für Stunden, die Sie", "nachprüfen können."],
|
||||
post: `Ich erfasse meine Zeit. Die Monatsrechnung zeigt, wofür die Stunden angefallen sind.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotice: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: notice",
|
||||
label: "How long are you committed?",
|
||||
lines: ["Two weeks", "notice."],
|
||||
post: `Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Kündigungsfrist",
|
||||
label: "Wie lange sind Sie gebunden?",
|
||||
lines: ["Zwei Wochen", "Kündigungsfrist."],
|
||||
post: `Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotMyField: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: not my field",
|
||||
label: "What I do not do",
|
||||
lines: ["Mobile apps.", "Windows software.", "Running servers."],
|
||||
post: `If your project needs those, I say so in the first call.\n\nWhat I do: web apps, interfaces, websites, and connecting systems.\n\nwww.mintel.me`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: nicht mein Feld",
|
||||
label: "Was ich nicht mache",
|
||||
lines: ["Mobile Apps.", "Windows-Software.", "Serverbetrieb."],
|
||||
post: `Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.\n\nWas ich mache: Web-Apps, Oberflächen, Websites und das Verbinden von Systemen.\n\nwww.mintel.me`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IMAGES, IMAGE_IDS } from "./images";
|
||||
import { PLAN } from "./plan";
|
||||
import { COPY, LANGS, VIDEO_IDS } from "./videos";
|
||||
|
||||
/** Longer lines get too small to read on a phone. */
|
||||
const MAX_IMAGE_LINE = 28;
|
||||
const MAX_IMAGE_LINES = 3;
|
||||
|
||||
describe("image posts", () => {
|
||||
it.each(IMAGE_IDS)("%s has at most three short lines in every language", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { lines } = IMAGES[id].copy[lang];
|
||||
expect(lines.length, `${id} ${lang}`).toBeGreaterThan(0);
|
||||
expect(lines.length, `${id} ${lang}`).toBeLessThanOrEqual(MAX_IMAGE_LINES);
|
||||
for (const line of lines) expect(line.length, `${id} ${lang}: "${line}"`).toBeLessThanOrEqual(MAX_IMAGE_LINE);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(IMAGE_IDS)("%s has a post that names the website and never an email address", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post } = IMAGES[id].copy[lang];
|
||||
expect(post).toContain("www.mintel.me");
|
||||
expect(post).not.toContain("@");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(IMAGE_IDS)("%s is a poster of an existing film, or a card with a question", (id) => {
|
||||
const image = IMAGES[id];
|
||||
if (image.poster) {
|
||||
expect(VIDEO_IDS).toContain(image.poster.video);
|
||||
expect(image.poster.frame).toBeGreaterThanOrEqual(0);
|
||||
} else {
|
||||
for (const lang of LANGS) expect(image.copy[lang].label, `${id} ${lang}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("video posts", () => {
|
||||
it.each(VIDEO_IDS)("%s opens its post with the hook of the film", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post, slides } = COPY[id][lang];
|
||||
const lastHookWord = (slides.hook ?? []).join(" ").split(" ").pop()?.replace(/[.?!]/g, "") ?? "";
|
||||
expect(post.split("\n")[0], `${id} ${lang}`).toContain(lastHookWord);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("posting plan", () => {
|
||||
const planned = PLAN.flatMap((week) => week.posts);
|
||||
|
||||
it("uses every film and every image exactly once", () => {
|
||||
const videos = planned.filter((post) => post.kind === "video").map((post) => post.id);
|
||||
const images = planned.filter((post) => post.kind === "image").map((post) => post.id);
|
||||
expect([...videos].sort()).toEqual([...VIDEO_IDS].sort());
|
||||
expect([...images].sort()).toEqual([...IMAGE_IDS].sort());
|
||||
});
|
||||
|
||||
it("opens every week with its film", () => {
|
||||
for (const week of PLAN) expect(week.posts[0].kind, week.topic).toBe("video");
|
||||
});
|
||||
|
||||
it("pairs the film of a week with the poster of the same film", () => {
|
||||
for (const week of PLAN) {
|
||||
const film = week.posts[0].id;
|
||||
const posters = week.posts.filter((post) => post.kind === "image" && IMAGES[post.id].poster?.video === film);
|
||||
expect(posters, week.topic).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The posting plan: which film or image goes out in which week, in which order.
|
||||
* The reasoning is in `apps/web/plans/content-strategy.md`. Each week has one topic and three
|
||||
* posts: the film, the poster of the same idea, and a card that answers a question clients ask.
|
||||
*
|
||||
* This file imports only types, so the export script can read it with plain Node.
|
||||
*/
|
||||
import type { ImageId } from "./images.ts";
|
||||
import type { VideoId } from "./videos.ts";
|
||||
|
||||
export type PlannedPost =
|
||||
| { readonly kind: "video"; readonly id: VideoId }
|
||||
| { readonly kind: "image"; readonly id: ImageId };
|
||||
|
||||
export type PlannedWeek = {
|
||||
readonly topic: string;
|
||||
/** In posting order: Tuesday, Thursday, Saturday. */
|
||||
readonly posts: readonly [PlannedPost, PlannedPost, PlannedPost];
|
||||
};
|
||||
|
||||
export const POSTING_DAYS = ["Tuesday", "Thursday", "Saturday"] as const;
|
||||
|
||||
export const PLAN: readonly PlannedWeek[] = [
|
||||
{
|
||||
topic: "Automation: orders",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationOrder" },
|
||||
{ kind: "image", id: "CardNotTechnical" },
|
||||
{ kind: "image", id: "PosterOrder" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Websites",
|
||||
posts: [
|
||||
{ kind: "video", id: "WebsiteDone" },
|
||||
{ kind: "image", id: "CardOneContact" },
|
||||
{ kind: "image", id: "PosterWebsite" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Automation: invoices",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationDocuments" },
|
||||
{ kind: "image", id: "CardHours" },
|
||||
{ kind: "image", id: "PosterDocuments" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Reliable apps",
|
||||
posts: [
|
||||
{ kind: "video", id: "AppRescue" },
|
||||
{ kind: "image", id: "CardOwnership" },
|
||||
{ kind: "image", id: "PosterApp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Automation: enquiries",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationEnquiry" },
|
||||
{ kind: "image", id: "CardNotMyField" },
|
||||
{ kind: "image", id: "PosterEnquiry" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "A developer for your product",
|
||||
posts: [
|
||||
{ kind: "video", id: "ProductDeveloper" },
|
||||
{ kind: "image", id: "CardNotice" },
|
||||
{ kind: "image", id: "PosterDeveloper" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COPY, LANGS, VIDEO_IDS, translate } from "./videos";
|
||||
|
||||
const MAX_LINES = 2;
|
||||
/** Longer lines get too small to read on a phone. */
|
||||
const MAX_LINE_LENGTH = 24;
|
||||
|
||||
describe("video copy", () => {
|
||||
it.each(VIDEO_IDS)("%s has the same slides in every language", (id) => {
|
||||
const [first, ...others] = LANGS.map((lang) => Object.keys(COPY[id][lang].slides).sort());
|
||||
for (const slides of others) expect(slides).toEqual(first);
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s keeps every headline to two short lines", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
for (const [slide, lines] of Object.entries(COPY[id][lang].slides)) {
|
||||
expect(lines.length, `${id} ${lang} ${slide}`).toBeGreaterThan(0);
|
||||
expect(lines.length, `${id} ${lang} ${slide}`).toBeLessThanOrEqual(MAX_LINES);
|
||||
for (const line of lines) {
|
||||
expect(line.length, `${id} ${lang} ${slide}: "${line}"`).toBeLessThanOrEqual(MAX_LINE_LENGTH);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s ends with a call to action slide", (id) => {
|
||||
for (const lang of LANGS) expect(COPY[id][lang].slides).toHaveProperty("cta");
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s has a post that names the website and never an email address", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post } = COPY[id][lang];
|
||||
expect(post).toContain("www.mintel.me");
|
||||
expect(post).not.toContain("@");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("translate", () => {
|
||||
it("leaves English labels as they are", () => {
|
||||
expect(translate("en", "Invoice")).toBe("Invoice");
|
||||
});
|
||||
|
||||
it("returns the German label when there is one", () => {
|
||||
expect(translate("de", "Invoice")).toBe("Rechnung");
|
||||
});
|
||||
|
||||
it("falls back to the given text when there is no German label", () => {
|
||||
expect(translate("de", "Shop")).toBe("Shop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Everything that is said in and about the videos: headlines per slide, labels inside the drawings,
|
||||
* and the text to post with each video. English and German.
|
||||
*
|
||||
* This file imports nothing, so the export script can read it with plain Node.
|
||||
* German addresses the viewer formally ("Sie"), as is usual towards business owners.
|
||||
*/
|
||||
|
||||
export const LANGS = ["en", "de"] as const;
|
||||
export type Lang = (typeof LANGS)[number];
|
||||
|
||||
export const VIDEO_IDS = [
|
||||
"AutomationOrder",
|
||||
"AutomationDocuments",
|
||||
"AutomationEnquiry",
|
||||
"WebsiteDone",
|
||||
"AppRescue",
|
||||
"ProductDeveloper",
|
||||
] as const;
|
||||
export type VideoId = (typeof VIDEO_IDS)[number];
|
||||
|
||||
export type VideoCopy = {
|
||||
/** What the video is about, for the overview page. */
|
||||
readonly title: string;
|
||||
/** Headline per slide id: at most two lines of a few words. */
|
||||
readonly slides: Readonly<Record<string, readonly string[]>>;
|
||||
/** Text to post with the video on LinkedIn. */
|
||||
readonly post: string;
|
||||
};
|
||||
|
||||
/** German for every label that appears inside a drawing or on the frame. Missing entries stay English. */
|
||||
export const LABELS_DE: Readonly<Record<string, string>> = {
|
||||
"Freelance developer": "Freelance-Entwickler",
|
||||
"Get in touch": "Kontakt aufnehmen",
|
||||
Invoice: "Rechnung",
|
||||
Customers: "Kunden",
|
||||
Email: "E-Mail",
|
||||
"One person": "Eine Person",
|
||||
Inbox: "Posteingang",
|
||||
Read: "Lesen",
|
||||
Check: "Prüfen",
|
||||
Books: "Buchhaltung",
|
||||
"Invoice.pdf": "Rechnung.pdf",
|
||||
Accounting: "Buchhaltung",
|
||||
Supplier: "Lieferant",
|
||||
Amount: "Betrag",
|
||||
Date: "Datum",
|
||||
"A person": "Ein Mensch",
|
||||
Contacts: "Kontakte",
|
||||
Reminder: "Erinnerung",
|
||||
"Day 1": "Tag 1",
|
||||
"Day 2": "Tag 2",
|
||||
"Day 3": "Tag 3",
|
||||
"Day 4": "Tag 4",
|
||||
"Website builder": "Baukasten",
|
||||
"Your website": "Ihre Website",
|
||||
"Your email": "Ihre E-Mail",
|
||||
"www.your-name.com": "www.ihr-name.de",
|
||||
"Your app": "Ihre App",
|
||||
Mon: "Mo",
|
||||
Tue: "Di",
|
||||
Wed: "Mi",
|
||||
Thu: "Do",
|
||||
Fri: "Fr",
|
||||
"To do": "Offen",
|
||||
Done: "Erledigt",
|
||||
Product: "Produkt",
|
||||
Developer: "Entwicklung",
|
||||
};
|
||||
|
||||
export const translate = (lang: Lang, text: string): string =>
|
||||
lang === "de" ? (LABELS_DE[text] ?? text) : text;
|
||||
|
||||
export const COPY: Readonly<Record<VideoId, Readonly<Record<Lang, VideoCopy>>>> = {
|
||||
AutomationOrder: {
|
||||
en: {
|
||||
title: "Automation: from order to invoice",
|
||||
slides: {
|
||||
hook: ["Looks like", "ping-pong."],
|
||||
reveal: ["It's your", "office."],
|
||||
problem: ["Every order.", "By hand."],
|
||||
turn: ["Or your tools", "talk to each other."],
|
||||
calm: ["And your team", "has time."],
|
||||
cta: ["Done with", "ping-pong?"],
|
||||
},
|
||||
post: `Looks like ping-pong. It's your office.
|
||||
|
||||
An order comes in. Someone opens it in the shop, writes the invoice, updates the customer record, sends the confirmation. Then the next one. And the next.
|
||||
|
||||
Works beautifully, as long as hardly anyone orders.
|
||||
|
||||
The four tools can sort this out among themselves: the order triggers invoice, customer record and email on its own. Your team sees the finished result and looks after the cases that really need a person.
|
||||
|
||||
I connect the tools you already use. A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #processautomation #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: von der Bestellung zur Rechnung",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "Pingpong."],
|
||||
reveal: ["Ist aber", "Ihr Büro."],
|
||||
problem: ["Jede Bestellung.", "Von Hand."],
|
||||
turn: ["Oder Ihre Tools", "reden miteinander."],
|
||||
calm: ["Und Ihr Team", "hat Zeit."],
|
||||
cta: ["Schluss mit", "Pingpong?"],
|
||||
},
|
||||
post: `Sieht aus wie Pingpong. Ist aber Ihr Büro.
|
||||
|
||||
Eine Bestellung kommt rein. Jemand öffnet sie im Shop, schreibt die Rechnung, pflegt den Kunden nach, verschickt die Bestätigung. Dann die nächste. Und die nächste.
|
||||
|
||||
Klappt wunderbar, solange kaum jemand bestellt.
|
||||
|
||||
Dabei können die vier Tools das längst unter sich ausmachen: Die Bestellung löst Rechnung, Kundendatensatz und E-Mail selbst aus. Ihr Team sieht das fertige Ergebnis und kümmert sich um die Fälle, die wirklich einen Menschen brauchen.
|
||||
|
||||
Ich verbinde die Tools, die Sie schon haben. Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #prozessautomatisierung #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AutomationDocuments: {
|
||||
en: {
|
||||
title: "Automation: incoming invoices",
|
||||
slides: {
|
||||
hook: ["Looks like", "a skyscraper."],
|
||||
reveal: ["It's your", "inbox."],
|
||||
problem: ["Every invoice.", "Typed in."],
|
||||
turn: ["Or it simply", "gets read."],
|
||||
calm: ["Unclear ones go", "to a person."],
|
||||
cta: ["Done typing", "things in?"],
|
||||
},
|
||||
post: `Looks like a skyscraper. It's your inbox.
|
||||
|
||||
Invoices arrive as PDF. Someone opens each one, reads supplier, amount and date, and types them into the accounting system. Floor by floor.
|
||||
|
||||
Nobody has to do that by hand. The document can be read automatically, a fixed rule checks whether the values make sense, and clear cases are booked directly. Anything unclear goes to a person, with the document attached.
|
||||
|
||||
So people decide the exceptions instead of typing the routine.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #accounting #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: Eingangsrechnungen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Hochhaus."],
|
||||
reveal: ["Ist aber Ihr", "Posteingang."],
|
||||
problem: ["Jede Rechnung.", "Abgetippt."],
|
||||
turn: ["Oder sie wird", "einfach gelesen."],
|
||||
calm: ["Unklares geht", "an einen Menschen."],
|
||||
cta: ["Schluss mit", "Abtippen?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Hochhaus. Ist aber Ihr Posteingang.
|
||||
|
||||
Rechnungen kommen als PDF. Jemand öffnet jede einzelne, liest Lieferant, Betrag und Datum ab und tippt alles in die Buchhaltung. Stockwerk für Stockwerk.
|
||||
|
||||
Das muss niemand von Hand machen. Das Dokument lässt sich automatisch auslesen, eine feste Regel prüft, ob die Werte plausibel sind, und klare Fälle werden direkt gebucht. Alles Unklare geht an einen Menschen, mit dem Dokument im Anhang.
|
||||
|
||||
So entscheiden Menschen die Ausnahmen, statt Routine abzutippen.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #buchhaltung #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AutomationEnquiry: {
|
||||
en: {
|
||||
title: "Automation: from enquiry to follow-up",
|
||||
slides: {
|
||||
hook: ["Looks like", "a star."],
|
||||
reveal: ["These are your", "enquiries."],
|
||||
problem: ["They burn out.", "Unanswered."],
|
||||
turn: ["Or each one reaches", "the right person."],
|
||||
calm: ["And follow-ups", "happen by themselves."],
|
||||
cta: ["Stop losing", "enquiries?"],
|
||||
},
|
||||
post: `Looks like a star. It's an enquiry, burning out.
|
||||
|
||||
Someone fills in your contact form. The enquiry is copied into the customer list by hand, forwarded by email, and everyone hopes the follow-up is not forgotten.
|
||||
|
||||
Hope is not a process.
|
||||
|
||||
These steps can be connected: the contact is created, the right person is told, and if nobody has replied after three days, a reminder appears.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #sales #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: von der Anfrage zum Nachfassen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Stern."],
|
||||
reveal: ["Sind aber Ihre", "Anfragen."],
|
||||
problem: ["Sie verglühen.", "Unbeantwortet."],
|
||||
turn: ["Oder jede landet", "beim Richtigen."],
|
||||
calm: ["Und Nachfassen", "passiert von selbst."],
|
||||
cta: ["Keine Anfrage", "mehr verlieren?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Stern. Ist aber eine Anfrage, die gerade verglüht.
|
||||
|
||||
Jemand füllt Ihr Kontaktformular aus. Die Anfrage wird von Hand in die Kundenliste kopiert, per E-Mail weitergeleitet, und alle hoffen, dass das Nachfassen nicht vergessen wird.
|
||||
|
||||
Hoffen ist kein Prozess.
|
||||
|
||||
Diese Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn nach drei Tagen niemand geantwortet hat, erscheint eine Erinnerung.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #vertrieb #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
WebsiteDone: {
|
||||
en: {
|
||||
title: "A website, built and looked after",
|
||||
slides: {
|
||||
hook: ["Looks like", "a beginning."],
|
||||
reveal: ["It's been your", "website for months."],
|
||||
problem: ["Empty.", "Still."],
|
||||
turn: ["You talk.", "I build."],
|
||||
calm: ["Fits every", "screen."],
|
||||
cta: ["Website", "overdue?"],
|
||||
},
|
||||
post: `Looks like a beginning. It's been your website for months.
|
||||
|
||||
You run a business. Learning a website builder on top of that was never going to happen.
|
||||
|
||||
This is how I do it instead: you tell me what your business does and what the website is for. I design it, build it and put it online. It works on phone, tablet and desktop, and you approve it before it goes live.
|
||||
|
||||
And afterwards? A new photo or a changed price: you send me an email.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#website #webdesign #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Eine Website, gebaut und betreut",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Anfang."],
|
||||
reveal: ["Ist seit Monaten", "Ihre Website."],
|
||||
problem: ["Leer.", "Immer noch."],
|
||||
turn: ["Sie erzählen.", "Ich baue."],
|
||||
calm: ["Passt auf", "jeden Bildschirm."],
|
||||
cta: ["Website", "fällig?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Anfang. Ist seit Monaten Ihre Website.
|
||||
|
||||
Sie führen ein Unternehmen. Dass Sie nebenbei noch einen Website-Baukasten lernen, war nie realistisch.
|
||||
|
||||
So mache ich es stattdessen: Sie erzählen mir, was Ihr Unternehmen macht und wofür die Website da ist. Ich gestalte sie, baue sie und bringe sie online. Sie funktioniert auf Handy, Tablet und Desktop, und Sie geben sie frei, bevor sie live geht.
|
||||
|
||||
Und danach? Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#website #webdesign #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AppRescue: {
|
||||
en: {
|
||||
title: "Making an app reliable",
|
||||
slides: {
|
||||
hook: ["Looks like", "Jenga."],
|
||||
reveal: ["It's your", "app."],
|
||||
problem: ["Every change", "wobbles."],
|
||||
turn: ["One week.", "I look inside."],
|
||||
calm: ["After that", "it stands."],
|
||||
cta: ["App", "wobbling?"],
|
||||
},
|
||||
post: `Looks like Jenga. It's your app.
|
||||
|
||||
It works. But bugs come back, and every small change feels like pulling out a block.
|
||||
|
||||
That usually happens when an app was built quickly and then kept growing.
|
||||
|
||||
I take a week to go through it: I look at what is solid and what is fragile, fix the most urgent problems as far as a week allows, and write down what I would do next. In plain language, so you can follow it without being a developer.
|
||||
|
||||
After that you decide how to continue: with me, with your own team, or not at all.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webdevelopment #softwarequality #startups`,
|
||||
},
|
||||
de: {
|
||||
title: "Eine App zuverlässig machen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "Jenga."],
|
||||
reveal: ["Ist aber", "Ihre App."],
|
||||
problem: ["Jede Änderung", "wackelt."],
|
||||
turn: ["Eine Woche.", "Ich sehe hinein."],
|
||||
calm: ["Danach", "steht sie."],
|
||||
cta: ["App", "wackelt?"],
|
||||
},
|
||||
post: `Sieht aus wie Jenga. Ist aber Ihre App.
|
||||
|
||||
Sie läuft. Aber Fehler kommen wieder, und jede kleine Änderung fühlt sich an, als zöge man einen Stein heraus.
|
||||
|
||||
Das passiert meist, wenn eine App schnell gebaut wurde und dann immer weiter gewachsen ist.
|
||||
|
||||
Ich nehme mir eine Woche dafür: Ich sehe mir an, was solide ist und was wackelt, behebe die dringendsten Probleme, soweit eine Woche reicht, und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache, die Sie auch ohne Entwicklerwissen verstehen.
|
||||
|
||||
Danach entscheiden Sie, wie es weitergeht: mit mir, mit Ihrem eigenen Team oder gar nicht.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webentwicklung #softwarequalität #startups`,
|
||||
},
|
||||
},
|
||||
|
||||
ProductDeveloper: {
|
||||
en: {
|
||||
title: "A developer for your product",
|
||||
slides: {
|
||||
hook: ["Looks like", "a loading bar."],
|
||||
reveal: ["It's your", "backlog."],
|
||||
problem: ["Growing faster", "than your team."],
|
||||
turn: ["One more", "developer."],
|
||||
calm: ["Fixed days.", "No minimum term."],
|
||||
cta: ["Backlog", "too long?"],
|
||||
},
|
||||
post: `Looks like a loading bar. It's your backlog.
|
||||
|
||||
You have a product, and the list grows faster than your team gets through it.
|
||||
|
||||
I join on agreed days of the week and work on what is next: new features, the interface, or the parts nobody gets around to.
|
||||
|
||||
Finished parts go to you for review as they are done. I track my hours, and you can see what they were spent on. No package to buy, no minimum term: either side can end it with two weeks notice to the end of a month.
|
||||
|
||||
15 years in web development, in agencies and product teams.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webdevelopment #freelance #react`,
|
||||
},
|
||||
de: {
|
||||
title: "Ein Entwickler für Ihr Produkt",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Ladebalken."],
|
||||
reveal: ["Ist aber", "Ihr Backlog."],
|
||||
problem: ["Wächst schneller", "als Ihr Team."],
|
||||
turn: ["Ein Entwickler", "mehr."],
|
||||
calm: ["Feste Tage.", "Keine Mindestlaufzeit."],
|
||||
cta: ["Backlog", "zu lang?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Ladebalken. Ist aber Ihr Backlog.
|
||||
|
||||
Sie haben ein Produkt, und die Liste wächst schneller, als Ihr Team sie abarbeitet.
|
||||
|
||||
Ich komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes ansteht: neue Funktionen, die Oberfläche oder die Teile, zu denen niemand kommt.
|
||||
|
||||
Fertige Teile gehen zur Durchsicht an Sie, sobald sie fertig sind. Ich erfasse meine Stunden, und Sie sehen, wofür sie verwendet wurden. Kein Paket, keine Mindestlaufzeit: Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.
|
||||
|
||||
15 Jahre Webentwicklung, in Agenturen und Produktteams.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webentwicklung #freelancer #react`,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a block wobbles on another: Jenga. The camera pulls back: it is a whole leaning tower,
|
||||
* the app, swaying with every change. It stops, a light rises through it, the blocks line up one
|
||||
* by one, and two rails keep it straight.
|
||||
*/
|
||||
const BLOCK = { width: 230, height: 44, pitch: 52, base: 290 } as const;
|
||||
/** How far each block sits off centre while the tower is crooked. */
|
||||
const OFFSETS = [0, 14, -10, 22, -18, 26, -8, 30, -22] as const;
|
||||
const TOP = OFFSETS.length - 1;
|
||||
const SWAY_FRAMES = 46;
|
||||
const SWAY_DEGREES = 2.2;
|
||||
const ALIGN_EVERY = 8;
|
||||
const RAIL_GAP = 42;
|
||||
|
||||
const blockY = (index: number): number => BLOCK.base - BLOCK.height / 2 - index * BLOCK.pitch;
|
||||
const alignStart = (index: number): number => T.resolve + index * ALIGN_EVERY;
|
||||
const LAST_ALIGN = alignStart(TOP) + 22;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const AppDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 2.4, 1, { x: 0, y: blockY(TOP) + 30 });
|
||||
const { zoom, hair, others } = camera;
|
||||
const folded = frame >= T.fold;
|
||||
/** How much the tower moves: it stops at the freeze and starts again for the loop. */
|
||||
const restless = folded ? progress(frame, T.fold + 16, SHOT_FRAMES) : 1 - progress(frame, T.freeze, T.turn);
|
||||
const wave = Math.sin((frame / SWAY_FRAMES) * Math.PI * 2);
|
||||
const crooked = (index: number): number =>
|
||||
folded ? inOut(progress(frame, T.fold, T.fold + 40)) : 1 - inOut(progress(frame, alignStart(index), alignStart(index) + 22));
|
||||
const scan = progress(frame, T.turn, LAST_ALIGN);
|
||||
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_ALIGN - 10, LAST_ALIGN));
|
||||
const rails = out(progress(frame, T.calm, T.calm + 40)) * untilFold(frame);
|
||||
const top = blockY(TOP) - BLOCK.height / 2 - 20;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
<path d={`M-280 ${BLOCK.base}h560`} stroke={DRAW.fg} strokeOpacity={0.5 * others} strokeWidth={hair} strokeLinecap="round" />
|
||||
|
||||
{[-1, 1].map((side) => (
|
||||
<path
|
||||
key={side}
|
||||
d={`M${side * (BLOCK.width / 2 + RAIL_GAP)} ${BLOCK.base}V${mix(BLOCK.base, top, rails)}`}
|
||||
stroke={COLOR.done}
|
||||
strokeWidth={hair * 1.6}
|
||||
strokeLinecap="round"
|
||||
opacity={rails > 0 ? 1 : 0}
|
||||
style={glow(COLOR.done, 10, zoom)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<g transform={`rotate(${wave * SWAY_DEGREES * restless} 0 ${BLOCK.base})`}>
|
||||
{OFFSETS.map((offset, index) => {
|
||||
const off = crooked(index);
|
||||
// The top block wobbles on its own: that is the opening picture.
|
||||
const wobble = index === TOP ? Math.sin((frame / (SWAY_FRAMES / 2)) * Math.PI * 2) * 3.5 * restless : 0;
|
||||
const justAligned = folded ? 0 : Math.sin(Math.PI * progress(frame, alignStart(index), alignStart(index) + 30));
|
||||
const y = blockY(index);
|
||||
return (
|
||||
<g
|
||||
key={index}
|
||||
opacity={index >= TOP - 1 ? 1 : others}
|
||||
transform={`translate(${offset * off} 0) rotate(${wobble} ${-BLOCK.width / 2} ${y + BLOCK.height / 2})`}
|
||||
>
|
||||
<rect x={-BLOCK.width / 2} y={y - BLOCK.height / 2} width={BLOCK.width} height={BLOCK.height} rx={13} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={-BLOCK.width / 2}
|
||||
y={y - BLOCK.height / 2}
|
||||
width={BLOCK.width}
|
||||
height={BLOCK.height}
|
||||
rx={13}
|
||||
fill={`rgba(248, 250, 252, ${0.1 + justAligned * 0.25})`}
|
||||
stroke={justAligned > 0.1 ? DRAW.mark : DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity + justAligned * 0.25}
|
||||
strokeWidth={hair * (1 + justAligned * 0.6)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{scanShown > 0 && (
|
||||
<path
|
||||
d={`M-190 ${mix(BLOCK.base, top, scan)}h380`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.4}
|
||||
strokeLinecap="round"
|
||||
opacity={scanShown}
|
||||
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Making an app reliable: from a leaning tower that sways with every change to one that stands. */
|
||||
export const AppFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<AppDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, glow } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a bar fills slowly: a loading bar. The camera pulls back: it is the first of a long
|
||||
* list, the backlog, and new entries arrive faster than the team gets through them. Then one more
|
||||
* developer joins, and the list is worked off, entry after entry.
|
||||
*/
|
||||
const ROW = { x: -215, width: 470, height: 26, pitch: 44, top: -270 } as const;
|
||||
const MAX_ROWS = 13;
|
||||
const OPENING_ROWS = 4;
|
||||
const ROW_EVERY = 26;
|
||||
const TEAM_SIZE = 3;
|
||||
const WORKER_X = ROW.x - 42;
|
||||
const DONE_EVERY = 13;
|
||||
const OPENING_FILL = 0.15;
|
||||
|
||||
const rowY = (index: number): number => ROW.top + index * ROW.pitch;
|
||||
const doneStart = (index: number): number => T.resolve + index * DONE_EVERY;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const DeveloperDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 1.7, 1, { x: 0, y: rowY(0) });
|
||||
const { zoom, hair, others } = camera;
|
||||
const folded = frame >= T.fold;
|
||||
const rows = folded ? 1 : Math.min(MAX_ROWS, OPENING_ROWS + Math.max(0, frame - T.reveal) / ROW_EVERY);
|
||||
const dot = 12 / Math.sqrt(zoom);
|
||||
const joined = out(progress(frame, T.turn, T.turn + 28));
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{Array.from({ length: MAX_ROWS }, (_, index) => {
|
||||
if (index >= rows) return null;
|
||||
const y = rowY(index);
|
||||
const arrived = Math.min(1, rows - index);
|
||||
const worked = index === 0 ? mix(OPENING_FILL, 0.6, progress(frame, 0, T.turn)) : index < TEAM_SIZE ? mix(0, 0.4, progress(frame, T.reveal, T.turn)) : 0;
|
||||
const finishing = folded ? 0 : out(progress(frame, doneStart(index), doneStart(index) + 16));
|
||||
const leaving = folded ? 0 : progress(frame, doneStart(index) + 18, doneStart(index) + 32);
|
||||
const fill = folded ? OPENING_FILL : mix(worked, 1, finishing);
|
||||
const shown = folded ? progress(frame, T.fold + 16, T.fold + 44) : arrived * (1 - leaving) * (index === 0 ? 1 : others);
|
||||
if (shown === 0) return null;
|
||||
return (
|
||||
<g key={index} opacity={shown} transform={`translate(${-36 * leaving} ${(1 - out(arrived)) * 26})`}>
|
||||
<rect
|
||||
x={ROW.x}
|
||||
y={y - ROW.height / 2}
|
||||
width={ROW.width}
|
||||
height={ROW.height}
|
||||
rx={ROW.height / 2}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={0.5}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
{fill > 0 && (
|
||||
<rect
|
||||
x={ROW.x}
|
||||
y={y - ROW.height / 2}
|
||||
width={Math.max(ROW.height, ROW.width * fill)}
|
||||
height={ROW.height}
|
||||
rx={ROW.height / 2}
|
||||
fill={finishing === 1 ? COLOR.done : DRAW.fg}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{Array.from({ length: TEAM_SIZE }, (_, index) => (
|
||||
<circle key={index} cx={WORKER_X} cy={rowY(index)} r={dot} fill={DRAW.fg} opacity={index === 0 ? 1 : others} />
|
||||
))}
|
||||
<circle
|
||||
cx={mix(WORKER_X - 150, WORKER_X, joined)}
|
||||
cy={rowY(TEAM_SIZE)}
|
||||
r={dot}
|
||||
fill={DRAW.mark}
|
||||
opacity={joined * others}
|
||||
style={glow(DRAW.mark, 14, zoom)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** A developer for a product: from a backlog that grows faster than the team to a list worked off. */
|
||||
export const DeveloperFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<DeveloperDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, bars drop onto a stack: a skyscraper going up. The camera pulls back: it is the pile of
|
||||
* invoices waiting to be typed in, and it keeps growing. Then a light runs down the pile and the
|
||||
* sheets leave by themselves: most into the books, the few unclear ones to a person.
|
||||
*/
|
||||
const BAR = { x: -170, width: 210, height: 16, pitch: 25, base: 290 } as const;
|
||||
const MAX_BARS = 24;
|
||||
const BOOKS = { x: 250, y: -70, size: 130 } as const;
|
||||
const PERSON = { x: 250, y: 170, r: 44 } as const;
|
||||
const OPENING_BARS = 3;
|
||||
const CLOSE_EVERY = 25;
|
||||
/** The film starts with a bar in mid-fall: the first frame is the preview image. */
|
||||
const CLOSE_PHASE = 12;
|
||||
const WIDE_EVERY = 9;
|
||||
const LEAVE_EVERY = 5;
|
||||
const LEAVE_FRAMES = 24;
|
||||
/** Every sixth sheet is unclear and goes to a person. */
|
||||
const isUnclear = (index: number): boolean => index % 6 === 2;
|
||||
|
||||
const barY = (index: number): number => BAR.base - BAR.height / 2 - index * BAR.pitch;
|
||||
|
||||
/** How many bars have arrived, as a number with a fraction: the fraction is the one still falling. */
|
||||
const barsAt = (frame: number): number => {
|
||||
if (frame >= T.fold) return OPENING_BARS + (frame - SHOT_FRAMES + CLOSE_PHASE) / CLOSE_EVERY;
|
||||
const close = OPENING_BARS + (Math.min(frame, T.reveal) + CLOSE_PHASE) / CLOSE_EVERY;
|
||||
return Math.min(MAX_BARS, close + Math.max(0, frame - T.reveal) / WIDE_EVERY);
|
||||
};
|
||||
|
||||
const leaveStart = (index: number): number => T.resolve + (MAX_BARS - 1 - index) * LEAVE_EVERY;
|
||||
const LAST_LEAVE = T.resolve + MAX_BARS * LEAVE_EVERY;
|
||||
const UNCLEAR = Array.from({ length: MAX_BARS }, (_, index) => index).filter(isUnclear);
|
||||
|
||||
/** Where an unclear sheet waits next to the person. */
|
||||
const waitingSpot = (index: number): { x: number; y: number } => {
|
||||
const angle = UNCLEAR.indexOf(index) * 0.62 + 2.2;
|
||||
return { x: PERSON.x + Math.cos(angle) * 78, y: PERSON.y + Math.sin(angle) * 78 };
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const DocumentsDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 2.6, 0.95, { x: BAR.x, y: barY(OPENING_BARS) - 20 });
|
||||
const { zoom, hair, others } = camera;
|
||||
const bars = barsAt(frame);
|
||||
const leaving = frame < T.fold;
|
||||
const scan = progress(frame, T.turn, LAST_LEAVE);
|
||||
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_LEAVE - 10, LAST_LEAVE));
|
||||
const dot = 11 / Math.sqrt(zoom);
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
<path d={`M${BAR.x - 170} ${BAR.base}h340`} stroke={DRAW.fg} strokeOpacity={0.5} strokeWidth={hair} strokeLinecap="round" />
|
||||
|
||||
<g opacity={others}>
|
||||
<rect
|
||||
x={BOOKS.x - BOOKS.size / 2}
|
||||
y={BOOKS.y - BOOKS.size / 2}
|
||||
width={BOOKS.size}
|
||||
height={BOOKS.size}
|
||||
rx={BOOKS.size * 0.24}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
<circle cx={PERSON.x} cy={PERSON.y} r={PERSON.r} fill={SURFACE.fill} stroke={DRAW.fg} strokeOpacity={SURFACE.strokeOpacity} strokeWidth={hair} />
|
||||
</g>
|
||||
|
||||
{Array.from({ length: MAX_BARS }, (_, index) => {
|
||||
if (index >= bars) return null;
|
||||
const arrived = Math.min(1, bars - index);
|
||||
const fadeIn = leaving ? 1 : progress(frame, T.fold + 8, T.fold + 30);
|
||||
const gone = leaving ? inOut(progress(frame, leaveStart(index), leaveStart(index) + LEAVE_FRAMES)) : 0;
|
||||
const unclear = isUnclear(index);
|
||||
const target = unclear ? waitingSpot(index) : BOOKS;
|
||||
const width = mix(BAR.width, dot * 2, gone);
|
||||
const height = mix(BAR.height, dot * 2, gone);
|
||||
const x = mix(BAR.x, target.x, gone);
|
||||
const y = mix(mix(barY(index) - 150 / zoom, barY(index), out(arrived)), target.y, gone) - Math.sin(Math.PI * gone) * 70;
|
||||
const opacity = arrived * fadeIn * (unclear ? 1 : 1 - progress(gone, 0.82, 1));
|
||||
if (opacity === 0) return null;
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={x - width / 2}
|
||||
y={y - height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={height / 2}
|
||||
fill={unclear && gone > 0 ? DRAW.mark : DRAW.fg}
|
||||
opacity={opacity * (gone > 0 ? 1 : 0.9)}
|
||||
style={unclear && gone > 0.5 ? glow(DRAW.mark, 10, zoom) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!leaving &&
|
||||
UNCLEAR.map((index) => {
|
||||
const spot = waitingSpot(index);
|
||||
return <circle key={index} cx={spot.x} cy={spot.y} r={dot} fill={DRAW.mark} opacity={untilFold(frame) * others} />;
|
||||
})}
|
||||
|
||||
{scanShown > 0 && (
|
||||
<path
|
||||
d={`M${BAR.x - 150} ${mix(barY(MAX_BARS - 1), BAR.base, scan)}h300`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.4}
|
||||
strokeLinecap="round"
|
||||
opacity={scanShown}
|
||||
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Incoming invoices: from a growing pile to documents that are read and booked by themselves. */
|
||||
export const DocumentsFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<DocumentsDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { mix, out, progress } from "../illustrations/motion";
|
||||
import type { Point } from "../lib/hop";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, one point of light: a star. The camera pulls back: a whole sky of them, each one an
|
||||
* enquiry, and one after the other they burn out unanswered. Then a line reaches every one of them
|
||||
* from the team in the middle, they light up again, and a quiet ring keeps reminding.
|
||||
*/
|
||||
const TEAM = { x: 0, y: 10, size: 120 } as const;
|
||||
/** The first one is the star of the opening picture. */
|
||||
const STARS: readonly Point[] = [
|
||||
{ x: -190, y: -90 },
|
||||
{ x: -380, y: -210 },
|
||||
{ x: -60, y: -260 },
|
||||
{ x: 210, y: -220 },
|
||||
{ x: 400, y: -130 },
|
||||
{ x: 320, y: 50 },
|
||||
{ x: 420, y: 230 },
|
||||
{ x: 170, y: 250 },
|
||||
{ x: -90, y: 230 },
|
||||
{ x: -300, y: 250 },
|
||||
{ x: -430, y: 50 },
|
||||
{ x: -250, y: 110 },
|
||||
{ x: 150, y: -90 },
|
||||
];
|
||||
const HERO: Point = STARS[0] ?? { x: 0, y: 0 };
|
||||
const TWINKLE_FRAMES = 23;
|
||||
const RING_FRAMES = 60;
|
||||
|
||||
const lineStart = (index: number): number => T.resolve + index * 6;
|
||||
|
||||
/** How brightly an enquiry still shines: it fades while nobody answers and returns once it is reached. */
|
||||
const brightness = (index: number, frame: number): number => {
|
||||
if (frame >= T.fold) return 1;
|
||||
const fadeFrom = index === 0 ? 0 : 60 + index * 17;
|
||||
const faded = progress(frame, fadeFrom, fadeFrom + (index === 0 ? 170 : 90));
|
||||
const reached = out(progress(frame, lineStart(index) + 18, lineStart(index) + 40));
|
||||
return mix(1 - 0.88 * faded, 1, reached);
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const EnquiryDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 3, 0.95, HERO);
|
||||
const { zoom, hair, others } = camera;
|
||||
const radius = 11 / Math.sqrt(zoom);
|
||||
const sinceCalm = frame - T.calm;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{STARS.map((star, index) => {
|
||||
const drawn = out(progress(frame, lineStart(index), lineStart(index) + 30)) * untilFold(frame);
|
||||
return drawn === 0 ? null : (
|
||||
<path
|
||||
key={index}
|
||||
d={`M${TEAM.x} ${TEAM.y}L${mix(TEAM.x, star.x, drawn)} ${mix(TEAM.y, star.y, drawn)}`}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={0.6}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{sinceCalm > 0 &&
|
||||
[0, RING_FRAMES / 2].map((delay) => {
|
||||
const t = ((sinceCalm + delay) % RING_FRAMES) / RING_FRAMES;
|
||||
return (
|
||||
<circle
|
||||
key={delay}
|
||||
cx={TEAM.x}
|
||||
cy={TEAM.y}
|
||||
r={mix(TEAM.size * 0.6, TEAM.size * 1.5, t)}
|
||||
fill="none"
|
||||
stroke={`rgba(${DRAW.light}, 1)`}
|
||||
strokeWidth={hair}
|
||||
opacity={(1 - t) * 0.7 * untilFold(frame) * progress(sinceCalm, 0, 20)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<g opacity={others}>
|
||||
<rect x={TEAM.x - TEAM.size / 2} y={TEAM.y - TEAM.size / 2} width={TEAM.size} height={TEAM.size} rx={TEAM.size * 0.24} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={TEAM.x - TEAM.size / 2}
|
||||
y={TEAM.y - TEAM.size / 2}
|
||||
width={TEAM.size}
|
||||
height={TEAM.size}
|
||||
rx={TEAM.size * 0.24}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
</g>
|
||||
|
||||
{STARS.map((star, index) => {
|
||||
const twinkle = 0.92 + 0.08 * Math.sin((frame / TWINKLE_FRAMES) * Math.PI * 2 + index);
|
||||
const shine = brightness(index, frame) * twinkle;
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={star.x}
|
||||
cy={star.y}
|
||||
r={radius}
|
||||
fill={DRAW.mark}
|
||||
opacity={shine * (index === 0 ? 1 : others)}
|
||||
style={glow(DRAW.mark, 16 * shine, zoom)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Enquiries: from stars that burn out unanswered to every one reaching the right person. */
|
||||
export const EnquiryFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<EnquiryDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Easing, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { BrandButton } from "../brand/BrandButton";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { useCopy } from "../content/CopyContext";
|
||||
import { headlineSize } from "../slides/SlideDeck";
|
||||
|
||||
/** Films are drawn in this coordinate system, with (0, 0) in the centre; the stage is scaled to fit. */
|
||||
export const STAGE = { width: 1000, height: 700 } as const;
|
||||
|
||||
/** One statement: which text (key in the copy) and while which frames. */
|
||||
export type Caption = {
|
||||
readonly id: string;
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
/** Show the website's button below this statement. */
|
||||
readonly button?: boolean;
|
||||
};
|
||||
|
||||
export const SIDE = 60;
|
||||
const FOOTER_HEIGHT = 130;
|
||||
const MARK_HEIGHT = 76;
|
||||
const GAP = 44;
|
||||
/** Room for two lines and the button below the stage, in px. */
|
||||
const STATEMENT_HEIGHT = 330;
|
||||
const MAX_WIDE_FONT = 88;
|
||||
const IN_FRAMES = 18;
|
||||
const OUT_FRAMES = 10;
|
||||
const RISE_PX = 18;
|
||||
const EDGE_FADE =
|
||||
"linear-gradient(to bottom, transparent, #000 9%, #000 91%, transparent), linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)";
|
||||
|
||||
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
/** Fast start, long soft landing: the easing of a calm presentation. */
|
||||
const settle = Easing.bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/** Two or three lines in the headline type: the first ones light, the last one quieter. */
|
||||
export const Lines: FC<{ lines: readonly string[]; fontSize: number }> = ({ lines, fontSize }) => (
|
||||
<>
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={line}
|
||||
style={{
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 900,
|
||||
fontSize,
|
||||
lineHeight: 1.06,
|
||||
letterSpacing: "-0.04em",
|
||||
wordSpacing: "0.08em",
|
||||
color: index > 0 && index === lines.length - 1 ? DRAW.label : DRAW.fg,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const Statement: FC<{ caption: Caption; first: boolean; fontSize: number; total: number }> = ({
|
||||
caption,
|
||||
first,
|
||||
fontSize,
|
||||
total,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const lines = useCopy().slides[caption.id] ?? [];
|
||||
// The first statement stands on frame 0 (the preview image) and returns at the very end.
|
||||
const back = first ? interpolate(frame, [total - OUT_FRAMES, total - 1], [0, 1], clampOpts) : 0;
|
||||
const arrive = first ? 1 : settle(interpolate(frame, [caption.from, caption.from + IN_FRAMES], [0, 1], clampOpts));
|
||||
const leave = interpolate(frame, [caption.to - OUT_FRAMES, caption.to - 1], [1, 0], clampOpts);
|
||||
const opacity = Math.max(frame < caption.to ? Math.min(arrive, leave) : 0, back);
|
||||
if (opacity === 0) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
textAlign: "center",
|
||||
opacity,
|
||||
transform: `translateY(${(1 - arrive) * RISE_PX}px)`,
|
||||
}}
|
||||
>
|
||||
<Lines lines={lines} fontSize={fontSize} />
|
||||
{caption.button && (
|
||||
<div style={{ marginTop: fontSize * 0.55 }}>
|
||||
<BrandButton label="Get in touch" size={Math.round(fontSize * 0.3)} tone="light" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The ground every film and image stands on: a quiet dark surface with one soft light, the
|
||||
* signature very large and faint behind everything, and a small line with signature and address.
|
||||
*/
|
||||
export const Backdrop: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height, durationInFrames } = useVideoConfig();
|
||||
const wide = width > height;
|
||||
const turn = (frame / durationInFrames) * Math.PI * 2;
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", backgroundColor: DRAW.bg }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `radial-gradient(60% 50% at ${50 + Math.sin(turn) * 10}% ${38 + Math.cos(turn) * 6}%, rgba(${DRAW.glow}, 0.3), transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
<Img
|
||||
src={staticFile("logo-mark.svg")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -width * 0.12,
|
||||
bottom: -width * 0.06,
|
||||
width: width * (wide ? 0.5 : 0.95),
|
||||
opacity: 0.04,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: SIDE,
|
||||
right: SIDE,
|
||||
bottom: 44,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile("logo-mark.svg")} style={{ height: MARK_HEIGHT, opacity: 0.92 }} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 22,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: DRAW.label,
|
||||
}}
|
||||
>
|
||||
www.mintel.me
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What every film shares: the backdrop, the drawing in the middle, and one statement at a time
|
||||
* centred below it. Deliberately little (owner feedback: minimal, calm, one idea at a time).
|
||||
* The film itself is drawn by `children` as SVG content in STAGE coordinates.
|
||||
*/
|
||||
export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }> = ({ captions, children }) => {
|
||||
const { width, height, durationInFrames } = useVideoConfig();
|
||||
const copy = useCopy();
|
||||
const wide = width > height;
|
||||
const scale = Math.min(
|
||||
(width - SIDE * 2) / STAGE.width,
|
||||
(height - FOOTER_HEIGHT - GAP - STATEMENT_HEIGHT - 50) / STAGE.height,
|
||||
);
|
||||
const stageHeight = STAGE.height * scale;
|
||||
const stageTop = (height - FOOTER_HEIGHT - stageHeight - GAP - STATEMENT_HEIGHT) / 2 + 20;
|
||||
const textWidth = width - SIDE * 2;
|
||||
// One size for the whole film: the largest at which its longest line still fits.
|
||||
const fontSize = Math.min(
|
||||
wide ? MAX_WIDE_FONT : Infinity,
|
||||
headlineSize(captions.flatMap((caption) => copy.slides[caption.id] ?? []), textWidth),
|
||||
);
|
||||
|
||||
return (
|
||||
<Backdrop>
|
||||
<svg
|
||||
width={STAGE.width * scale}
|
||||
height={stageHeight}
|
||||
viewBox={`${-STAGE.width / 2} ${-STAGE.height / 2} ${STAGE.width} ${STAGE.height}`}
|
||||
// While the camera moves, the drawing is larger than the stage: it fades out at the stage's edges.
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: (width - STAGE.width * scale) / 2,
|
||||
top: stageTop,
|
||||
maskImage: EDGE_FADE,
|
||||
WebkitMaskImage: EDGE_FADE,
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
<div style={{ position: "absolute", left: SIDE, width: textWidth, top: stageTop + stageHeight + GAP, height: STATEMENT_HEIGHT }}>
|
||||
{captions.map((caption, index) => (
|
||||
<Statement key={caption.id} caption={caption} first={index === 0} fontSize={fontSize} total={durationInFrames} />
|
||||
))}
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { hopAt, type Point } from "../lib/hop";
|
||||
import { FilmStage, type Caption } from "./FilmStage";
|
||||
|
||||
export const ORDER_FILM_FRAMES = 690;
|
||||
|
||||
/**
|
||||
* One continuous shot, told by the camera. Close up, a dot drifts between two squares: ping-pong.
|
||||
* The camera pulls back: it is one of many, a whole office carrying things from tool to tool.
|
||||
* Everything stops, the dots sink onto a line, and the lines carry the work by themselves.
|
||||
* The camera returns to the two squares, so the film loops.
|
||||
*/
|
||||
const T = {
|
||||
reveal: 75,
|
||||
wide: 195,
|
||||
freeze: 310,
|
||||
sink: 345,
|
||||
lines: 365,
|
||||
flow: 440,
|
||||
cta: 560,
|
||||
fold: 630,
|
||||
} as const;
|
||||
|
||||
const CAPTIONS: readonly Caption[] = [
|
||||
{ id: "hook", from: 0, to: T.reveal + 20 },
|
||||
{ id: "reveal", from: T.reveal + 30, to: T.wide + 30 },
|
||||
{ id: "problem", from: T.wide + 30, to: T.freeze + 20 },
|
||||
{ id: "turn", from: T.sink, to: T.flow + 20 },
|
||||
{ id: "calm", from: T.flow + 20, to: T.cta },
|
||||
{ id: "cta", from: T.cta, to: ORDER_FILM_FRAMES, button: true },
|
||||
];
|
||||
|
||||
const COLS = 4;
|
||||
const HERO_ROW = 2;
|
||||
const PITCH = { x: 220, y: 190 } as const;
|
||||
const TILE = 96;
|
||||
const ZOOM_IN = 2.6;
|
||||
const ZOOM_OUT = 0.8;
|
||||
/** Height of a hop as a share of its length. */
|
||||
const ARC = 0.22;
|
||||
const CLOSE_HOP_FRAMES = 30;
|
||||
/** The film starts in mid-flight: the first frame is the preview image. */
|
||||
const CLOSE_PHASE = 15;
|
||||
|
||||
const colX = (col: number): number => (col - (COLS - 1) / 2) * PITCH.x;
|
||||
const rowY = (row: number): number => (row - HERO_ROW) * PITCH.y;
|
||||
const stop = (row: number, col: number): Point => ({ x: colX(col), y: rowY(row) });
|
||||
|
||||
/** Each row has someone carrying things round its four tools, in their own order and pace. */
|
||||
const WALKERS = [
|
||||
{ route: [0, 1, 2, 3], hopFrames: 27, offset: 7 },
|
||||
{ route: [2, 0, 3, 1], hopFrames: 32, offset: 40 },
|
||||
{ route: [2, 3, 0, 1], hopFrames: 29, offset: 0 },
|
||||
{ route: [3, 2, 1, 0], hopFrames: 25, offset: 19 },
|
||||
{ route: [1, 3, 0, 2], hopFrames: 30, offset: 55 },
|
||||
] as const;
|
||||
|
||||
/** 0 while the camera is close, 1 when it has pulled back. */
|
||||
const pulledBack = (frame: number): number =>
|
||||
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, ORDER_FILM_FRAMES)));
|
||||
|
||||
/** Time as the walkers feel it: it runs, then slows to a halt at the freeze. */
|
||||
const walkTime = (frame: number): number => {
|
||||
if (frame < T.freeze) return frame;
|
||||
const u = progress(frame, T.freeze, T.freeze + 30);
|
||||
return T.freeze + 15 * (1 - (1 - u) ** 2);
|
||||
};
|
||||
|
||||
const walkerAt = (row: number, frame: number): Point => {
|
||||
const walker = WALKERS[row];
|
||||
if (!walker) return stop(row, 0);
|
||||
const time = walkTime(frame);
|
||||
// The one in the middle row plays ping-pong between the two inner squares while the camera is close.
|
||||
if (row === HERO_ROW && (time < T.reveal || frame >= T.fold)) {
|
||||
const loopTime = frame >= T.fold ? frame - ORDER_FILM_FRAMES : time;
|
||||
return hopAt(loopTime + CLOSE_PHASE, [stop(row, 1), stop(row, 2)], CLOSE_HOP_FRAMES, ARC * 1.4);
|
||||
}
|
||||
const start = row === HERO_ROW ? T.reveal : -walker.offset;
|
||||
return hopAt(time - start, walker.route.map((col) => stop(row, col)), walker.hopFrames, ARC);
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const OrderDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const back = pulledBack(frame);
|
||||
const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back;
|
||||
/** Everything outside the opening picture fades in as the camera pulls back. */
|
||||
const others = progress(back, 0.05, 0.5);
|
||||
const hair = 2.2 / zoom;
|
||||
// Back in the air for the return to the opening picture.
|
||||
const sink = frame >= T.fold ? 0 : inOut(progress(frame, T.sink, T.sink + 30));
|
||||
const folding = 1 - progress(frame, T.fold, T.fold + 24);
|
||||
|
||||
return (
|
||||
<g transform={`scale(${zoom})`}>
|
||||
{WALKERS.map((_, row) => {
|
||||
const hero = row === HERO_ROW;
|
||||
const y = rowY(row);
|
||||
const away = Math.abs(row - HERO_ROW);
|
||||
|
||||
// The line that replaces the walking.
|
||||
const lineStart = T.lines + away * 12;
|
||||
const drawn = out(progress(frame, lineStart, lineStart + 45)) * folding;
|
||||
const lineEnd = mix(colX(0), colX(COLS - 1), drawn);
|
||||
|
||||
// Work travelling along the line by itself.
|
||||
const sincePulse = frame - (T.flow + away * 10);
|
||||
const pulseT = sincePulse < 0 ? -1 : (sincePulse % 80) / 55;
|
||||
const pulseX = pulseT >= 0 && pulseT <= 1 ? mix(colX(0), colX(COLS - 1), inOut(pulseT)) : null;
|
||||
|
||||
// The walker, and the light it trails.
|
||||
const walkerShown =
|
||||
(hero ? 1 : others) * (1 - progress(frame, lineStart, lineStart + 18)) +
|
||||
(hero ? progress(frame, T.fold + 28, ORDER_FILM_FRAMES - 6) : 0);
|
||||
const place = (at: number): Point => {
|
||||
const point = walkerAt(row, at);
|
||||
return { x: point.x, y: mix(point.y, y, sink) };
|
||||
};
|
||||
const walker = place(frame);
|
||||
const radius = 12 / Math.sqrt(zoom);
|
||||
|
||||
return (
|
||||
<g key={row}>
|
||||
{drawn > 0 && (
|
||||
<path
|
||||
d={`M${colX(0)} ${y}H${lineEnd}`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.3}
|
||||
strokeLinecap="round"
|
||||
opacity={hero ? 1 : others}
|
||||
style={{ filter: `drop-shadow(0 0 ${6 / zoom}px rgba(${DRAW.light}, 0.9))` }}
|
||||
/>
|
||||
)}
|
||||
{Array.from({ length: COLS }, (_unused, col) => {
|
||||
const x = colX(col);
|
||||
const opening = hero && (col === 1 || col === 2);
|
||||
const touched = walkerShown > 0.5 && Math.hypot(walker.x - x, walker.y - y) < TILE * 0.42;
|
||||
const lit = pulseX === null ? 0 : Math.max(0, 1 - Math.abs(pulseX - x) / 110) * folding;
|
||||
return (
|
||||
<g key={col} opacity={opening ? 1 : others}>
|
||||
<rect x={x - TILE / 2} y={y - TILE / 2} width={TILE} height={TILE} rx={TILE * 0.24} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={x - TILE / 2}
|
||||
y={y - TILE / 2}
|
||||
width={TILE}
|
||||
height={TILE}
|
||||
rx={TILE * 0.24}
|
||||
fill={`rgba(248, 250, 252, ${0.1 + lit * 0.22})`}
|
||||
stroke={touched ? DRAW.mark : DRAW.fg}
|
||||
strokeOpacity={touched ? 1 : 0.75 + lit * 0.25}
|
||||
strokeWidth={touched ? hair * 1.6 : hair}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{pulseX !== null && folding > 0 && (
|
||||
<circle
|
||||
cx={pulseX}
|
||||
cy={y}
|
||||
r={radius * 0.8}
|
||||
fill={DRAW.mark}
|
||||
opacity={(hero ? 1 : others) * folding}
|
||||
style={{ filter: `drop-shadow(0 0 ${10 / zoom}px ${DRAW.mark})` }}
|
||||
/>
|
||||
)}
|
||||
{walkerShown > 0 && (
|
||||
<g opacity={Math.min(1, walkerShown)}>
|
||||
{[1, 2, 3, 4, 5].map((k) => {
|
||||
const ghost = place(frame - k * 2.2);
|
||||
return <circle key={k} cx={ghost.x} cy={ghost.y} r={radius * (1 - k * 0.13)} fill={DRAW.mark} opacity={0.3 - k * 0.05} />;
|
||||
})}
|
||||
<circle
|
||||
cx={walker.x}
|
||||
cy={walker.y}
|
||||
r={radius}
|
||||
fill={DRAW.mark}
|
||||
style={{ filter: `drop-shadow(0 0 ${12 / zoom}px ${DRAW.mark})` }}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Automation, as one continuous shot: from ping-pong to tools that talk to each other. */
|
||||
export const OrderFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<OrderDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { FC } from "react";
|
||||
import { Easing, useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a blinking cursor: a beginning. The camera pulls back: it is an empty page, and it has
|
||||
* been empty for months. Then the page builds itself, block by block, and becomes three screens.
|
||||
*/
|
||||
const WINDOW = { width: 640, height: 440 } as const;
|
||||
const BAR_HEIGHT = 50;
|
||||
const CURSOR = { x: -250, y: -110, width: 9, height: 62 } as const;
|
||||
const BLINK_FRAMES = 30;
|
||||
const settle = Easing.bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/** The blocks of a simple page, in the coordinates of a screen of the given size. */
|
||||
const Page: FC<{ width: number; height: number; columns: number; shown: (block: number) => number }> = ({
|
||||
width,
|
||||
height,
|
||||
columns,
|
||||
shown,
|
||||
}) => {
|
||||
const pad = width * 0.07;
|
||||
const inner = width - pad * 2;
|
||||
const top = -height / 2 + pad;
|
||||
const heroHeight = height * 0.42;
|
||||
const cardsTop = top + heroHeight + pad * 0.6;
|
||||
const cardHeight = height / 2 - pad - cardsTop;
|
||||
const gap = pad * 0.5;
|
||||
const cardWidth = (inner - gap * (columns - 1)) / columns;
|
||||
const block = (index: number): { opacity: number; transform: string } => ({
|
||||
opacity: shown(index),
|
||||
transform: `translate(0 ${(1 - shown(index)) * 22})`,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<g {...block(0)}>
|
||||
<rect x={-inner / 2} y={top} width={inner} height={heroHeight} rx={pad * 0.45} fill={DRAW.fg} />
|
||||
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.34} width={inner * 0.5} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} />
|
||||
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.58} width={inner * 0.3} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} opacity={0.45} />
|
||||
</g>
|
||||
{Array.from({ length: columns }, (_, column) => (
|
||||
<rect
|
||||
key={column}
|
||||
{...block(1 + column)}
|
||||
x={-inner / 2 + column * (cardWidth + gap)}
|
||||
y={cardsTop}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
rx={pad * 0.45}
|
||||
fill="rgba(248, 250, 252, 0.22)"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Screen: FC<{ width: number; height: number; hair: number; radius: number }> = ({ width, height, hair, radius }) => (
|
||||
<>
|
||||
<rect x={-width / 2} y={-height / 2} width={width} height={height} rx={radius} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={-width / 2}
|
||||
y={-height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={radius}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const DEVICES = [
|
||||
{ x: 195, y: 20, width: 190, height: 270, columns: 2 },
|
||||
{ x: 380, y: 50, width: 112, height: 210, columns: 1 },
|
||||
] as const;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const WebsiteDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 3, 1, CURSOR);
|
||||
const { hair, others } = camera;
|
||||
const blink = Math.cos((frame / BLINK_FRAMES) * Math.PI * 2) > -0.2 ? 1 : 0.12;
|
||||
const cursorShown = 1 - progress(frame, T.turn, T.turn + 12) + progress(frame, T.fold + 24, T.fold + 44);
|
||||
const built = (block: number): number =>
|
||||
settle(progress(frame, T.resolve - 12 + block * 12, T.resolve + 22 + block * 12)) * untilFold(frame);
|
||||
const split = inOut(progress(frame, T.calm, T.calm + 50)) * (1 - inOut(progress(frame, T.fold, T.fold + 30)));
|
||||
const contentHeight = WINDOW.height - BAR_HEIGHT;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{DEVICES.map((device) => (
|
||||
<g key={device.x} opacity={split} transform={`translate(${mix(-120, device.x, split)} ${device.y})`}>
|
||||
<Screen width={device.width} height={device.height} hair={hair} radius={22} />
|
||||
<Page width={device.width} height={device.height} columns={device.columns} shown={() => 1} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
<g transform={`translate(${mix(0, -170, split)} 0) scale(${mix(1, 0.72, split)})`}>
|
||||
<g opacity={others}>
|
||||
<Screen width={WINDOW.width} height={WINDOW.height} hair={hair} radius={30} />
|
||||
<path d={`M${-WINDOW.width / 2} ${-WINDOW.height / 2 + BAR_HEIGHT}h${WINDOW.width}`} stroke={DRAW.fg} strokeOpacity={0.4} strokeWidth={hair} />
|
||||
{[0, 1, 2].map((n) => (
|
||||
<circle key={n} cx={-WINDOW.width / 2 + 30 + n * 24} cy={-WINDOW.height / 2 + BAR_HEIGHT / 2} r={7} fill={DRAW.fg} opacity={0.3} />
|
||||
))}
|
||||
</g>
|
||||
<g transform={`translate(0 ${BAR_HEIGHT / 2})`}>
|
||||
<Page width={WINDOW.width} height={contentHeight} columns={3} shown={built} />
|
||||
</g>
|
||||
<rect
|
||||
x={CURSOR.x - CURSOR.width / 2}
|
||||
y={CURSOR.y - CURSOR.height / 2}
|
||||
width={CURSOR.width}
|
||||
height={CURSOR.height}
|
||||
rx={CURSOR.width / 2}
|
||||
fill={DRAW.mark}
|
||||
opacity={Math.min(1, cursorShown) * blink}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** A website: from a page that has been empty for months to one that is built and fits every screen. */
|
||||
export const WebsiteFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<WebsiteDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { inOut, progress } from "../illustrations/motion";
|
||||
import type { Point } from "../lib/hop";
|
||||
import type { Caption } from "./FilmStage";
|
||||
|
||||
/**
|
||||
* What the films share: one timeline and one camera move. Every film is a single shot:
|
||||
* close on something small, pull back to see what it really is, stop, resolve, rest,
|
||||
* and return to the opening picture so that it loops.
|
||||
*/
|
||||
export const SHOT_FRAMES = 690;
|
||||
|
||||
export const T = {
|
||||
/** The camera starts to pull back. */
|
||||
reveal: 75,
|
||||
/** The whole picture is in view. */
|
||||
wide: 195,
|
||||
/** Everything slows to a halt. */
|
||||
freeze: 310,
|
||||
/** The turn begins. */
|
||||
turn: 345,
|
||||
/** The problem resolves. */
|
||||
resolve: 365,
|
||||
/** The calm result. */
|
||||
calm: 440,
|
||||
cta: 560,
|
||||
/** The picture folds back into its opening. */
|
||||
fold: 630,
|
||||
} as const;
|
||||
|
||||
/** The six statements of a film, in order: hook, reveal, problem, turn, calm, call to action. */
|
||||
export const CAPTIONS: readonly Caption[] = [
|
||||
{ id: "hook", from: 0, to: T.reveal + 20 },
|
||||
{ id: "reveal", from: T.reveal + 30, to: T.wide + 30 },
|
||||
{ id: "problem", from: T.wide + 30, to: T.freeze + 20 },
|
||||
{ id: "turn", from: T.turn, to: T.calm + 20 },
|
||||
{ id: "calm", from: T.calm + 20, to: T.cta },
|
||||
{ id: "cta", from: T.cta, to: SHOT_FRAMES, button: true },
|
||||
];
|
||||
|
||||
/** 0 while the camera is close, 1 when it has pulled back. */
|
||||
export const pulledBack = (frame: number): number =>
|
||||
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, SHOT_FRAMES)));
|
||||
|
||||
/** 1 until the fold, then quickly 0: for everything that has to be gone before the loop closes. */
|
||||
export const untilFold = (frame: number): number => 1 - progress(frame, T.fold, T.fold + 24);
|
||||
|
||||
export type Camera = {
|
||||
readonly zoom: number;
|
||||
/** Opacity of everything outside the opening picture: it fades in as the camera pulls back. */
|
||||
readonly others: number;
|
||||
/** Width of a hairline that looks the same at every zoom. */
|
||||
readonly hair: number;
|
||||
/** SVG transform for the group that holds the drawing. */
|
||||
readonly transform: string;
|
||||
};
|
||||
|
||||
/** The camera at a frame: close on `focus` at `zoomIn`, then the whole stage at `zoomOut`. */
|
||||
export const cameraAt = (frame: number, zoomIn: number, zoomOut: number, focus: Point): Camera => {
|
||||
const back = pulledBack(frame);
|
||||
const zoom = zoomIn * (zoomOut / zoomIn) ** back;
|
||||
return {
|
||||
zoom,
|
||||
others: progress(back, 0.05, 0.5),
|
||||
hair: 2.2 / zoom,
|
||||
transform: `scale(${zoom}) translate(${-focus.x * (1 - back)} ${-focus.y * (1 - back)})`,
|
||||
};
|
||||
};
|
||||
|
||||
/** A soft light around a shape, the same size on screen at every zoom. */
|
||||
export const glow = (color: string, px: number, zoom: number): { filter: string } => ({
|
||||
filter: `drop-shadow(0 0 ${px / zoom}px ${color})`,
|
||||
});
|
||||
|
||||
/** The quiet look of every surface: a light tint and a fine light outline. */
|
||||
export const SURFACE = { fill: "rgba(248, 250, 252, 0.1)", strokeOpacity: 0.75 } as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Easing, interpolate } from "remotion";
|
||||
|
||||
/** Small motion helpers shared by all illustrations. All of them are pure functions of the frame. */
|
||||
|
||||
const CLAMP = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
|
||||
/** 0 before `from`, 1 after `to`, linear in between. */
|
||||
export const progress = (frame: number, from: number, to: number): number =>
|
||||
interpolate(frame, [from, to], [0, 1], CLAMP);
|
||||
|
||||
export const mix = (from: number, to: number, t: number): number => from + (to - from) * t;
|
||||
|
||||
export const inOut = Easing.inOut(Easing.cubic);
|
||||
export const out = Easing.out(Easing.cubic);
|
||||
/** Overshoots slightly, for things that pop in. */
|
||||
export const pop = Easing.out(Easing.back(2.2));
|
||||
|
||||
/** `pop` between two frames: 0 before, 1 after. */
|
||||
export const popAt = (frame: number, from: number, length = 8): number =>
|
||||
pop(progress(frame, from, from + length));
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { useT } from "../content/CopyContext";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
|
||||
/** Shared drawing parts for illustrations. Everything is SVG, drawn in stage coordinates. */
|
||||
|
||||
export type IconName =
|
||||
| "cart"
|
||||
| "invoice"
|
||||
| "people"
|
||||
| "mail"
|
||||
| "check"
|
||||
| "book"
|
||||
| "form"
|
||||
| "chat"
|
||||
| "bell"
|
||||
| "tasks";
|
||||
|
||||
const ICON_PATHS: Readonly<Record<IconName, ReactNode>> = {
|
||||
cart: (
|
||||
<>
|
||||
<path d="M3 4h2l2.4 10.2a1 1 0 0 0 1 .8h8.9a1 1 0 0 0 1-.8L20 7H6.2" />
|
||||
<circle cx="9.5" cy="19" r="1.3" />
|
||||
<circle cx="17" cy="19" r="1.3" />
|
||||
</>
|
||||
),
|
||||
invoice: (
|
||||
<>
|
||||
<path d="M7 3h7l4 4v14H7z" />
|
||||
<path d="M14 3v4h4" />
|
||||
<path d="M10 12h5" />
|
||||
<path d="M10 16h5" />
|
||||
</>
|
||||
),
|
||||
people: (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="3.5" />
|
||||
<path d="M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
|
||||
</>
|
||||
),
|
||||
mail: (
|
||||
<>
|
||||
<rect x="3" y="6" width="18" height="13" rx="2" />
|
||||
<path d="m3.5 7.5 8.5 6 8.5-6" />
|
||||
</>
|
||||
),
|
||||
check: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8 12.5 3 3 5-6" />
|
||||
</>
|
||||
),
|
||||
book: (
|
||||
<>
|
||||
<path d="M5 4h11a2 2 0 0 1 2 2v14H7a2 2 0 0 1-2-2z" />
|
||||
<path d="M5 18a2 2 0 0 1 2-2h11" />
|
||||
<path d="M9 8h6" />
|
||||
</>
|
||||
),
|
||||
form: (
|
||||
<>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M8 9h8" />
|
||||
<path d="M8 13h8" />
|
||||
<path d="M8 17h4" />
|
||||
</>
|
||||
),
|
||||
chat: <path d="M4 5h16v11H10l-4 4v-4H4z" />,
|
||||
bell: (
|
||||
<>
|
||||
<path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z" />
|
||||
<path d="M10 20a2 2 0 0 0 4 0" />
|
||||
</>
|
||||
),
|
||||
tasks: (
|
||||
<>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="m8 12 3 3 5-6" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/** A line icon from a 24 x 24 grid, centred on (cx, cy). */
|
||||
export const Icon: FC<{ name: IconName; cx: number; cy: number; size: number; color: string }> = ({
|
||||
name,
|
||||
cx,
|
||||
cy,
|
||||
size,
|
||||
color,
|
||||
}) => (
|
||||
<g
|
||||
transform={`translate(${cx - size / 2} ${cy - size / 2}) scale(${size / 24})`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{ICON_PATHS[name]}
|
||||
</g>
|
||||
);
|
||||
|
||||
export const TILE_SIZE = 150;
|
||||
|
||||
/** One tool: a dark rounded tile with an icon and a mono label below. */
|
||||
export const Tile: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
/** Coloured outline, e.g. while someone works in this tool. */
|
||||
ring?: string;
|
||||
scale?: number;
|
||||
opacity?: number;
|
||||
}> = ({ x, y, icon, label, ring, scale = 1, opacity = 1 }) => {
|
||||
const centre = TILE_SIZE / 2;
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`} opacity={opacity}>
|
||||
<g transform={`translate(${centre} ${centre}) scale(${scale}) translate(${-centre} ${-centre})`}>
|
||||
{ring && (
|
||||
<rect
|
||||
x={-10}
|
||||
y={-10}
|
||||
width={TILE_SIZE + 20}
|
||||
height={TILE_SIZE + 20}
|
||||
rx={42}
|
||||
fill="none"
|
||||
stroke={ring}
|
||||
strokeWidth={4}
|
||||
style={{ filter: `drop-shadow(0 0 12px ${ring})` }}
|
||||
/>
|
||||
)}
|
||||
<rect width={TILE_SIZE} height={TILE_SIZE} rx={34} fill={DRAW.tint} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<Icon name={icon} cx={centre} cy={centre} size={78} color={DRAW.fg} />
|
||||
</g>
|
||||
<text
|
||||
x={centre}
|
||||
y={TILE_SIZE + 44}
|
||||
textAnchor="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={19}
|
||||
letterSpacing="0.16em"
|
||||
fill={DRAW.label}
|
||||
>
|
||||
{useT()(label).toUpperCase()}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Round badge with a tick, centred on (cx, cy). */
|
||||
export const DoneBadge: FC<{ cx: number; cy: number; r: number; scale?: number }> = ({ cx, cy, r, scale = 1 }) => (
|
||||
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
|
||||
<circle r={r} fill={COLOR.done} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${COLOR.done})` }} />
|
||||
<path
|
||||
d={`M${-r * 0.42} ${r * 0.04} ${-r * 0.1} ${r * 0.36} ${r * 0.44} ${-r * 0.3}`}
|
||||
fill="none"
|
||||
stroke={DRAW.bg}
|
||||
strokeWidth={r * 0.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Round amber badge with one character: something needs attention. */
|
||||
export const WarnBadge: FC<{ cx: number; cy: number; r: number; scale?: number; char?: string }> = ({
|
||||
cx,
|
||||
cy,
|
||||
r,
|
||||
scale = 1,
|
||||
char = "!",
|
||||
}) => (
|
||||
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
|
||||
<circle r={r} fill={DRAW.mark} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${DRAW.mark})` }} />
|
||||
<text y={r * 0.42} textAnchor="middle" fontFamily={FONT.sans} fontWeight={900} fontSize={r * 1.2} fill={DRAW.bg}>
|
||||
{char}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Mono caption inside a drawing. */
|
||||
export const Label: FC<{ x: number; y: number; text: string; anchor?: "start" | "middle" | "end"; color?: string; size?: number }> = ({
|
||||
x,
|
||||
y,
|
||||
text,
|
||||
anchor = "middle",
|
||||
color = DRAW.label,
|
||||
size = 19,
|
||||
}) => (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor={anchor}
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={size}
|
||||
letterSpacing="0.16em"
|
||||
fill={color}
|
||||
>
|
||||
{useT()(text).toUpperCase()}
|
||||
</text>
|
||||
);
|
||||
|
||||
/** Mouse pointer with its tip at (x, y): stands for a person doing the step by hand. */
|
||||
export const Cursor: FC<{ x: number; y: number; scale?: number }> = ({ x, y, scale = 1.5 }) => (
|
||||
<path
|
||||
transform={`translate(${x} ${y}) scale(${scale})`}
|
||||
d="M0 0 0 30 8 23 14 36 19 34 13 21 24 21Z"
|
||||
fill={DRAW.fg}
|
||||
stroke={DRAW.bg}
|
||||
strokeWidth={2.5}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
|
||||
/** A piece of data: dark pill with binary digits. */
|
||||
export const DataChip: FC<{ x: number; y: number; width?: number; digits?: string; color?: string }> = ({
|
||||
x,
|
||||
y,
|
||||
width = 150,
|
||||
digits = "0110",
|
||||
color = DRAW.fg,
|
||||
}) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={44} rx={22} fill={color} />
|
||||
<text
|
||||
x={width / 2}
|
||||
y={30}
|
||||
textAnchor="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={700}
|
||||
fontSize={22}
|
||||
letterSpacing="0.2em"
|
||||
fill={DRAW.bg}
|
||||
>
|
||||
{digits}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** An app window: white card with a title bar. Children are drawn in its coordinates. */
|
||||
export const AppWindow: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}> = ({ x, y, width, height, title, children }) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={height} rx={30} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<path d={`M0 64H${width}`} stroke={DRAW.rim} strokeWidth={2} />
|
||||
{[30, 56, 82].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={33} r={8} fill={DRAW.soft} />
|
||||
))}
|
||||
<text
|
||||
x={width - 28}
|
||||
y={41}
|
||||
textAnchor="end"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={19}
|
||||
letterSpacing="0.16em"
|
||||
fill={DRAW.label}
|
||||
>
|
||||
{useT()(title).toUpperCase()}
|
||||
</text>
|
||||
{children}
|
||||
</g>
|
||||
);
|
||||
|
||||
/** A box for one day of a week or a countdown, centred on cx. */
|
||||
export const DayBox: FC<{
|
||||
cx: number;
|
||||
y: number;
|
||||
width: number;
|
||||
label: string;
|
||||
filled: boolean;
|
||||
scale?: number;
|
||||
}> = ({ cx, y, width, label, filled, scale = 1 }) => (
|
||||
<g transform={`translate(${cx} ${y + 55}) scale(${scale})`}>
|
||||
<rect
|
||||
x={-width / 2}
|
||||
y={-55}
|
||||
width={width}
|
||||
height={110}
|
||||
rx={26}
|
||||
fill={filled ? DRAW.fg : DRAW.glass}
|
||||
stroke={filled ? DRAW.fg : DRAW.rim}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<Label x={0} y={8} text={label} color={filled ? DRAW.bg : DRAW.fg} size={24} />
|
||||
</g>
|
||||
);
|
||||
|
||||
/** A sheet of paper. Children are drawn in its coordinates. */
|
||||
export const Page: FC<{ x: number; y: number; width: number; height: number; children?: ReactNode }> = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
}) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={height} rx={24} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
{children}
|
||||
</g>
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { STAGE_WIDTH } from "../slides/SlideDeck";
|
||||
import { inOut, mix, out, popAt, progress } from "./motion";
|
||||
import { AppWindow, Cursor, DataChip, DoneBadge, Icon, TILE_SIZE, Tile, type IconName } from "./parts";
|
||||
|
||||
/** Scenes that several videos share. Each is one slide's illustration, drawn from its frame 0. */
|
||||
|
||||
export const SCENE_HEIGHT = 520;
|
||||
export const CTA_SCENE_HEIGHT = 300;
|
||||
|
||||
export type Tool = { readonly icon: IconName; readonly label: string };
|
||||
/** Scenes with a row of tools expect exactly four of them. */
|
||||
export type Tools = readonly [Tool, Tool, Tool, Tool];
|
||||
|
||||
const TOOL_COUNT = 4;
|
||||
const TOOL_GAP = (STAGE_WIDTH - TOOL_COUNT * TILE_SIZE) / (TOOL_COUNT - 1);
|
||||
export const toolX = (index: number): number => index * (TILE_SIZE + TOOL_GAP);
|
||||
export const ROW_Y = 185;
|
||||
|
||||
export const Canvas: FC<{ height?: number; children: ReactNode }> = ({ height = SCENE_HEIGHT, children }) => (
|
||||
<svg width={STAGE_WIDTH} height={height} viewBox={`0 0 ${STAGE_WIDTH} ${height}`} style={{ overflow: "visible" }}>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CHIP_DIGITS = ["0110", "1001", "1101"] as const;
|
||||
|
||||
/** Values carried by hand from one window into another, again and again. */
|
||||
export const CarryScene: FC<{ fromTitle: string; toTitle: string }> = ({ fromTitle, toTitle }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const perChip = 25;
|
||||
const rowY = (row: number): number => 166 + row * 92;
|
||||
const current = Math.min(CHIP_DIGITS.length - 1, Math.floor(frame / perChip));
|
||||
const chipAt = (row: number): { x: number; y: number } => {
|
||||
const t = row < current ? 1 : row > current ? 0 : inOut(progress(frame, row * perChip + 4, row * perChip + 20));
|
||||
return { x: mix(34, 472 + 38, t), y: rowY(row) - Math.sin(Math.PI * t) * 46 };
|
||||
};
|
||||
const held = chipAt(current);
|
||||
return (
|
||||
<Canvas>
|
||||
<AppWindow x={0} y={60} width={400} height={400} title={fromTitle}>
|
||||
{CHIP_DIGITS.map((digits, row) => (
|
||||
<rect key={digits} x={200} y={rowY(row) - 46} width={160} height={16} rx={8} fill={DRAW.soft} />
|
||||
))}
|
||||
</AppWindow>
|
||||
<AppWindow x={472} y={60} width={400} height={400} title={toTitle}>
|
||||
{CHIP_DIGITS.map((digits, row) => (
|
||||
<rect
|
||||
key={digits}
|
||||
x={30}
|
||||
y={rowY(row) - 66}
|
||||
width={340}
|
||||
height={56}
|
||||
rx={28}
|
||||
fill="none"
|
||||
stroke={DRAW.rim}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="10 10"
|
||||
/>
|
||||
))}
|
||||
</AppWindow>
|
||||
{CHIP_DIGITS.map((digits, row) => {
|
||||
const chip = chipAt(row);
|
||||
return <DataChip key={digits} x={chip.x} y={chip.y} digits={digits} />;
|
||||
})}
|
||||
<Cursor x={held.x + 112} y={held.y + 26} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** Four separate tools, and a person hopping between them with the data. */
|
||||
export const HopScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const perTool = 15;
|
||||
const active = Math.min(TOOL_COUNT - 1, Math.floor(frame / perTool));
|
||||
const hop = active === 0 ? 1 : inOut(progress(frame, active * perTool, active * perTool + 9));
|
||||
const x = mix(toolX(Math.max(0, active - 1)), toolX(active), hop) + 78;
|
||||
const y = ROW_Y + TILE_SIZE + 70 - Math.sin(Math.PI * hop) * 40;
|
||||
return (
|
||||
<Canvas>
|
||||
{tools.map((tool, index) => (
|
||||
<Tile key={tool.label} x={toolX(index)} y={ROW_Y} {...tool} ring={index === active ? COLOR.active : undefined} />
|
||||
))}
|
||||
<DataChip x={x + 34} y={y + 26} width={110} />
|
||||
<Cursor x={x} y={y} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const CARD = { width: 300, height: 66, step: 46, count: 9, x: 60, base: 440 } as const;
|
||||
|
||||
/** One item of work waiting to be handled. */
|
||||
export const WorkCard: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
icon: IconName;
|
||||
tilt?: number;
|
||||
opacity?: number;
|
||||
done?: boolean;
|
||||
width?: number;
|
||||
}> = ({ x, y, icon, tilt = 0, opacity = 1, done = false, width = CARD.width }) => (
|
||||
<g transform={`translate(${x} ${y}) rotate(${tilt} ${width / 2} ${CARD.height / 2})`} opacity={opacity}>
|
||||
<rect width={width} height={CARD.height} rx={20} fill={DRAW.glass} stroke={done ? COLOR.done : DRAW.fg} strokeWidth={2.5} />
|
||||
<Icon name={icon} cx={40} cy={CARD.height / 2} size={34} color={done ? COLOR.done : DRAW.fg} />
|
||||
<rect x={76} y={CARD.height / 2 - 8} width={width / 2} height={16} rx={8} fill={DRAW.soft} />
|
||||
</g>
|
||||
);
|
||||
|
||||
const cardTilt = (index: number): number => (index % 2 === 0 ? -2.5 : 2);
|
||||
const cardY = (index: number): number => CARD.base - index * CARD.step;
|
||||
|
||||
/** Work arrives faster than one person can handle it. */
|
||||
export const PileScene: FC<{ icon: IconName }> = ({ icon }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const every = 5;
|
||||
return (
|
||||
<Canvas>
|
||||
{Array.from({ length: CARD.count }, (_, index) => {
|
||||
const fall = out(progress(frame, index * every, index * every + 8));
|
||||
return fall === 0 ? null : (
|
||||
<WorkCard key={index} x={CARD.x} y={mix(-120, cardY(index), fall)} icon={icon} tilt={cardTilt(index)} opacity={fall} />
|
||||
);
|
||||
})}
|
||||
<Tile x={600} y={ROW_Y} icon="people" label="One person" ring={COLOR.active} />
|
||||
<Cursor x={690 + Math.cos(frame / 5) * 14} y={ROW_Y + TILE_SIZE / 2 + 40 + Math.sin(frame / 5) * 14} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** The pile from PileScene clears itself. */
|
||||
export const ClearedScene: FC<{ icon: IconName }> = ({ icon }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const every = 4;
|
||||
return (
|
||||
<Canvas>
|
||||
<DoneBadge cx={STAGE_WIDTH / 2} cy={250} r={120} scale={popAt(frame, 36, 14)} />
|
||||
{Array.from({ length: CARD.count }, (_, index) => {
|
||||
const order = CARD.count - 1 - index;
|
||||
const leave = inOut(progress(frame, 4 + order * every, 14 + order * every));
|
||||
return leave === 1 ? null : (
|
||||
<WorkCard
|
||||
key={index}
|
||||
x={CARD.x + leave * 620}
|
||||
y={cardY(index)}
|
||||
icon={icon}
|
||||
tilt={cardTilt(index)}
|
||||
opacity={1 - leave}
|
||||
done={leave > 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const PACKET_TRAVEL_FRAMES = 27;
|
||||
const PACKET_EVERY_FRAMES = 9;
|
||||
|
||||
/** Four tools in a row, connected, with work travelling through on its own. */
|
||||
export const FlowRow: FC<{
|
||||
tools: Tools;
|
||||
y: number;
|
||||
/** 0..1: how far the link after tool `index` is drawn. */
|
||||
connected: (index: number) => number;
|
||||
/** Frame at which work starts to flow, or null while the tools are only being connected. */
|
||||
flowFrom: number | null;
|
||||
}> = ({ tools, y, connected, flowFrom }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const centreY = y + TILE_SIZE / 2;
|
||||
const firstCentre = toolX(0) + TILE_SIZE / 2;
|
||||
const lastCentre = toolX(TOOL_COUNT - 1) + TILE_SIZE / 2;
|
||||
const flowing = flowFrom === null ? -1 : frame - flowFrom;
|
||||
const packets =
|
||||
flowing < 0 ? [] : Array.from({ length: Math.floor(flowing / PACKET_EVERY_FRAMES) + 1 }, (_, n) => n);
|
||||
const passFrame = (index: number): number => (PACKET_TRAVEL_FRAMES * index) / (TOOL_COUNT - 1);
|
||||
return (
|
||||
<>
|
||||
{tools.slice(1).map((tool, index) => {
|
||||
const from = toolX(index) + TILE_SIZE;
|
||||
const drawn = connected(index);
|
||||
const tip = mix(from, toolX(index + 1), drawn);
|
||||
return drawn === 0 ? null : (
|
||||
<g key={tool.label}>
|
||||
<path d={`M${from} ${centreY}H${tip}`} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<circle cx={tip} cy={centreY} r={6} fill={DRAW.fg} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{packets.map((n) => {
|
||||
const t = (flowing - n * PACKET_EVERY_FRAMES) / PACKET_TRAVEL_FRAMES;
|
||||
return t > 1 ? null : (
|
||||
<circle key={n} cx={mix(firstCentre, lastCentre, t)} cy={centreY} r={11}
|
||||
fill={DRAW.mark}
|
||||
stroke={DRAW.mark}
|
||||
strokeOpacity={0.28}
|
||||
strokeWidth={16}
|
||||
style={{ filter: `drop-shadow(0 0 10px ${DRAW.mark})` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tools.map((tool, index) => {
|
||||
const since = flowing - passFrame(index);
|
||||
const pulse = since < 0 ? 0 : 1 - (since % PACKET_EVERY_FRAMES) / PACKET_EVERY_FRAMES;
|
||||
return (
|
||||
<g key={tool.label}>
|
||||
<Tile x={toolX(index)} y={y} {...tool} scale={1 + pulse * 0.05} />
|
||||
{since >= 0 && (
|
||||
<DoneBadge cx={toolX(index) + TILE_SIZE - 8} cy={y + 8} r={26} scale={popAt(since, 0) * (1 + pulse * 0.12)} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** The tools get connected, one link after the other. */
|
||||
export const ConnectScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<Canvas>
|
||||
<FlowRow
|
||||
tools={tools}
|
||||
y={ROW_Y}
|
||||
connected={(index) => inOut(progress(frame, 8 + index * 12, 20 + index * 12))}
|
||||
flowFrom={null}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const RUNS_FLOW_FROM = 4;
|
||||
const FINISHED_SLOTS = 9;
|
||||
|
||||
/** Work runs through all four tools by itself; finished items collect below. */
|
||||
export const RunsScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const firstArrival = RUNS_FLOW_FROM + PACKET_TRAVEL_FRAMES;
|
||||
return (
|
||||
<Canvas>
|
||||
<FlowRow tools={tools} y={110} connected={() => 1} flowFrom={RUNS_FLOW_FROM} />
|
||||
{Array.from({ length: FINISHED_SLOTS }, (_, n) => {
|
||||
const arrival = firstArrival + n * PACKET_EVERY_FRAMES;
|
||||
return frame < arrival ? null : (
|
||||
<DoneBadge key={n} cx={36 + n * 100} cy={420} r={30} scale={popAt(frame, arrival)} />
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** The connected tools, still running: sits above the call to action. */
|
||||
export const RunningScene: FC<{ tools: Tools }> = ({ tools }) => (
|
||||
<Canvas height={CTA_SCENE_HEIGHT}>
|
||||
<FlowRow tools={tools} y={64} connected={() => 1} flowFrom={-60} />
|
||||
</Canvas>
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FC } from "react";
|
||||
import { useVideoConfig } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { CopyProvider } from "../content/CopyContext";
|
||||
import { IMAGES, type ImageCopy, type ImageId } from "../content/images";
|
||||
import type { Lang } from "../content/videos";
|
||||
import { Backdrop, FilmStage, Lines, SIDE, type Caption } from "../films/FilmStage";
|
||||
import { VIDEOS } from "../registry";
|
||||
import { headlineSize } from "../slides/SlideDeck";
|
||||
|
||||
const MAX_CARD_FONT = 132;
|
||||
/** The statement of a poster stands for good: an image has one frame. */
|
||||
const ALWAYS: readonly Caption[] = [{ id: "poster", from: 0, to: 1_000_000 }];
|
||||
|
||||
/**
|
||||
* One picture of a film, with the film's idea in two lines below it. Which moment is shown is
|
||||
* decided by the frame the composition is rendered at (see Root and the export script).
|
||||
*/
|
||||
const Poster: FC<{ video: string; copy: ImageCopy; lang: Lang }> = ({ video, copy, lang }) => {
|
||||
const entry = VIDEOS.find((candidate) => candidate.id === video);
|
||||
if (!entry || !("Drawing" in entry)) throw new Error(`No film with a drawing is registered as "${video}"`);
|
||||
return (
|
||||
<CopyProvider value={{ lang, copy: { title: copy.title, slides: { poster: copy.lines }, post: copy.post } }}>
|
||||
<FilmStage captions={ALWAYS}>
|
||||
<entry.Drawing />
|
||||
</FilmStage>
|
||||
</CopyProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/** A question clients ask, small, and its answer in very large type. Nothing else. */
|
||||
const Card: FC<{ copy: ImageCopy }> = ({ copy }) => {
|
||||
const { width } = useVideoConfig();
|
||||
const fontSize = Math.min(MAX_CARD_FONT, headlineSize(copy.lines, width - SIDE * 2));
|
||||
return (
|
||||
<Backdrop>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: `0 ${SIDE}px 150px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 22,
|
||||
backgroundColor: DRAW.mark,
|
||||
boxShadow: `0 0 28px 6px ${DRAW.mark}`,
|
||||
marginBottom: fontSize * 0.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: Math.round(fontSize * 0.28),
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: DRAW.mark,
|
||||
marginBottom: fontSize * 0.45,
|
||||
}}
|
||||
>
|
||||
{copy.label}
|
||||
</div>
|
||||
<Lines lines={copy.lines} fontSize={fontSize} />
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
|
||||
/** One image post in one language: a poster of a film, or a card. */
|
||||
export const ImagePost: FC<{ id: ImageId; lang: Lang }> = ({ id, lang }) => {
|
||||
const image = IMAGES[id];
|
||||
const copy = image.copy[lang];
|
||||
return image.poster ? (
|
||||
<Poster video={image.poster.video} copy={copy} lang={lang} />
|
||||
) : (
|
||||
<Card copy={copy} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { Root } from "./Root";
|
||||
|
||||
registerRoot(Root);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { outlinePoint, sparkAt, type Box } from "./aura";
|
||||
|
||||
const BOX: Box = { width: 400, height: 200 };
|
||||
const LOOP = 300;
|
||||
|
||||
describe("outlinePoint", () => {
|
||||
it("starts at the top left corner and walks clockwise", () => {
|
||||
expect(outlinePoint(0, BOX)).toEqual({ x: 0, y: 0, nx: 0, ny: -1 });
|
||||
// Perimeter is 1200: a quarter of it is 300 px along the top edge.
|
||||
expect(outlinePoint(0.25, BOX)).toEqual({ x: 300, y: 0, nx: 0, ny: -1 });
|
||||
});
|
||||
|
||||
it("gives the right edge an outward normal pointing right", () => {
|
||||
// 450 px: 400 along the top, 50 down the right edge.
|
||||
expect(outlinePoint(450 / 1200, BOX)).toEqual({ x: 400, y: expect.closeTo(50, 6), nx: 1, ny: 0 });
|
||||
});
|
||||
|
||||
it("walks the bottom edge from right to left and the left edge upwards", () => {
|
||||
expect(outlinePoint(700 / 1200, BOX)).toEqual({ x: expect.closeTo(300, 6), y: 200, nx: 0, ny: 1 });
|
||||
expect(outlinePoint(1100 / 1200, BOX)).toEqual({ x: 0, y: expect.closeTo(100, 6), nx: -1, ny: 0 });
|
||||
});
|
||||
|
||||
it("wraps positions outside 0..1", () => {
|
||||
expect(outlinePoint(1.25, BOX).x).toBeCloseTo(300, 6);
|
||||
expect(outlinePoint(-0.75, BOX).x).toBeCloseTo(300, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sparkAt", () => {
|
||||
it("is deterministic", () => {
|
||||
expect(sparkAt(7, 120, LOOP, BOX)).toEqual(sparkAt(7, 120, LOOP, BOX));
|
||||
});
|
||||
|
||||
it("is the same on the frame after the loop ends as on frame 0", () => {
|
||||
for (const index of [0, 1, 2, 17, 40]) {
|
||||
expect(sparkAt(index, LOOP, LOOP, BOX)).toEqual(sparkAt(index, 0, LOOP, BOX));
|
||||
}
|
||||
});
|
||||
|
||||
it("hides a spark outside its life and shows it inside", () => {
|
||||
const visibleFrames = Array.from({ length: LOOP }, (_, frame) => sparkAt(3, frame, LOOP, BOX)).filter(
|
||||
(spark) => spark !== null,
|
||||
);
|
||||
expect(visibleFrames.length).toBeGreaterThan(0);
|
||||
expect(visibleFrames.length).toBeLessThan(LOOP);
|
||||
});
|
||||
|
||||
it("fades in and out: opacity stays within 0..1 and digits are 0 or 1", () => {
|
||||
for (let frame = 0; frame < LOOP; frame++) {
|
||||
const spark = sparkAt(5, frame, LOOP, BOX);
|
||||
if (spark === null) continue;
|
||||
expect(spark.opacity).toBeGreaterThanOrEqual(0);
|
||||
expect(spark.opacity).toBeLessThanOrEqual(1);
|
||||
expect(["0", "1"]).toContain(spark.digit);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves away from the outline as it ages", () => {
|
||||
const frames = Array.from({ length: LOOP }, (_, frame) => sparkAt(9, frame, LOOP, BOX));
|
||||
const first = frames.findIndex((spark) => spark !== null);
|
||||
const distance = (frame: number): number => {
|
||||
const spark = frames[frame];
|
||||
if (!spark) throw new Error("spark expected");
|
||||
const outsideX = Math.max(0, -spark.x, spark.x - BOX.width);
|
||||
const outsideY = Math.max(0, -spark.y, spark.y - BOX.height);
|
||||
return Math.hypot(outsideX, outsideY);
|
||||
};
|
||||
// Frame `first` can be mid-life if the spark was born before the loop wrapped; pick a run of two frames.
|
||||
const young = frames.findIndex((spark, frame) => spark !== null && frames[frame + 6] !== null && frame >= first);
|
||||
expect(distance(young + 6)).toBeGreaterThan(distance(young));
|
||||
});
|
||||
|
||||
it("gives different sparks different places", () => {
|
||||
const places = new Set(
|
||||
Array.from({ length: 60 }, (_, index) => sparkAt(index, 150, LOOP, BOX))
|
||||
.filter((spark) => spark !== null)
|
||||
.map((spark) => `${Math.round(spark.x)}:${Math.round(spark.y)}`),
|
||||
);
|
||||
expect(places.size).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The website's BitAura (energy along the outline of an element, digits drifting off it), as pure
|
||||
* functions of the frame number. The website simulates it with random particles; here every spark's
|
||||
* whole life is computed from its index, so each frame can be rendered on its own and the motion
|
||||
* repeats exactly when a video loops.
|
||||
*/
|
||||
|
||||
export type Box = { readonly width: number; readonly height: number };
|
||||
|
||||
export type OutlinePoint = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
/** Outward normal of the edge the point lies on. */
|
||||
readonly nx: number;
|
||||
readonly ny: number;
|
||||
};
|
||||
|
||||
export type Spark = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly opacity: number;
|
||||
readonly digit: "0" | "1";
|
||||
readonly large: boolean;
|
||||
};
|
||||
|
||||
/** How often the light travels round the element during one loop of the video. */
|
||||
export const AURA_LAPS = 2;
|
||||
|
||||
const MIN_LIFE_FRAMES = 50;
|
||||
const EXTRA_LIFE_FRAMES = 60;
|
||||
const MIN_SPEED = 0.35;
|
||||
const EXTRA_SPEED = 0.75;
|
||||
const MAX_SLIDE = 0.3;
|
||||
const SCATTER = 0.14;
|
||||
const LARGE_SHARE = 0.22;
|
||||
const FLIP_EVERY_FRAMES = 33;
|
||||
|
||||
const fraction = (value: number): number => value - Math.floor(value);
|
||||
|
||||
/** Point on the outline of a box, walking clockwise from the top left corner. `at` wraps at 1. */
|
||||
export const outlinePoint = (at: number, { width, height }: Box): OutlinePoint => {
|
||||
const distance = fraction(at) * 2 * (width + height);
|
||||
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
|
||||
if (distance < width + height) return { x: width, y: distance - width, nx: 1, ny: 0 };
|
||||
if (distance < 2 * width + height) return { x: 2 * width + height - distance, y: height, nx: 0, ny: 1 };
|
||||
return { x: 0, y: 2 * (width + height) - distance, nx: -1, ny: 0 };
|
||||
};
|
||||
|
||||
/** A stable pseudo-random number in 0..1 for a spark and one of its properties. */
|
||||
const random = (index: number, property: number): number => {
|
||||
const hash = Math.imul(index + 1, 2654435761) ^ Math.imul(property + 1, 2246822519);
|
||||
return ((Math.imul(hash ^ (hash >>> 15), 3266489917) >>> 0) % 100000) / 100000;
|
||||
};
|
||||
|
||||
/** Where the light is on the outline at a frame: it circles AURA_LAPS times per loop. */
|
||||
export const cometAt = (frame: number, loopFrames: number): number =>
|
||||
fraction((frame / loopFrames) * AURA_LAPS);
|
||||
|
||||
/**
|
||||
* Spark number `index` at `frame`, or null while it is not alive. It is born where the light is at
|
||||
* that moment, drifts away from the edge and fades. `loopFrames` is the length of the video.
|
||||
*/
|
||||
export const sparkAt = (index: number, frame: number, loopFrames: number, box: Box): Spark | null => {
|
||||
const birth = Math.floor(random(index, 0) * loopFrames);
|
||||
const life = Math.min(loopFrames, MIN_LIFE_FRAMES + random(index, 1) * EXTRA_LIFE_FRAMES);
|
||||
const age = (((frame - birth) % loopFrames) + loopFrames) % loopFrames;
|
||||
if (age >= life) return null;
|
||||
|
||||
const second = index % 2 === 1 ? 0.5 : 0;
|
||||
const origin = outlinePoint(cometAt(birth, loopFrames) + second + (random(index, 2) - 0.5) * SCATTER, box);
|
||||
const travelled = (MIN_SPEED + random(index, 3) * EXTRA_SPEED) * age;
|
||||
const slid = (random(index, 4) - 0.5) * 2 * MAX_SLIDE * age;
|
||||
const seed = random(index, 5);
|
||||
|
||||
return {
|
||||
x: origin.x + origin.nx * travelled - origin.ny * slid,
|
||||
y: origin.y + origin.ny * travelled + origin.nx * slid,
|
||||
opacity: Math.sin((Math.PI * age) / life),
|
||||
digit: Math.floor(age / FLIP_EVERY_FRAMES + seed * 10) % 2 === 0 ? "1" : "0",
|
||||
large: random(index, 6) < LARGE_SHARE,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bitString, decodeText } from "./bits";
|
||||
|
||||
const isBinary = (s: string): boolean => /^[01]*$/.test(s);
|
||||
|
||||
describe("decodeText", () => {
|
||||
it("shows only digits for letters and numbers at progress 0, keeps punctuation and spaces", () => {
|
||||
const out = decodeText("Hi, 2 you!", 0, 0);
|
||||
expect(out).toHaveLength("Hi, 2 you!".length);
|
||||
expect(out.replace(/[^A-Za-z2-9]/g, "")).toBe("");
|
||||
expect(out[2]).toBe(",");
|
||||
expect(out[3]).toBe(" ");
|
||||
expect(out[9]).toBe("!");
|
||||
});
|
||||
|
||||
it("returns the original text at progress 1", () => {
|
||||
expect(decodeText("Concept to code", 1, 7)).toBe("Concept to code");
|
||||
});
|
||||
|
||||
it("resolves left to right: at 0.5 the first half is readable, the rest still digits", () => {
|
||||
const out = decodeText("ABCDEFGH", 0.5, 3);
|
||||
expect(out.slice(0, 4)).toBe("ABCD");
|
||||
expect(isBinary(out.slice(4))).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps progress outside 0..1", () => {
|
||||
expect(decodeText("Abc", 2, 0)).toBe("Abc");
|
||||
expect(isBinary(decodeText("Abc", -1, 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic for the same tick and varies with the tick", () => {
|
||||
const text = "WEB APPS & WEBSITES";
|
||||
expect(decodeText(text, 0, 5)).toBe(decodeText(text, 0, 5));
|
||||
const ticks = new Set(
|
||||
Array.from({ length: 10 }, (_, tick) => decodeText(text, 0, tick)),
|
||||
);
|
||||
expect(ticks.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("handles the empty string", () => {
|
||||
expect(decodeText("", 0.5, 0)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bitString", () => {
|
||||
it("has the requested length and only 0 and 1", () => {
|
||||
const out = bitString(40, 3);
|
||||
expect(out).toHaveLength(40);
|
||||
expect(isBinary(out)).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic and changes with the tick", () => {
|
||||
expect(bitString(40, 3)).toBe(bitString(40, 3));
|
||||
expect(bitString(40, 3)).not.toBe(bitString(40, 4));
|
||||
});
|
||||
|
||||
it("is empty for length 0", () => {
|
||||
expect(bitString(0, 1)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Pure, frame-driven binary effects. Remotion renders frame by frame, so nothing here
|
||||
* uses timers or randomness: the same inputs always give the same output.
|
||||
*/
|
||||
|
||||
const HASH_MULTIPLIER = 2654435761;
|
||||
const TICK_MULTIPLIER = 40503;
|
||||
const LETTER_OR_DIGIT = /[A-Za-z0-9]/;
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
|
||||
/** A stable pseudo-random bit for a position at a given tick. */
|
||||
const bitAt = (index: number, tick: number): "0" | "1" => {
|
||||
const hash = Math.imul(index + 1, HASH_MULTIPLIER) ^ Math.imul(tick + 1, TICK_MULTIPLIER);
|
||||
return ((hash >>> 7) & 1) === 0 ? "0" : "1";
|
||||
};
|
||||
|
||||
/**
|
||||
* Text resolving from digits, left to right. Letters and numbers not yet reached show
|
||||
* as 0 or 1; spaces and punctuation stay put so the shape of the text is readable.
|
||||
*/
|
||||
export const decodeText = (text: string, progress: number, tick: number): string => {
|
||||
const chars = Array.from(text);
|
||||
const revealed = Math.floor(clamp01(progress) * chars.length);
|
||||
return chars
|
||||
.map((char, index) =>
|
||||
index < revealed || !LETTER_OR_DIGIT.test(char) ? char : bitAt(index, tick),
|
||||
)
|
||||
.join("");
|
||||
};
|
||||
|
||||
/** A string of `length` binary digits for a given tick. */
|
||||
export const bitString = (length: number, tick: number): string =>
|
||||
Array.from({ length }, (_, index) => bitAt(index, tick)).join("");
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { curtainEdge, curtainPolygon } from "./curtain";
|
||||
|
||||
const ROWS = Array.from({ length: 30 }, (_, row) => row);
|
||||
|
||||
describe("curtainEdge", () => {
|
||||
it("is fully closed at progress 0 and fully open at progress 1, in every row", () => {
|
||||
for (const row of ROWS) {
|
||||
expect(curtainEdge(row, 0)).toBe(0);
|
||||
expect(curtainEdge(row, 1)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("never moves backwards as progress grows", () => {
|
||||
for (const row of ROWS) {
|
||||
let last = 0;
|
||||
for (let step = 0; step <= 20; step++) {
|
||||
const edge = curtainEdge(row, step / 20);
|
||||
expect(edge).toBeGreaterThanOrEqual(last);
|
||||
last = edge;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is ragged: rows are at different places half way through", () => {
|
||||
const places = new Set(ROWS.map((row) => curtainEdge(row, 0.5).toFixed(3)));
|
||||
expect(places.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("stays within 0..1 and clamps progress outside 0..1", () => {
|
||||
expect(curtainEdge(3, -1)).toBe(0);
|
||||
expect(curtainEdge(3, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("is deterministic per row", () => {
|
||||
expect(curtainEdge(7, 0.4)).toBe(curtainEdge(7, 0.4));
|
||||
});
|
||||
});
|
||||
|
||||
describe("curtainPolygon", () => {
|
||||
const box = { width: 200, height: 40, cell: 20 };
|
||||
|
||||
it("covers nothing at progress 0 and the whole box at progress 1 (behind the edge)", () => {
|
||||
expect(curtainPolygon("behind", 0, box)).toBe("polygon(0px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 0px 40px)");
|
||||
expect(curtainPolygon("behind", 1, box)).toBe(
|
||||
"polygon(0px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 0px 40px)",
|
||||
);
|
||||
});
|
||||
|
||||
it("is the complement ahead of the edge", () => {
|
||||
expect(curtainPolygon("ahead", 1, box)).toBe(
|
||||
"polygon(200px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 200px 40px)",
|
||||
);
|
||||
expect(curtainPolygon("ahead", 0, box)).toBe(
|
||||
"polygon(200px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 200px 40px)",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a row for a height that is not a multiple of the cell", () => {
|
||||
const points = curtainPolygon("behind", 1, { width: 100, height: 50, cell: 20 }).split(",");
|
||||
// Two corner points plus two per row, for three rows.
|
||||
expect(points).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The website's BitCurtain (the language switch transition): a front of binary digits with a ragged
|
||||
* edge that sweeps from left to right. Here it changes slides. Pure functions of the progress.
|
||||
*/
|
||||
|
||||
/** How ragged the edge is, as a share of the width. */
|
||||
const SPREAD = 0.24;
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
|
||||
/** A fixed pseudo-random number in 0..1 per row, so the edge keeps its shape while it moves. */
|
||||
const rowLag = (row: number): number => {
|
||||
const x = Math.sin((row + 1) * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
/** Where the edge is in a row, as a share of the width: 0 before the sweep, 1 after it. */
|
||||
export const curtainEdge = (row: number, progress: number): number =>
|
||||
clamp01(clamp01(progress) * (1 + SPREAD) - rowLag(row) * SPREAD);
|
||||
|
||||
export type CurtainBox = { readonly width: number; readonly height: number; readonly cell: number };
|
||||
|
||||
/**
|
||||
* CSS `clip-path` for one side of the edge: `behind` is what the front has already passed
|
||||
* (left of it), `ahead` is what it has not reached yet.
|
||||
*/
|
||||
export const curtainPolygon = (side: "behind" | "ahead", progress: number, box: CurtainBox): string => {
|
||||
const outer = side === "behind" ? 0 : box.width;
|
||||
const rows = Math.ceil(box.height / box.cell);
|
||||
const steps = Array.from({ length: rows }, (_, row) => {
|
||||
const x = curtainEdge(row, progress) * box.width;
|
||||
return [`${x}px ${row * box.cell}px`, `${x}px ${Math.min(box.height, (row + 1) * box.cell)}px`];
|
||||
}).flat();
|
||||
return `polygon(${[`${outer}px 0px`, ...steps, `${outer}px ${box.height}px`].join(", ")})`;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hopAt } from "./hop";
|
||||
|
||||
const A = { x: 100, y: 300 };
|
||||
const B = { x: 300, y: 300 };
|
||||
const C = { x: 700, y: 300 };
|
||||
const FRAMES = 10;
|
||||
const ARC = 0.5;
|
||||
|
||||
describe("hopAt", () => {
|
||||
it("rests on the first stop at time 0 and on the next one a hop later", () => {
|
||||
expect(hopAt(0, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
expect(hopAt(10, [A, B, C], FRAMES, ARC)).toEqual(B);
|
||||
expect(hopAt(20, [A, B, C], FRAMES, ARC)).toEqual(C);
|
||||
});
|
||||
|
||||
it("is half way across and highest in the middle of a hop", () => {
|
||||
// A to B is 200 px: the arc rises by half of that.
|
||||
const mid = hopAt(5, [A, B, C], FRAMES, ARC);
|
||||
expect(mid.x).toBeCloseTo(200, 6);
|
||||
expect(mid.y).toBeCloseTo(200, 6);
|
||||
});
|
||||
|
||||
it("jumps higher on a longer hop", () => {
|
||||
// B to C is 400 px.
|
||||
expect(hopAt(15, [A, B, C], FRAMES, ARC).y).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
it("returns from the last stop to the first and starts again", () => {
|
||||
expect(hopAt(30, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
expect(hopAt(40, [A, B, C], FRAMES, ARC)).toEqual(B);
|
||||
expect(hopAt(25, [A, B, C], FRAMES, ARC).x).toBeCloseTo(400, 6);
|
||||
});
|
||||
|
||||
it("works backwards in time, so a loop can end where it starts", () => {
|
||||
expect(hopAt(-10, [A, B, C], FRAMES, ARC)).toEqual(C);
|
||||
expect(hopAt(-30, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
});
|
||||
|
||||
it("eases: slower near a stop than in the middle of a hop", () => {
|
||||
const nearStop = hopAt(1, [A, B], FRAMES, ARC).x - A.x;
|
||||
const inFlight = hopAt(5, [A, B], FRAMES, ARC).x - hopAt(4, [A, B], FRAMES, ARC).x;
|
||||
expect(nearStop).toBeLessThan(inFlight);
|
||||
});
|
||||
|
||||
it("stays put with a single stop", () => {
|
||||
expect(hopAt(7, [A], FRAMES, ARC)).toEqual(A);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Something that hops from stop to stop in an arc, round and round. A pure function of time. */
|
||||
|
||||
export type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
const easeInOut = (t: number): number => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
||||
const wrap = (index: number, length: number): number => ((index % length) + length) % length;
|
||||
|
||||
/**
|
||||
* Where the hopper is at `time`. It rests on `stops[0]` at time 0, reaches the next stop every
|
||||
* `framesPerHop`, and returns from the last stop to the first. The arc of a hop rises by
|
||||
* `arcRatio` times the distance covered. Negative times are fine.
|
||||
*/
|
||||
export const hopAt = (time: number, stops: readonly Point[], framesPerHop: number, arcRatio: number): Point => {
|
||||
const hop = Math.floor(time / framesPerHop);
|
||||
const from = stops[wrap(hop, stops.length)];
|
||||
const to = stops[wrap(hop + 1, stops.length)];
|
||||
if (!from || !to) throw new Error("hopAt needs at least one stop");
|
||||
const t = easeInOut(time / framesPerHop - hop);
|
||||
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
||||
return {
|
||||
x: from.x + (to.x - from.x) * t,
|
||||
y: from.y + (to.y - from.y) * t - Math.sin(Math.PI * t) * distance * arcRatio,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { appRescue } from "./compositions/AppRescue";
|
||||
import { automationDocuments } from "./compositions/AutomationDocuments";
|
||||
import { automationEnquiry } from "./compositions/AutomationEnquiry";
|
||||
import { automationOrder } from "./compositions/AutomationOrder";
|
||||
import { productDeveloper } from "./compositions/ProductDeveloper";
|
||||
import { websiteDone } from "./compositions/WebsiteDone";
|
||||
import type { VideoEntry } from "./slides/ServiceVideo";
|
||||
|
||||
/** Register new videos here. Each one is offered as `<id>-<language>-<format>`. */
|
||||
export const VIDEOS: readonly VideoEntry[] = [
|
||||
automationOrder,
|
||||
automationDocuments,
|
||||
automationEnquiry,
|
||||
websiteDone,
|
||||
appRescue,
|
||||
productDeveloper,
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
import { BrandFrame } from "../brand/BrandFrame";
|
||||
import { CopyProvider } from "../content/CopyContext";
|
||||
import { COPY, type Lang, type VideoId } from "../content/videos";
|
||||
import { SlideDeck, deckDuration, type Slide } from "./SlideDeck";
|
||||
|
||||
/**
|
||||
* A video about one service, and the id under which its texts are kept. It is either a deck of
|
||||
* slides or a film: one continuous shot that brings its own stage.
|
||||
*/
|
||||
export type VideoEntry =
|
||||
| { readonly id: VideoId; readonly slides: readonly Slide[] }
|
||||
| {
|
||||
readonly id: VideoId;
|
||||
readonly Film: FC;
|
||||
/** The picture of the film alone, in stage coordinates. */
|
||||
readonly Drawing: FC;
|
||||
readonly durationInFrames: number;
|
||||
};
|
||||
|
||||
export const videoDuration = (video: VideoEntry): number =>
|
||||
"Film" in video ? video.durationInFrames : deckDuration(video.slides);
|
||||
|
||||
/** One service video in one language: brand frame, looping slide deck, texts from the copy file. */
|
||||
export const ServiceVideo: FC<{ video: VideoEntry; lang: Lang }> = ({ video, lang }) => {
|
||||
const copy = COPY[video.id][lang];
|
||||
return (
|
||||
<CopyProvider value={{ lang, copy }}>
|
||||
{"Film" in video ? (
|
||||
<video.Film />
|
||||
) : (
|
||||
<BrandFrame>
|
||||
<SlideDeck slides={video.slides} />
|
||||
</BrandFrame>
|
||||
)}
|
||||
</CopyProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { CSSProperties, FC, ReactNode } from "react";
|
||||
import { Easing, Freeze, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { BitWipe } from "../bits/BitWipe";
|
||||
import { FRAME } from "../brand/BrandFrame";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { Panel } from "../brand/Panel";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { useCopy } from "../content/CopyContext";
|
||||
import { bitString } from "../lib/bits";
|
||||
import { curtainEdge, curtainPolygon } from "../lib/curtain";
|
||||
|
||||
/** Illustrations are drawn in a box this wide; it is scaled down to fit into the panel. */
|
||||
export const STAGE_WIDTH = 872;
|
||||
const DEFAULT_ILLUSTRATION_HEIGHT = 520;
|
||||
const ILLUSTRATION_SCALE = 0.9;
|
||||
const PANEL_HEIGHT = 600;
|
||||
/** Height the panel shrinks to on a slide with extra content below the headline (the button). */
|
||||
const PANEL_HEIGHT_WITH_AFTER = 454;
|
||||
const GAP = 60;
|
||||
const TEXT_HEIGHT = 200;
|
||||
const TALL_STAGE_HEIGHT = PANEL_HEIGHT + GAP + TEXT_HEIGHT;
|
||||
const WIDE_TEXT_WIDTH = 620;
|
||||
const WIDE_GAP = 110;
|
||||
const WIDE_STAGE = { width: STAGE_WIDTH + WIDE_GAP + WIDE_TEXT_WIDTH, height: PANEL_HEIGHT };
|
||||
|
||||
const ENTER_FRAMES = 10;
|
||||
const EXIT_FRAMES = 8;
|
||||
/** Length of the digit curtain that changes the picture. */
|
||||
const WIPE_FRAMES = 16;
|
||||
const CURTAIN_CELL = 24;
|
||||
const CURTAIN_TRAIL = 9;
|
||||
const CURTAIN_LEAD = 2;
|
||||
const MAX_HEADLINE_SIZE = 100;
|
||||
/** Average width of a character of the headline font, as a share of the font size. */
|
||||
const CHAR_WIDTH = 0.53;
|
||||
|
||||
export type Slide = {
|
||||
/** Also the key of the slide's headline in the video's copy. */
|
||||
readonly id: string;
|
||||
readonly durationInFrames: number;
|
||||
/** Draws into a box of STAGE_WIDTH x illustrationHeight. Its frame 0 is the slide's first frame. */
|
||||
readonly Illustration: FC;
|
||||
readonly illustrationHeight?: number;
|
||||
/** Extra content below the headline, e.g. the button on the last slide. */
|
||||
readonly after?: ReactNode;
|
||||
};
|
||||
|
||||
type Box = { readonly width: number; readonly height: number };
|
||||
|
||||
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
const easeOut = Easing.out(Easing.cubic);
|
||||
const easeInOut = Easing.inOut(Easing.cubic);
|
||||
|
||||
/** How far the curtain is, `since` frames into a wipe. Complete on the wipe's last frame. */
|
||||
const wipeProgress = (since: number): number =>
|
||||
easeInOut(interpolate(since, [-1, WIPE_FRAMES - 1], [0, 1], clampOpts));
|
||||
|
||||
/** Largest headline size at which the longest line still fits the given width. */
|
||||
export const headlineSize = (lines: readonly string[], width: number): number => {
|
||||
const longest = Math.max(1, ...lines.map((line) => line.length));
|
||||
return Math.min(MAX_HEADLINE_SIZE, Math.floor(width / (longest * CHAR_WIDTH)));
|
||||
};
|
||||
|
||||
/**
|
||||
* The drawing of one slide, centred in the panel. The curtain reveals it from the left at its
|
||||
* start (`wipeIn`) and takes it away from the left from frame `wipeOutAt` on.
|
||||
*/
|
||||
const Picture: FC<{ slide: Slide; box: Box; wipeIn: boolean; wipeOutAt: number | null }> = ({
|
||||
slide,
|
||||
box,
|
||||
wipeIn,
|
||||
wipeOutAt,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { Illustration } = slide;
|
||||
const curtain = { ...box, cell: CURTAIN_CELL };
|
||||
const clipPath =
|
||||
wipeOutAt !== null && frame >= wipeOutAt
|
||||
? curtainPolygon("ahead", wipeProgress(frame - wipeOutAt), curtain)
|
||||
: wipeIn && frame < WIPE_FRAMES
|
||||
? curtainPolygon("behind", wipeProgress(frame), curtain)
|
||||
: undefined;
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", clipPath }}>
|
||||
<div
|
||||
style={{
|
||||
width: STAGE_WIDTH,
|
||||
height: slide.illustrationHeight ?? DEFAULT_ILLUSTRATION_HEIGHT,
|
||||
flexShrink: 0,
|
||||
transform: `scale(${ILLUSTRATION_SCALE})`,
|
||||
}}
|
||||
>
|
||||
<Illustration />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The moving edge of the curtain: bright digits at the front, a fading trail behind it. */
|
||||
const CurtainFront: FC<{ box: Box }> = ({ box }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const progress = wipeProgress(frame);
|
||||
const rows = Math.ceil(box.height / CURTAIN_CELL);
|
||||
return (
|
||||
<svg width={box.width} height={box.height} style={{ position: "absolute", inset: 0 }}>
|
||||
{Array.from({ length: rows }, (_, row) => {
|
||||
const x = curtainEdge(row, progress) * box.width;
|
||||
if (x <= 0 || x >= box.width) return null;
|
||||
const digits = bitString(CURTAIN_TRAIL + CURTAIN_LEAD, frame * 7 + row * 13);
|
||||
return Array.from(digits, (digit, index) => {
|
||||
// k counts cells from the edge back into what is already revealed; negative is ahead of it.
|
||||
const k = index - CURTAIN_LEAD;
|
||||
return (
|
||||
<text
|
||||
key={`${row}-${index}`}
|
||||
x={x - (k + 0.5) * CURTAIN_CELL}
|
||||
y={row * CURTAIN_CELL + CURTAIN_CELL / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={700}
|
||||
fontSize={18}
|
||||
fill={k < 0 ? DRAW.label : k < 2 ? DRAW.mark : DRAW.fg}
|
||||
opacity={k < 0 ? 0.5 : (1 - k / CURTAIN_TRAIL) * 0.9}
|
||||
>
|
||||
{digit}
|
||||
</text>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/** All pictures in turn, changed by the curtain, and the first one again at the very end. */
|
||||
const Pictures: FC<{ slides: readonly Slide[]; starts: readonly number[]; total: number; box: Box }> = ({
|
||||
slides,
|
||||
starts,
|
||||
total,
|
||||
box,
|
||||
}) => {
|
||||
const first = slides[0];
|
||||
const last = slides.length - 1;
|
||||
return (
|
||||
<>
|
||||
{slides.map((slide, index) => {
|
||||
// Every picture keeps playing while the next one is wiped in; the last one is wiped out within its time.
|
||||
const wipeOutAt = index === last ? slide.durationInFrames - WIPE_FRAMES : slide.durationInFrames;
|
||||
return (
|
||||
<Sequence
|
||||
key={slide.id}
|
||||
from={starts[index]}
|
||||
durationInFrames={index === last ? slide.durationInFrames : slide.durationInFrames + WIPE_FRAMES}
|
||||
layout="none"
|
||||
>
|
||||
<Picture slide={slide} box={box} wipeIn={index > 0} wipeOutAt={wipeOutAt} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
{first && (
|
||||
<Sequence from={total - WIPE_FRAMES} durationInFrames={WIPE_FRAMES} layout="none">
|
||||
<LoopPicture slide={first} box={box} />
|
||||
</Sequence>
|
||||
)}
|
||||
{[...starts.slice(1), total - WIPE_FRAMES].map((from) => (
|
||||
<Sequence key={from} from={from} durationInFrames={WIPE_FRAMES} layout="none">
|
||||
<CurtainFront box={box} />
|
||||
</Sequence>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** The first picture as it looks on frame 0, wiped back in at the end of the video. */
|
||||
const LoopPicture: FC<{ slide: Slide; box: Box }> = ({ slide, box }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
clipPath: curtainPolygon("behind", wipeProgress(frame), { ...box, cell: CURTAIN_CELL }),
|
||||
}}
|
||||
>
|
||||
<Freeze frame={0}>
|
||||
<Picture slide={slide} box={box} wipeIn={false} wipeOutAt={null} />
|
||||
</Freeze>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The headline of one slide, and what follows it. `settled` shows it complete from its first frame. */
|
||||
const Words: FC<{ slide: Slide; settled: boolean; width: number }> = ({ slide, settled, width }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const enter = settled ? 1 : interpolate(frame, [0, ENTER_FRAMES], [0, 1], clampOpts);
|
||||
const exit = interpolate(
|
||||
frame,
|
||||
[slide.durationInFrames - EXIT_FRAMES, slide.durationInFrames - 1],
|
||||
[1, 0],
|
||||
clampOpts,
|
||||
);
|
||||
const lines = useCopy().slides[slide.id] ?? [];
|
||||
const style: CSSProperties = {
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 900,
|
||||
fontSize: headlineSize(lines, width),
|
||||
lineHeight: 0.98,
|
||||
letterSpacing: "-0.04em",
|
||||
wordSpacing: "0.08em",
|
||||
};
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, opacity: Math.min(enter, exit) }}>
|
||||
{lines.map((line, index) => (
|
||||
<div key={line} style={{ ...style, color: index > 0 && index === lines.length - 1 ? COLOR.faint : COLOR.ink }}>
|
||||
<BitWipe from={settled ? -1000 : 3 + index * 4} durationInFrames={16} digitSize={18}>
|
||||
{line}
|
||||
</BitWipe>
|
||||
</div>
|
||||
))}
|
||||
{slide.after}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Fades its children in over the exit of the last slide, so the last frame equals the first. */
|
||||
const LoopBack: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, EXIT_FRAMES - 1], [0, 1], clampOpts);
|
||||
return <div style={{ position: "absolute", inset: 0, opacity }}>{children}</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A looping sequence of slides: big picture, few words. The pictures share one dark panel and are
|
||||
* changed by a curtain of digits; the headlines sit below the panel (portrait and square) or beside
|
||||
* it (16:9). The first slide is complete on frame 0, which is the preview image, and returns at the end.
|
||||
*/
|
||||
export const SlideDeck: FC<{ slides: readonly Slide[] }> = ({ slides }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const wide = width > height;
|
||||
const stage = wide ? WIDE_STAGE : { width: STAGE_WIDTH, height: TALL_STAGE_HEIGHT };
|
||||
// As large as the frame allows: the drawing is what matters.
|
||||
const scale = Math.min(
|
||||
(height - FRAME.top - FRAME.bottom) / stage.height,
|
||||
(width - FRAME.side * 2) / stage.width,
|
||||
);
|
||||
|
||||
const starts = slides.map((_, index) =>
|
||||
slides.slice(0, index).reduce((sum, slide) => sum + slide.durationInFrames, 0),
|
||||
);
|
||||
const total = deckDuration(slides);
|
||||
const first = slides[0];
|
||||
|
||||
// In portrait the panel makes room for a slide that has more below its headline.
|
||||
const shrink = wide
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
...slides.map((slide, index) => {
|
||||
if (!slide.after) return 0;
|
||||
const start = starts[index] ?? 0;
|
||||
const end = start + slide.durationInFrames;
|
||||
return Math.min(
|
||||
interpolate(frame, [start, start + ENTER_FRAMES], [0, 1], clampOpts),
|
||||
interpolate(frame, [end - WIPE_FRAMES, end - 1], [1, 0], clampOpts),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const panel = { width: STAGE_WIDTH, height: PANEL_HEIGHT - (PANEL_HEIGHT - PANEL_HEIGHT_WITH_AFTER) * easeOut(shrink) };
|
||||
const textWidth = wide ? WIDE_TEXT_WIDTH : STAGE_WIDTH;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", ...stage, flexShrink: 0, transform: `scale(${scale})` }}>
|
||||
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<Panel {...panel}>
|
||||
<Pictures slides={slides} starts={starts} total={total} box={panel} />
|
||||
</Panel>
|
||||
</div>
|
||||
<div
|
||||
style={
|
||||
wide
|
||||
? { position: "absolute", top: 200, left: STAGE_WIDTH + WIDE_GAP, width: WIDE_TEXT_WIDTH, height: TEXT_HEIGHT }
|
||||
: { position: "absolute", top: panel.height + GAP, left: 0, width: STAGE_WIDTH, height: TEXT_HEIGHT }
|
||||
}
|
||||
>
|
||||
{slides.map((slide, index) => (
|
||||
<Sequence key={slide.id} from={starts[index]} durationInFrames={slide.durationInFrames} layout="none">
|
||||
<Words slide={slide} settled={index === 0} width={textWidth} />
|
||||
</Sequence>
|
||||
))}
|
||||
{first && (
|
||||
<Sequence from={total - EXIT_FRAMES} durationInFrames={EXIT_FRAMES} layout="none">
|
||||
<LoopBack>
|
||||
<Freeze frame={0}>
|
||||
<Words slide={first} settled width={textWidth} />
|
||||
</Freeze>
|
||||
</LoopBack>
|
||||
</Sequence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const deckDuration = (slides: readonly Slide[]): number =>
|
||||
slides.reduce((sum, slide) => sum + slide.durationInFrames, 0);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
import { BrandButton } from "../brand/BrandButton";
|
||||
import { CTA_SCENE_HEIGHT } from "../illustrations/scenes";
|
||||
import type { Slide } from "./SlideDeck";
|
||||
|
||||
const CTA_FRAMES = 75;
|
||||
|
||||
/** The closing slide of every video: a smaller picture, the last statement and the website's button. */
|
||||
export const ctaSlide = (Illustration: FC): Slide => ({
|
||||
id: "cta",
|
||||
durationInFrames: CTA_FRAMES,
|
||||
Illustration,
|
||||
illustrationHeight: CTA_SCENE_HEIGHT,
|
||||
after: (
|
||||
<div style={{ marginTop: 48 }}>
|
||||
<BrandButton label="Get in touch" size={28} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"scripts",
|
||||
"remotion.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { EmailLink } from "@/src/components/EmailLink";
|
||||
import { LegalPage, LegalSection } from "@/src/components/LegalPage";
|
||||
import { OWNER } from "@/src/content/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Imprint",
|
||||
description: "Legal notice (Impressum) for mintel.me.",
|
||||
alternates: { canonical: "/imprint" },
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
|
||||
|
||||
export default function ImprintPage() {
|
||||
return (
|
||||
<LegalPage title="Imprint">
|
||||
<LegalSection heading="Information according to section 5 DDG">
|
||||
<p>
|
||||
{OWNER.name}
|
||||
<br />
|
||||
{OWNER.street}
|
||||
<br />
|
||||
{OWNER.city}
|
||||
<br />
|
||||
{OWNER.country}
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Contact">
|
||||
<p>
|
||||
Email: <EmailLink className={LINK} />
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="VAT identification number">
|
||||
<p>
|
||||
VAT identification number according to section 27a of the German VAT
|
||||
Act (Umsatzsteuergesetz): {OWNER.vatId}
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Responsible for the content">
|
||||
<p>{OWNER.name}, address as above (section 18 paragraph 2 MStV).</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Consumer dispute resolution">
|
||||
<p>
|
||||
I am neither willing nor obliged to take part in dispute resolution
|
||||
proceedings before a consumer arbitration board.
|
||||
</p>
|
||||
</LegalSection>
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
||||
import { BitBurst } from "@/src/components/bits/BitBurst";
|
||||
import { Footer } from "@/src/components/Footer";
|
||||
import { Header } from "@/src/components/Header";
|
||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||
import {
|
||||
SITE_DESCRIPTION,
|
||||
SITE_NAME,
|
||||
SITE_TITLE,
|
||||
SITE_URL,
|
||||
} from "@/src/content/site";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
});
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
style: "italic",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: SITE_TITLE,
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description: SITE_DESCRIPTION,
|
||||
applicationName: SITE_NAME,
|
||||
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||
creator: SITE_NAME,
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
// The images come from opengraph-image.png and twitter-image.png next to this file.
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/",
|
||||
siteName: SITE_NAME,
|
||||
locale: "en_US",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
<body className="min-h-screen bg-white">
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<BitBurst />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 190 KiB |
@@ -1,315 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { Faq } from "@/src/components/home/Faq";
|
||||
import { HeroStatement } from "@/src/components/home/HeroStatement";
|
||||
import { HomeJsonLd } from "@/src/components/home/HomeJsonLd";
|
||||
import { PathPicker } from "@/src/components/home/PathPicker";
|
||||
import { PortraitSection } from "@/src/components/home/PortraitSection";
|
||||
import { StickyBook } from "@/src/components/home/StickyBook";
|
||||
import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
|
||||
import { CareerLine } from "@/src/components/illustrations/CareerLine";
|
||||
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
|
||||
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
|
||||
import {
|
||||
WorkShowcase,
|
||||
type WorkItem,
|
||||
} from "@/src/components/illustrations/WorkShowcase";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { CAREER, FIT, TECH } from "@/src/content/about";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE } from "@/src/content/site";
|
||||
import { AUTOMATION_NOTES } from "@/src/content/automation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: SITE_TITLE },
|
||||
description: SITE_DESCRIPTION,
|
||||
alternates: { canonical: "/" },
|
||||
};
|
||||
|
||||
const MUTED = "text-slate-400";
|
||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
|
||||
|
||||
const PRINCIPLES = [
|
||||
{
|
||||
title: "One contact",
|
||||
text: "You talk to me, and I am the one who does the work.",
|
||||
},
|
||||
{
|
||||
title: "Hours you can check",
|
||||
text: "I track my time. The monthly invoice shows what the hours were spent on.",
|
||||
},
|
||||
{
|
||||
title: "Careful work",
|
||||
text: "I test what I build before I hand it over, and I say so when something is not finished or I am not sure.",
|
||||
},
|
||||
{
|
||||
title: "What I build is yours",
|
||||
text: "Code and design belong to you. You can hand them to another developer.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const WORK: readonly WorkItem[] = [
|
||||
{
|
||||
name: "klz-cables.com",
|
||||
href: "https://klz-cables.com",
|
||||
client: "Cable supplier",
|
||||
text: "The company website of a supplier of energy cables, in German and English.",
|
||||
did: [
|
||||
"Product catalogue with categories and detail pages",
|
||||
"Datasheets generated from the product data",
|
||||
"Content the client can edit in a CMS",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
|
||||
desktop: { src: "/work/klz-long.webp", width: 1200, height: 1680 },
|
||||
mobile: { src: "/work/klz-mobile.webp", width: 390, height: 3600 },
|
||||
},
|
||||
{
|
||||
name: "e-tib.com",
|
||||
href: "https://e-tib.com",
|
||||
client: "Cable network construction",
|
||||
text: "The company website of a group in cable network construction, in German and English.",
|
||||
did: [
|
||||
"Pages for services, references, careers and events",
|
||||
"The same content in two languages",
|
||||
"Works on phone, tablet and desktop",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
|
||||
desktop: { src: "/work/etib-long.webp", width: 1200, height: 4333 },
|
||||
mobile: { src: "/work/etib-mobile.webp", width: 390, height: 3600 },
|
||||
},
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
<HomeJsonLd />
|
||||
<HeroStatement />
|
||||
<StickyBook />
|
||||
|
||||
<PortraitSection
|
||||
id="hello"
|
||||
index="01"
|
||||
label="Hello"
|
||||
title={<>I am Marc.</>}
|
||||
>
|
||||
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
|
||||
I have been building for the web for 15 years: first in agencies,
|
||||
later as a senior developer, and now on my own.
|
||||
</p>
|
||||
<p className="m-0 mt-8 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
|
||||
My strength is the part people see and use: design and interface. I am
|
||||
also at home with what lies behind it, so I can build a complete
|
||||
application and not just its surface.
|
||||
</p>
|
||||
<p className="m-0 mt-6 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
|
||||
You work with me directly. Whether you run a product team or are
|
||||
commissioning something like this for the first time.
|
||||
</p>
|
||||
</PortraitSection>
|
||||
|
||||
<section className="bg-white">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 py-16 md:px-12 md:py-28">
|
||||
<p className={`${MONO} m-0 mb-10`}>The path so far</p>
|
||||
<CareerLine stations={CAREER} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Block
|
||||
index="02"
|
||||
label="Services"
|
||||
title={
|
||||
<>
|
||||
What do you need? <span className={MUTED}>Four typical cases.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<PathPicker />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="03"
|
||||
label="Process automation"
|
||||
title={
|
||||
<>
|
||||
Let the systems <span className={MUTED}>do the copying.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<p className={`${BODY} mb-12 max-w-3xl`}>
|
||||
In most companies, people move information from one tool to another:
|
||||
read it here, type it in there, send an email about it. Automation
|
||||
connects those tools so that the work passes from one to the next on
|
||||
its own. Three typical examples:
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<AutomationFlow />
|
||||
</Reveal>
|
||||
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
|
||||
{AUTOMATION_NOTES.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
<p className={`${MONO} m-0`}>{group.title}</p>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="border-b border-slate-100 py-4 font-sans text-lg text-slate-900 md:text-xl"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="04"
|
||||
label="Technology"
|
||||
title={
|
||||
<>
|
||||
Which projects <span className={MUTED}>I am right for.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
|
||||
{FIT.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div
|
||||
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
|
||||
>
|
||||
<p className={`${MONO} m-0`}>{group.title}</p>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
|
||||
i === 2 ? "text-slate-400" : "text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className="mt-16 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
|
||||
<p className={`${MONO} m-0`}>What I work with</p>
|
||||
<div className="mt-8 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{TECH.map((group) => (
|
||||
<div key={group.area}>
|
||||
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
|
||||
{group.area}
|
||||
</p>
|
||||
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
|
||||
>
|
||||
<DecodeText text={item} trigger="both" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-16 md:mt-24">
|
||||
<Reveal>
|
||||
<h3 className="m-0 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
|
||||
What happens before a change goes live.
|
||||
</h3>
|
||||
<p className={`${BODY} mb-12 mt-6 max-w-3xl`}>
|
||||
Software is connected: a change in one place can break another. I
|
||||
secure what I build with automated tests, and every change has to
|
||||
pass them before it is released. This is what such a run looks
|
||||
like.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<PipelineRun />
|
||||
</Reveal>
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
label="Working with me"
|
||||
title={
|
||||
<>
|
||||
How I work. <span className={MUTED}>Nothing unusual.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<div className="mb-16 overflow-hidden rounded-3xl bg-slate-900 p-4 md:mb-24 md:p-10">
|
||||
<ContactDiagram />
|
||||
</div>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
|
||||
{PRINCIPLES.map((item, i) => (
|
||||
<Reveal key={item.title} delay={0.08 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
<h3 className="m-0 font-sans text-3xl font-bold tracking-tight text-slate-900 md:text-4xl">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={`${BODY} mt-4`}>{item.text}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
label="My work"
|
||||
title={
|
||||
<>
|
||||
Two sites I built. <span className={MUTED}>Both are live.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<WorkShowcase items={WORK} />
|
||||
<Reveal>
|
||||
<p
|
||||
className={`${BODY} mt-20 max-w-3xl border-t border-slate-200 pt-8 md:mt-28`}
|
||||
>
|
||||
Besides client work I build my own software, among other things a
|
||||
system in Rust and Solidity that runs on seven blockchains.
|
||||
</p>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="07"
|
||||
label="Questions"
|
||||
title={
|
||||
<>
|
||||
Good to know <span className={MUTED}>before we talk.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<Faq />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { EmailLink } from "@/src/components/EmailLink";
|
||||
import { LegalPage, LegalSection } from "@/src/components/LegalPage";
|
||||
import { OWNER } from "@/src/content/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy",
|
||||
description: "How mintel.me handles personal data.",
|
||||
alternates: { canonical: "/privacy" },
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<LegalPage title="Privacy">
|
||||
<LegalSection heading="Who is responsible">
|
||||
<p>
|
||||
{OWNER.name}, {OWNER.street}, {OWNER.city}, {OWNER.country}.
|
||||
<br />
|
||||
Email: <EmailLink className={LINK} />
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="In short">
|
||||
<p>
|
||||
This website sets no cookies, shows no advertising and includes no
|
||||
content from third parties. Fonts and images are served from this
|
||||
website itself.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Server logs">
|
||||
<p>
|
||||
When you open a page, the server processes technical data that your
|
||||
browser sends: your IP address, the time, the page requested and the
|
||||
browser type. This is necessary to deliver the website and to keep it
|
||||
secure. The legal basis is my legitimate interest in running the
|
||||
website (Article 6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Visitor statistics">
|
||||
<p>
|
||||
I count page views with Umami, which I run on my own server. It works
|
||||
without cookies and does not create profiles of individual visitors.
|
||||
The data is not passed on to third parties. The legal basis is my
|
||||
legitimate interest in understanding how the website is used (Article
|
||||
6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Error reports">
|
||||
<p>
|
||||
If something on this website fails technically, an error report is
|
||||
sent to an error tracking service that I run on my own server. It
|
||||
contains technical details such as the page, the browser and the error
|
||||
message. Sessions are not recorded. The legal basis is my legitimate
|
||||
interest in a working website (Article 6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Storage in your browser">
|
||||
<p>
|
||||
If loading a page fails, the website stores a timestamp in your
|
||||
browser's session storage so that it reloads only once. It is
|
||||
deleted when you close the tab and is not sent anywhere.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="When you write to me">
|
||||
<p>
|
||||
If you send me an email, I process what you write and your address in
|
||||
order to answer you and, if it comes to that, to prepare a contract
|
||||
(Article 6(1)(b) GDPR). I keep the correspondence for as long as it is
|
||||
needed for that purpose or as the law requires.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Your rights">
|
||||
<p>You have the right to:</p>
|
||||
<ul>
|
||||
<li>ask what data I hold about you</li>
|
||||
<li>have incorrect data corrected</li>
|
||||
<li>have your data deleted or its processing restricted</li>
|
||||
<li>object to processing that is based on legitimate interest</li>
|
||||
<li>receive your data in a portable format</li>
|
||||
<li>complain to a data protection supervisory authority</li>
|
||||
</ul>
|
||||
<p>
|
||||
To use any of these rights, write to <EmailLink className={LINK} />.
|
||||
</p>
|
||||
</LegalSection>
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/** The site is a single page. */
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://mintel.me";
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 190 KiB |
@@ -0,0 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
/** Any address that is not a page ends in the error page of its language. */
|
||||
export default function UnknownPage() {
|
||||
notFound();
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useEffect } from "react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
@@ -11,6 +13,9 @@ export default function Error({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { error: copy } = useContent();
|
||||
const home = pathFor("home", useLocale());
|
||||
|
||||
useEffect(() => {
|
||||
// Log the error to Sentry/GlitchTip
|
||||
Sentry.captureException(error);
|
||||
@@ -37,22 +42,21 @@ export default function Error({
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<span className="inline-block px-3 py-1 bg-red-50 border border-red-100 rounded text-[10px] font-mono text-red-500 uppercase tracking-widest">
|
||||
Error 500
|
||||
{copy.kicker}
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
Kritischer Fehler.
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
Ein unerwartetes Problem ist aufgetreten. Unsere Systeme haben den
|
||||
Vorfall protokolliert.
|
||||
{copy.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button href="#" onClick={() => reset()} variant="primary">
|
||||
System neu starten (Retry)
|
||||
{copy.retry}
|
||||
</Button>
|
||||
<Button href="/" variant="outline">
|
||||
Zurück zur Basis
|
||||
<Button href={home} variant="outline">
|
||||
{copy.home}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { Faq } from "@/src/components/home/Faq";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { PathPicker } from "@/src/components/home/PathPicker";
|
||||
import { TopicButton } from "@/src/components/home/TopicButton";
|
||||
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
|
||||
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { SwipeRow } from "@/src/components/SwipeRow";
|
||||
import { contentFor } from "@/src/content";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { freelance } = contentFor(locale);
|
||||
return {
|
||||
title: { absolute: freelance.metaTitle },
|
||||
description: freelance.metaDescription,
|
||||
alternates: {
|
||||
canonical: pathFor("freelance", locale),
|
||||
languages: languageAlternates("freelance"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a team may ask for. Clients have their own on the homepage. */
|
||||
const TEAM_TOPICS = ["developer", "rescue", "other"] as const;
|
||||
|
||||
const MUTED = "text-slate-400";
|
||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||
const LIST_TITLE = "m-0 font-sans text-xl font-bold tracking-tight md:text-2xl";
|
||||
|
||||
/**
|
||||
* The page for people who develop themselves or lead a team: which projects
|
||||
* fit, the tools, and how a change is secured before it goes live.
|
||||
*/
|
||||
export default async function FreelancePage({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const content = contentFor(locale);
|
||||
const { freelance, technology } = content;
|
||||
const { booking } = freelance;
|
||||
const { services } = content;
|
||||
const { working } = content;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
<section className="bg-white">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 pb-4 pt-14 md:px-12 md:pb-10 md:pt-28">
|
||||
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
|
||||
{freelance.label}
|
||||
</p>
|
||||
<h1 className="m-0 mt-8 max-w-6xl font-sans text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
|
||||
{freelance.title.lead}{" "}
|
||||
<span className={MUTED}>{freelance.title.rest}</span>
|
||||
</h1>
|
||||
<p className={`${BODY} mt-10 max-w-3xl md:mt-14`}>
|
||||
{freelance.intro}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Block
|
||||
index="01"
|
||||
label={technology.label}
|
||||
title={
|
||||
<>
|
||||
{technology.title.lead}{" "}
|
||||
<span className={MUTED}>{technology.title.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SwipeRow className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
|
||||
{technology.fit.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div
|
||||
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
|
||||
>
|
||||
<h3
|
||||
className={`${LIST_TITLE} ${i === 2 ? "text-slate-400" : "text-slate-900"}`}
|
||||
>
|
||||
{group.title}
|
||||
</h3>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
|
||||
i === 2 ? "text-slate-400" : "text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</SwipeRow>
|
||||
|
||||
<Reveal>
|
||||
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
|
||||
<h3 className={`${LIST_TITLE} text-slate-900`}>
|
||||
{technology.techLabel}
|
||||
</h3>
|
||||
<div className="mt-6 grid grid-cols-1 gap-7 sm:grid-cols-2 md:mt-8 md:gap-10 lg:grid-cols-4">
|
||||
{technology.tech.map((group) => (
|
||||
<div key={group.area}>
|
||||
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
|
||||
{group.area}
|
||||
</p>
|
||||
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
|
||||
>
|
||||
<DecodeText text={item} trigger="both" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="02"
|
||||
label={services.label}
|
||||
title={
|
||||
<>
|
||||
{freelance.cases.lead}{" "}
|
||||
<span className={MUTED}>{freelance.cases.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<PathPicker ids={["developer", "rescue"]} pricing="estimate" />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="03"
|
||||
label={freelance.pipelineLabel}
|
||||
title={technology.pipelineTitle}
|
||||
>
|
||||
<Reveal>
|
||||
<p className={`${BODY} mb-12 max-w-3xl`}>
|
||||
{technology.pipelineIntro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<PipelineRun />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="04"
|
||||
label={working.label}
|
||||
title={
|
||||
<>
|
||||
{working.title.lead}{" "}
|
||||
<span className={MUTED}>{working.title.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<BitAura source="bottom" tone="light" className="mb-16 md:mb-24">
|
||||
<div className="overflow-hidden rounded-3xl bg-slate-900 p-4 md:p-10">
|
||||
<ContactDiagram />
|
||||
</div>
|
||||
</BitAura>
|
||||
</Reveal>
|
||||
<SwipeRow className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
|
||||
{working.principles.map((item, i) => (
|
||||
<Reveal key={item.title} delay={0.08 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
<h3 className="m-0 font-sans text-3xl font-bold tracking-tight text-slate-900 md:text-4xl">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={`${BODY} mt-4`}>{item.text}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</SwipeRow>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
label={booking.label}
|
||||
title={
|
||||
<>
|
||||
{booking.title.lead}{" "}
|
||||
<span className={MUTED}>{booking.title.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
|
||||
<div className="md:col-span-6">
|
||||
<p className={BODY}>{booking.text}</p>
|
||||
<ul className="m-0 mt-8 list-none p-0">
|
||||
{booking.terms.map((term) => (
|
||||
<li
|
||||
key={term}
|
||||
className="border-t border-slate-200 py-4 font-sans text-lg text-slate-900 md:text-xl"
|
||||
>
|
||||
{term}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="md:col-span-6">
|
||||
<div className="rounded-3xl bg-slate-50 p-6 md:p-10">
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
{[booking.rate, booking.example].map((price) => (
|
||||
<div key={price.label}>
|
||||
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
|
||||
{price.label}
|
||||
</p>
|
||||
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
|
||||
{price.amount}
|
||||
</p>
|
||||
<p className="m-0 mt-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
|
||||
{price.note}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-10 border-t border-slate-200 pt-8">
|
||||
<TopicButton topic="developer">{booking.cta}</TopicButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-12 border-t border-slate-900 pt-6 md:mt-20">
|
||||
<h3 className="m-0 font-sans text-2xl font-bold tracking-tight text-slate-900 md:text-4xl">
|
||||
{booking.remote.title}
|
||||
</h3>
|
||||
<p className={`${BODY} mt-4 max-w-3xl`}>{booking.remote.text}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
label={content.faq.label}
|
||||
title={
|
||||
<>
|
||||
{freelance.faq.title.lead}{" "}
|
||||
<span className={MUTED}>{freelance.faq.title.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<Faq items={freelance.faq.items} />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta contact={content.contact} topics={TEAM_TOPICS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/src/components/LegalPage";
|
||||
import { IMPRINT } from "@/src/content/legal/imprint";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, description } = IMPRINT[locale];
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: pathFor("imprint", locale),
|
||||
languages: languageAlternates("imprint"),
|
||||
},
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, Body } = IMPRINT[locale];
|
||||
return (
|
||||
<LegalPage title={title}>
|
||||
<Body />
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { StaticImageData } from "next/image";
|
||||
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
||||
import { BitBurst } from "@/src/components/bits/BitBurst";
|
||||
import { BitGlow } from "@/src/components/bits/BitGlow";
|
||||
import { Footer } from "@/src/components/Footer";
|
||||
import { Header } from "@/src/components/Header";
|
||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||
import { ContentProvider } from "@/src/components/ContentProvider";
|
||||
import { BitCurtain } from "@/src/components/bits/BitCurtain";
|
||||
import shareImageDe from "@/src/assets/share/share-de.png";
|
||||
import shareImageEn from "@/src/assets/share/share-en.png";
|
||||
import { contentFor } from "@/src/content";
|
||||
import { INDEXING, SITE_NAME, SITE_URL } from "@/src/content/site";
|
||||
import { robotsMeta } from "@/src/domain/indexing";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
parseLocale,
|
||||
type Locale,
|
||||
} from "@/src/domain/locale";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import "../globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
// A real monospaced fallback has almost the same letter width. The default
|
||||
// fallback (a resized Arial) wraps the mono labels differently, so the hero
|
||||
// jumped when the font arrived.
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
adjustFontFallback: false,
|
||||
fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
style: "italic",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
type Props = {
|
||||
readonly children: React.ReactNode;
|
||||
readonly params: Promise<{ readonly locale: string }>;
|
||||
};
|
||||
|
||||
/** The share image is a screenshot of the hero in that language (see scripts/render-share-image.mjs). */
|
||||
const SHARE_IMAGE: Readonly<Record<Locale, StaticImageData>> = {
|
||||
en: shareImageEn,
|
||||
de: shareImageDe,
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { site } = contentFor(locale);
|
||||
const image = SHARE_IMAGE[locale];
|
||||
const images = [
|
||||
{
|
||||
url: image.src,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
alt: site.shareImageAlt,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: site.title,
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description: site.description,
|
||||
applicationName: SITE_NAME,
|
||||
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||
creator: SITE_NAME,
|
||||
robots: robotsMeta(INDEXING),
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: pathFor("home", locale),
|
||||
siteName: SITE_NAME,
|
||||
locale: site.ogLocale,
|
||||
alternateLocale: LOCALES.filter((other) => other !== locale).map(
|
||||
(other) => contentFor(other).site.ogLocale,
|
||||
),
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
images,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
images,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#ffffff",
|
||||
colorScheme: "light",
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children, params }: Props) {
|
||||
// The proxy only lets the two languages through; anything else is a file-like
|
||||
// address that ends in the error page of the main language.
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
<body className="min-h-screen bg-white">
|
||||
<ContentProvider locale={locale} content={contentFor(locale)}>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<BitCurtain />
|
||||
</ContentProvider>
|
||||
<BitBurst />
|
||||
<BitGlow />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,28 @@
|
||||
export const metadata = {
|
||||
title: "404 - Seite nicht gefunden | Marc Mintel",
|
||||
description: "Diese Seite konnte leider nicht gefunden werden.",
|
||||
};
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
|
||||
export default function NotFound() {
|
||||
const { notFound } = useContent();
|
||||
const home = pathFor("home", useLocale());
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<span className="inline-block px-3 py-1 bg-slate-50 border border-slate-100 rounded text-[10px] font-mono text-slate-500 uppercase tracking-widest">
|
||||
Error 404
|
||||
{notFound.kicker}
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
System-Anomalie.
|
||||
{notFound.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
Die angeforderte URL existiert nicht in dieser Zeitleiste.
|
||||
Möglicherweise wurde die Seite verschoben oder gelöscht.
|
||||
{notFound.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button href="/" variant="primary">
|
||||
Zurück zur Basis
|
||||
</Button>
|
||||
<Button href="/blog" variant="outline">
|
||||
Aktuelle Artikel lesen
|
||||
<Button href={home} variant="primary">
|
||||
{notFound.home}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { Faq } from "@/src/components/home/Faq";
|
||||
import { HeroStatement } from "@/src/components/home/HeroStatement";
|
||||
import { HomeJsonLd } from "@/src/components/home/HomeJsonLd";
|
||||
import { PathPicker } from "@/src/components/home/PathPicker";
|
||||
import { PortraitSection } from "@/src/components/home/PortraitSection";
|
||||
import { StickyBook } from "@/src/components/home/StickyBook";
|
||||
import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
|
||||
import { CareerLine } from "@/src/components/illustrations/CareerLine";
|
||||
import {
|
||||
PresentationCompare,
|
||||
ReasonDrawing,
|
||||
} from "@/src/components/illustrations/WebPresentation";
|
||||
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { StoryThread } from "@/src/components/story/StoryThread";
|
||||
import { SwipeRow } from "@/src/components/SwipeRow";
|
||||
import { contentFor } from "@/src/content";
|
||||
import type { SplitTitle } from "@/src/content/types";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { site } = contentFor(locale);
|
||||
return {
|
||||
title: { absolute: site.title },
|
||||
description: site.description,
|
||||
alternates: {
|
||||
canonical: pathFor("home", locale),
|
||||
languages: languageAlternates("home"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a client of the homepage may ask for. Teams have their own on the freelance page. */
|
||||
const HOME_TOPICS = ["automation", "website", "other"] as const;
|
||||
|
||||
const MUTED = "text-slate-400";
|
||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||
const LIST_TITLE =
|
||||
"m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl";
|
||||
|
||||
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
|
||||
title,
|
||||
muted = MUTED,
|
||||
}) => (
|
||||
<>
|
||||
{title.lead} <span className={muted}>{title.rest}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
export default async function LandingPage({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const content = contentFor(locale);
|
||||
const {
|
||||
hello,
|
||||
services,
|
||||
automation,
|
||||
presentations,
|
||||
story,
|
||||
freelance,
|
||||
work,
|
||||
faq,
|
||||
} = content;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
<HomeJsonLd locale={locale} />
|
||||
<HeroStatement hero={content.hero} />
|
||||
<StickyBook />
|
||||
|
||||
<PortraitSection
|
||||
id="hello"
|
||||
index="01"
|
||||
label={hello.label}
|
||||
title={hello.title}
|
||||
portraitAlt={hello.portraitAlt}
|
||||
story={story.hello}
|
||||
>
|
||||
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
|
||||
{hello.lead}
|
||||
</p>
|
||||
{hello.paragraphs.map((paragraph, i) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className={`m-0 font-sans text-lg leading-relaxed text-slate-300 md:text-xl ${i === 0 ? "mt-8" : "mt-6"}`}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</PortraitSection>
|
||||
|
||||
<section className="relative overflow-clip bg-slate-900">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 pb-20 pt-4 md:px-12 md:pb-36 md:pt-0">
|
||||
<p className="m-0 mb-10 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
|
||||
{hello.careerLabel}
|
||||
</p>
|
||||
<CareerLine stations={hello.career} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Block
|
||||
index="02"
|
||||
label={services.label}
|
||||
chapter={{ line: story.services, figure: "diamond", flip: false }}
|
||||
title={<Title title={services.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<PathPicker ids={["automation", "website"]} pricing="offer" />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<Link
|
||||
href={pathFor("freelance", locale)}
|
||||
className="group mt-14 flex flex-col gap-5 rounded-3xl bg-slate-50 p-6 no-underline transition-colors duration-300 hover:bg-slate-100 md:mt-24 md:flex-row md:items-center md:justify-between md:p-10"
|
||||
>
|
||||
<span className="block max-w-2xl">
|
||||
<span className="block font-sans text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
|
||||
{freelance.teaser.title}
|
||||
</span>
|
||||
<span className="mt-3 block font-sans text-base leading-relaxed text-slate-500 md:text-lg">
|
||||
{freelance.teaser.text}
|
||||
</span>
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-3 self-start rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white md:self-auto">
|
||||
{freelance.teaser.cta}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="03"
|
||||
label={automation.label}
|
||||
chapter={{ line: story.automation, figure: "hexagon", flip: true }}
|
||||
title={<Title title={automation.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<p className={`${BODY} mb-12 max-w-3xl`}>{automation.intro}</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<AutomationFlow />
|
||||
</Reveal>
|
||||
<SwipeRow className="mt-12 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
|
||||
{automation.notes.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
<h3 className={LIST_TITLE}>{group.title}</h3>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="border-b border-slate-100 py-4 font-sans text-lg text-slate-900 md:text-xl"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</SwipeRow>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="04"
|
||||
label={presentations.label}
|
||||
chapter={{ line: story.presentations, figure: "screen", flip: false }}
|
||||
tone="dark"
|
||||
title={<Title title={presentations.title} muted="text-slate-500" />}
|
||||
>
|
||||
<Reveal>
|
||||
<p className="m-0 mb-12 max-w-3xl font-sans text-lg leading-relaxed text-slate-300 md:mb-16 md:text-xl">
|
||||
{presentations.intro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<BitAura source="around" tone="dark">
|
||||
<div className="rounded-3xl border border-slate-700 bg-slate-950 px-4 py-8 md:px-10 md:py-12">
|
||||
<PresentationCompare />
|
||||
</div>
|
||||
</BitAura>
|
||||
</Reveal>
|
||||
|
||||
<SwipeRow className="mt-12 grid grid-cols-1 gap-x-10 gap-y-14 md:mt-28 md:grid-cols-2 lg:grid-cols-4">
|
||||
{presentations.reasons.map((reason, i) => (
|
||||
<Reveal key={reason.title} delay={0.08 * i}>
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<ReasonDrawing index={i} />
|
||||
</div>
|
||||
<h3 className="m-0 mt-6 font-sans text-2xl font-bold tracking-tight text-white">
|
||||
{reason.title}
|
||||
</h3>
|
||||
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-300 md:text-lg">
|
||||
{reason.text}
|
||||
</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</SwipeRow>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-28 md:grid-cols-12 md:gap-12">
|
||||
<Reveal className="md:col-span-6">
|
||||
<div className="border-t border-slate-700 pt-6">
|
||||
<h3 className="m-0 font-sans text-xl font-bold tracking-tight text-white md:text-2xl">
|
||||
{presentations.goodFor.title}
|
||||
</h3>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{presentations.goodFor.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="border-b border-slate-800 py-4 font-sans text-lg text-slate-100 md:text-xl"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1} className="md:col-span-6">
|
||||
<div className="rounded-3xl border border-slate-700 bg-slate-950/60 p-6 md:p-10">
|
||||
<h3 className="m-0 font-sans text-2xl font-bold tracking-tight text-white md:text-3xl">
|
||||
{presentations.honest.title}
|
||||
</h3>
|
||||
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-300 md:text-lg">
|
||||
{presentations.honest.text}
|
||||
</p>
|
||||
<a
|
||||
href="#contact"
|
||||
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
{presentations.cta} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
label={work.label}
|
||||
chapter={{ line: story.work, figure: "square", flip: true }}
|
||||
tone="dark"
|
||||
title={<Title title={work.title} muted="text-slate-500" />}
|
||||
>
|
||||
<WorkShowcase />
|
||||
<Reveal>
|
||||
<p className="m-0 mt-20 max-w-3xl border-t border-slate-800 pt-8 font-sans text-lg leading-relaxed text-slate-300 md:mt-28 md:text-xl">
|
||||
{work.note}
|
||||
</p>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
label={faq.label}
|
||||
chapter={{ line: story.faq, figure: "triangle", flip: false }}
|
||||
title={<Title title={faq.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<Faq items={faq.items} />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta
|
||||
contact={content.contact}
|
||||
topics={HOME_TOPICS}
|
||||
story={story.contact}
|
||||
/>
|
||||
<StoryThread />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/src/components/LegalPage";
|
||||
import { PRIVACY } from "@/src/content/legal/privacy";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, description } = PRIVACY[locale];
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: pathFor("privacy", locale),
|
||||
languages: languageAlternates("privacy"),
|
||||
},
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, Body } = PRIVACY[locale];
|
||||
return (
|
||||
<LegalPage title={title}>
|
||||
<Body />
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Inter, Newsreader } from "next/font/google";
|
||||
import { contentFor } from "@/src/content";
|
||||
import { DEFAULT_LOCALE, type Locale } from "@/src/domain/locale";
|
||||
import { localeOfPath } from "@/src/domain/routes";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
@@ -20,6 +23,11 @@ export default function GlobalError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
// The root layout failed, so the language is read from the address.
|
||||
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
||||
useEffect(() => setLocale(localeOfPath(window.location.pathname)), []);
|
||||
const copy = contentFor(locale).error;
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
|
||||
@@ -41,19 +49,18 @@ export default function GlobalError({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<html lang={locale} className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<body className="min-h-screen bg-white font-sans text-slate-900">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-5 py-20 text-center">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<span className="inline-block px-3 py-1 bg-red-50 border border-red-200 rounded text-[10px] font-mono text-red-600 uppercase tracking-widest border-dashed">
|
||||
Root Level Error
|
||||
{copy.kicker}
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black tracking-tighter text-slate-900">
|
||||
Systemausfall der Hauptebene.
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-slate-500 font-serif italic max-w-xl mx-auto">
|
||||
Ein kritischer Fehler auf der Root-Layout Ebene hat das Rendering
|
||||
blockiert. Der Vorfall wurde zur Untersuchung protokolliert.
|
||||
{copy.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8">
|
||||
@@ -62,7 +69,7 @@ export default function GlobalError({
|
||||
className="relative inline-flex items-center justify-center gap-3 overflow-hidden rounded-full font-bold uppercase tracking-[0.15em] transition-all duration-300 group cursor-pointer px-8 py-4 text-[10px] bg-slate-900 text-white hover:shadow-xl hover:-translate-y-0.5"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
Notfall-Neustart (Reset)
|
||||
{copy.retry}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -321,26 +321,6 @@
|
||||
|
||||
/* Additional global styles from BaseLayout */
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: #0f172a;
|
||||
@@ -1038,53 +1018,503 @@ pre:has(code[class*="language-"]) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Corner marks that snap onto a card on hover or focus. */
|
||||
.bit-card {
|
||||
position: relative;
|
||||
}
|
||||
.bit-card::before,
|
||||
.bit-card::after {
|
||||
content: "";
|
||||
/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */
|
||||
.bit-glow {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid currentColor;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 250ms ease,
|
||||
transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 400ms ease;
|
||||
}
|
||||
.bit-card::before {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border-left: 0;
|
||||
border-bottom: 0;
|
||||
transform: translate(-6px, 6px);
|
||||
@media (hover: hover) {
|
||||
[data-glow]:hover > .bit-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.bit-card::after {
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
transform: translate(6px, -6px);
|
||||
/* Dark surfaces: the dot grid lights up around the pointer. */
|
||||
.bit-glow-dots {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
320px circle at var(--gx, 50%) var(--gy, 50%),
|
||||
rgba(148, 163, 184, 0.22),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(#e2e8f0 1.25px, transparent 1.25px);
|
||||
background-size:
|
||||
auto,
|
||||
20px 20px;
|
||||
-webkit-mask-image: radial-gradient(
|
||||
280px circle at var(--gx, 50%) var(--gy, 50%),
|
||||
#000,
|
||||
transparent 75%
|
||||
);
|
||||
mask-image: radial-gradient(
|
||||
280px circle at var(--gx, 50%) var(--gy, 50%),
|
||||
#000,
|
||||
transparent 75%
|
||||
);
|
||||
}
|
||||
.bit-card:hover::before,
|
||||
.bit-card:hover::after,
|
||||
.bit-card:focus-visible::before,
|
||||
.bit-card:focus-visible::after,
|
||||
.bit-card[aria-selected="true"]::before,
|
||||
.bit-card[aria-selected="true"]::after {
|
||||
opacity: 0.55;
|
||||
transform: translate(0, 0);
|
||||
/* Cards: only the border lights up, in the card's text colour. */
|
||||
.bit-glow-edge {
|
||||
inset: -1px;
|
||||
padding: 2px;
|
||||
background: radial-gradient(
|
||||
320px circle at var(--gx, 50%) var(--gy, 50%),
|
||||
currentColor,
|
||||
transparent 70%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box exclude,
|
||||
linear-gradient(#000 0 0);
|
||||
}
|
||||
/* Dark buttons: a spot of light under the pointer. */
|
||||
.bit-glow-spot {
|
||||
background: radial-gradient(
|
||||
90px circle at var(--gx, 50%) var(--gy, 50%),
|
||||
rgba(129, 140, 248, 0.5),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bit-burst {
|
||||
.bit-burst,
|
||||
.bit-glow {
|
||||
display: none;
|
||||
}
|
||||
.bit-card::before,
|
||||
.bit-card::after {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Calls to action: a light travels round the edge of the pill and glows softly
|
||||
* behind it; on hover the glow comes up. `bit-cta-light` is for white pills on
|
||||
* dark surfaces, `bit-cta-quiet` only lights up on hover.
|
||||
*/
|
||||
@property --cta-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
.bit-cta {
|
||||
--cta-a: rgba(129, 140, 248, 0.95);
|
||||
--cta-b: #ffffff;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.bit-cta-light {
|
||||
--cta-a: rgba(99, 102, 241, 0.95);
|
||||
--cta-b: #312e81;
|
||||
}
|
||||
.bit-cta-quiet {
|
||||
--cta-a: rgba(99, 102, 241, 0.9);
|
||||
--cta-b: #0f172a;
|
||||
}
|
||||
.bit-cta::before,
|
||||
.bit-cta::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: conic-gradient(
|
||||
from var(--cta-angle),
|
||||
transparent 0 58%,
|
||||
var(--cta-a) 76%,
|
||||
var(--cta-b) 86%,
|
||||
var(--cta-a) 93%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: bit-cta-turn 6s linear infinite;
|
||||
}
|
||||
/* The edge itself. */
|
||||
.bit-cta::before {
|
||||
inset: -1px;
|
||||
padding: 1.5px;
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box exclude,
|
||||
linear-gradient(#000 0 0);
|
||||
}
|
||||
/* The glow behind the edge. */
|
||||
.bit-cta::after {
|
||||
inset: -2px;
|
||||
z-index: -1;
|
||||
padding: 6px;
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box exclude,
|
||||
linear-gradient(#000 0 0);
|
||||
filter: blur(10px);
|
||||
opacity: 0.55;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
.bit-cta-quiet::before,
|
||||
.bit-cta-quiet::after {
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.bit-cta:hover::after,
|
||||
.group:hover > .bit-cta::after {
|
||||
opacity: 1;
|
||||
}
|
||||
.bit-cta-quiet:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.bit-cta-quiet:hover::after {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
@keyframes bit-cta-turn {
|
||||
to {
|
||||
--cta-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The contact address on a dark surface: a slow sheen runs through the letters
|
||||
* and a light travels along the underline. On hover the whole line lights up.
|
||||
*/
|
||||
.bit-mail {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 0.42em;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
#ffffff 0 38%,
|
||||
#a5b4fc 50%,
|
||||
#ffffff 62% 100%
|
||||
);
|
||||
background-size: 260% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: bit-mail-sheen 7s ease-in-out infinite;
|
||||
transition: filter 300ms ease;
|
||||
}
|
||||
.bit-mail::before,
|
||||
.bit-mail::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The resting underline. */
|
||||
.bit-mail::before {
|
||||
bottom: 1px;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
/* The light travelling along it. */
|
||||
.bit-mail::after {
|
||||
bottom: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#818cf8 30%,
|
||||
#ffffff 50%,
|
||||
#818cf8 70%,
|
||||
transparent
|
||||
)
|
||||
no-repeat;
|
||||
background-size: 42% 100%;
|
||||
filter: drop-shadow(0 0 8px rgba(129, 140, 248, 0.95));
|
||||
animation: bit-mail-run 3.8s ease-in-out infinite alternate;
|
||||
transition: background-size 400ms ease;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.bit-mail:hover {
|
||||
filter: drop-shadow(0 0 18px rgba(129, 140, 248, 0.7));
|
||||
}
|
||||
.bit-mail:hover::after {
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
@keyframes bit-mail-sheen {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
50% {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
}
|
||||
@keyframes bit-mail-run {
|
||||
from {
|
||||
background-position: -30% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 130% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slow light drifting over a dark picture (see PortraitSection). */
|
||||
.bit-aurora {
|
||||
position: absolute;
|
||||
inset: -10%;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: screen;
|
||||
background:
|
||||
radial-gradient(
|
||||
38% 46% at 38% 42%,
|
||||
rgba(99, 102, 241, 0.55),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
30% 40% at 64% 72%,
|
||||
rgba(129, 140, 248, 0.35),
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(40px);
|
||||
animation: bit-aurora-drift 16s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes bit-aurora-drift {
|
||||
from {
|
||||
transform: translate3d(-4%, -3%, 0) scale(1);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(5%, 4%, 0) scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* The current station on the career line: a node that breathes light. */
|
||||
.bit-node-live {
|
||||
animation: bit-node-breathe 3.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bit-node-breathe {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(129, 140, 248, 0.22),
|
||||
0 0 18px 2px rgba(129, 140, 248, 0.55);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 8px rgba(129, 140, 248, 0.12),
|
||||
0 0 34px 8px rgba(129, 140, 248, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* SwipeRow: on phones equal items stand side by side and are swiped through,
|
||||
* with the next one peeking in. From tablet width on the slide wrappers
|
||||
* dissolve and the items sit in the grid the row was given.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.swipe-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x mandatory;
|
||||
/* Bleed to the screen edges, but start in line with the text. */
|
||||
margin-inline: -1.25rem;
|
||||
padding-inline: 1.25rem;
|
||||
scroll-padding-inline: 1.25rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.swipe-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.swipe-slide {
|
||||
flex: 0 0 82%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.swipe-slide {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The story thread (see StoryThread): a trace down the page like a track on a
|
||||
* circuit board. Indigo on light sections, light on dark ones.
|
||||
*/
|
||||
.story-thread {
|
||||
--thread-ink: #4f46e5;
|
||||
--thread-ground: #ffffff;
|
||||
}
|
||||
.story-thread-on-light {
|
||||
--thread-ink: #4f46e5;
|
||||
--thread-ground: #ffffff;
|
||||
}
|
||||
.story-thread-on-dark {
|
||||
--thread-ink: #c7d2fe;
|
||||
--thread-ground: #0f172a;
|
||||
}
|
||||
.story-thread path,
|
||||
.story-thread line {
|
||||
stroke: var(--thread-ink);
|
||||
stroke-width: 1.75;
|
||||
stroke-linecap: square;
|
||||
stroke-linejoin: miter;
|
||||
}
|
||||
/* The second track of the bus. */
|
||||
.story-thread path.story-thread-bus {
|
||||
stroke-width: 1;
|
||||
opacity: 0.38;
|
||||
}
|
||||
/* Solder points where the track bends or branches. */
|
||||
.story-thread-pad {
|
||||
fill: var(--thread-ground);
|
||||
stroke: var(--thread-ink);
|
||||
stroke-width: 1.75;
|
||||
opacity: 0;
|
||||
transition: opacity 250ms ease;
|
||||
}
|
||||
/* What makes a figure a component: its pins and its number in binary. */
|
||||
.story-thread-part {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease 350ms;
|
||||
}
|
||||
.story-thread-part text {
|
||||
fill: var(--thread-ink);
|
||||
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
/* The bit that travels at the head of the track. */
|
||||
.story-thread-bit {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -9px 0 0 -9px;
|
||||
border-radius: 3px;
|
||||
background: #fde68a;
|
||||
color: #0f172a;
|
||||
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(253, 230, 138, 0.25),
|
||||
0 0 18px 4px rgba(251, 191, 36, 0.8),
|
||||
0 0 40px 12px rgba(129, 140, 248, 0.5);
|
||||
transition: opacity 300ms ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/*
|
||||
* Native feel: the page should behave like a well-made app on every input type.
|
||||
*/
|
||||
html {
|
||||
color-scheme: light;
|
||||
/* Anchor jumps stop below the fixed header. */
|
||||
scroll-padding-top: 6rem;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
accent-color: #0f172a;
|
||||
caret-color: #0f172a;
|
||||
}
|
||||
|
||||
body {
|
||||
text-rendering: optimizeLegibility;
|
||||
font-kerning: normal;
|
||||
}
|
||||
|
||||
/* Headlines break into even lines, running text avoids lonely last words. */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
p,
|
||||
li,
|
||||
dd,
|
||||
summary {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Controls react at once to a tap and are not selected like text. */
|
||||
a,
|
||||
button,
|
||||
summary,
|
||||
[role="tab"],
|
||||
[role="button"] {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
button,
|
||||
summary,
|
||||
[role="tab"],
|
||||
[role="button"],
|
||||
a[href^="#"] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Decoration is neither selectable nor draggable. */
|
||||
[aria-hidden="true"],
|
||||
canvas,
|
||||
img,
|
||||
svg {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
img,
|
||||
svg,
|
||||
a {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
/* One visible keyboard focus, none for mouse and touch. */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid #0f172a;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
summary:focus-visible {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* A short press-in on anything that can be pressed. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
button:active,
|
||||
[role="tab"]:active,
|
||||
a[class*="rounded-full"]:active,
|
||||
a:active > [class*="rounded-full"] {
|
||||
scale: 0.97;
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark surfaces: light selection, light focus ring, dark system controls. */
|
||||
.bg-slate-900,
|
||||
.bg-slate-950 {
|
||||
color-scheme: dark;
|
||||
}
|
||||
.bg-slate-900 ::selection,
|
||||
.bg-slate-950 ::selection {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
.bg-slate-900 :focus-visible,
|
||||
.bg-slate-950 :focus-visible {
|
||||
outline-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Fixed controls stay clear of the home indicator and rounded corners. */
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
body {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { INDEXING, SITE_URL } from "@/src/content/site";
|
||||
import { robotsFile } from "@/src/domain/indexing";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const { rules, sitemap } = robotsFile(INDEXING, SITE_URL);
|
||||
return {
|
||||
rules: {
|
||||
...rules,
|
||||
disallow:
|
||||
typeof rules.disallow === "string"
|
||||
? rules.disallow
|
||||
: [...rules.disallow],
|
||||
},
|
||||
sitemap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/src/content/site";
|
||||
import { sitemapEntries } from "@/src/domain/routes";
|
||||
|
||||
/**
|
||||
* The homepage and the freelance page, each in two languages; the legal pages
|
||||
* are not meant for search results. No modification date: it would have to be the date the content
|
||||
* last changed, and a date that moves with every build teaches search engines to
|
||||
* ignore it.
|
||||
*/
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return sitemapEntries(SITE_URL, ["home", "freelance"]).map((entry) => ({
|
||||
url: entry.url,
|
||||
alternates: { languages: { ...entry.alternates.languages } },
|
||||
}));
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
// The site is a single page. Old URLs from the previous site lead to it.
|
||||
// The language of a request and the legal pages are handled in proxy.ts.
|
||||
async redirects() {
|
||||
return [
|
||||
"/about",
|
||||
@@ -41,11 +42,7 @@ const nextConfig = {
|
||||
"/technologies/:path*",
|
||||
"/tags/:path*",
|
||||
]
|
||||
.map((source) => ({ source, destination: "/", permanent: true }))
|
||||
.concat([
|
||||
{ source: "/impressum", destination: "/imprint", permanent: true },
|
||||
{ source: "/datenschutz", destination: "/privacy", permanent: true },
|
||||
]);
|
||||
.map((source) => ({ source, destination: "/", permanent: true }));
|
||||
},
|
||||
// In Standalone mode, Next.js expects the tracing root to be the monorepo root
|
||||
outputFileTracingRoot: path.join(dirname, '../../'),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Content strategy: LinkedIn first
|
||||
|
||||
Status: proposal for the owner's review. Nothing has been posted or scheduled. The material exists:
|
||||
six films and twelve image posts, each in German and English, with the text to post
|
||||
(`apps/video`, rendered by `pnpm library` into `out/library`).
|
||||
|
||||
No reach or engagement figures are quoted here: there are none for this account yet. Section 6 says
|
||||
what to measure, and what to change once there are numbers.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Enquiries by email from people who need one of the four services. Not followers, not likes.
|
||||
Every post leads to one action: write to me, via `www.mintel.me`.
|
||||
|
||||
## 2. Who it is for
|
||||
|
||||
| Audience | What they recognise | Films |
|
||||
| :------- | :------------------ | :---- |
|
||||
| Owners and managers of small and mid-sized companies, not technical | Work done by hand between tools; a website that never gets done | Orders, invoices, enquiries, website |
|
||||
| Founders and product leads | An app that has become fragile; a backlog that outgrows the team | Reliable apps, developer |
|
||||
|
||||
German is the main language, as on the website. The English versions are for an English-speaking
|
||||
network and for the developer topic, where teams often work in English.
|
||||
|
||||
## 3. The idea that holds it together
|
||||
|
||||
Every post starts from the same move: something that **looks like** one thing and **is** another.
|
||||
"Looks like ping-pong. It's your office." The viewer gets a small riddle, then recognises their own
|
||||
situation, then sees it resolved. That is the hook, the tone (a little dry, never mocking the
|
||||
client) and the visual language at once: squares, dots, lines, nothing else.
|
||||
|
||||
Three kinds of post carry it:
|
||||
|
||||
| Kind | What it is | Job |
|
||||
| :--- | :--------- | :-- |
|
||||
| Film (6) | 23 seconds, one shot, loops | Gets attention, explains one service to a layperson |
|
||||
| Poster (6) | One picture of the film with two lines: "Not ping-pong. Your office." | Repeats the idea for those who scrolled past the film |
|
||||
| Card (6) | A question clients ask before the first call, and the answer in large type | Removes a reason not to write: lock-in, ownership, "I am not technical" |
|
||||
|
||||
The cards say only what the website says (questions and principles in `apps/web/src/content`).
|
||||
|
||||
## 4. Rhythm
|
||||
|
||||
Proposal: **three posts a week for six weeks**, one topic per week.
|
||||
|
||||
- Tuesday: the film.
|
||||
- Thursday: a card.
|
||||
- Saturday: the poster of the same film.
|
||||
|
||||
This is more than the one post a week in the relaunch concept. The reasoning: one topic is seen
|
||||
three times in a week in three forms, without repeating a post. If three is too many, post only
|
||||
the films weekly and use posters and cards as the second round: the same material then lasts
|
||||
eighteen weeks. Which of the two is the owner's decision.
|
||||
|
||||
Order of topics: automation and websites first (the widest audience and the two planned ad
|
||||
campaigns), the two developer topics later.
|
||||
|
||||
| Week | Topic | Tuesday | Thursday | Saturday |
|
||||
| :--- | :---- | :------ | :------- | :------- |
|
||||
| 1 | Automation: orders | Film "ping-pong" | Card "Not a technical person?" | Poster |
|
||||
| 2 | Websites | Film "a beginning" | Card "Who does the work?" | Poster |
|
||||
| 3 | Automation: invoices | Film "skyscraper" | Card "What do you pay for?" | Poster |
|
||||
| 4 | Reliable apps | Film "Jenga" | Card "Who owns the code?" | Poster |
|
||||
| 5 | Automation: enquiries | Film "a star" | Card "What I do not do" | Poster |
|
||||
| 6 | A developer for your product | Film "loading bar" | Card "How long are you committed?" | Poster |
|
||||
|
||||
The plan is data in `apps/video/src/content/plan.ts`; the overview page `out/library/index.html`
|
||||
shows the posts in this order, with the text to copy and the files to download.
|
||||
|
||||
## 5. How to post
|
||||
|
||||
- Upload the film or image directly to LinkedIn (no link to a video elsewhere), 4:5 format.
|
||||
- The post text opens with the hook of the film. Keep it as written, or shorten it; do not add claims.
|
||||
- The address is in the text and in the picture. If a link is wanted, put it in the first comment.
|
||||
- Answer every comment and message personally. That is where enquiries start.
|
||||
- 16:9 versions are for YouTube and, later, for ads shown to people who already visited the site
|
||||
(see `google-ads.md`).
|
||||
|
||||
## 6. What to measure, and when to change something
|
||||
|
||||
Per post, noted once a week: impressions, comments, profile visits, and above all enquiries by
|
||||
email that mention LinkedIn or follow a post. Clicks on the address are counted on the website
|
||||
once the click event exists (see `google-ads.md`, prerequisite 2).
|
||||
|
||||
After three weeks:
|
||||
- A topic that brought an enquiry or real conversations gets a second film with another example.
|
||||
- A kind of post that is consistently ignored is dropped in favour of the others.
|
||||
- If nothing moves, the rhythm is not the problem: look at who sees the posts (network) first.
|
||||
|
||||
## 7. What comes after the six weeks
|
||||
|
||||
- New examples for the topic that worked, made with the `video-film` skill.
|
||||
- Posts from real work, once a client agrees: what the problem was, what changed. These need the
|
||||
client's consent and are not prepared.
|
||||
- The same material on other channels (X, YouTube) only if LinkedIn shows which topic works.
|
||||
|
||||
## 8. Open decisions for the owner
|
||||
|
||||
1. Three posts a week, or one?
|
||||
2. German only, or both languages (and if both: one account, alternating, or German only on LinkedIn)?
|
||||
3. The offer "make an app reliable" is posted in week 4 without a price. Is the offer final?
|
||||
@@ -1,7 +1,9 @@
|
||||
# mintel.me relaunch concept: freelance engineer site
|
||||
|
||||
Scope: this website only. Goal: a visitor understands what I sell within 10 seconds and
|
||||
books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable hours.
|
||||
Scope: this website, plus the videos made from it (`apps/video`, see section 6). Goal: a visitor understands
|
||||
what I sell within 10 seconds and writes an email within 2 minutes. Low sales overhead, more bookable hours.
|
||||
|
||||
Status note: sections 3 to 6 describe the site as built. Sections 2 and 9 are kept as history and are marked as such.
|
||||
|
||||
## 1. Positioning
|
||||
|
||||
@@ -13,10 +15,10 @@ books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable
|
||||
|
||||
- **Never mention AI as my own tool.** How I work is none of the client's business: no "AI-built", "AI-accelerated", "AI-code rescue", no FAQ about AI tools. The one exception is the automation service, where AI steps are part of what the client gets (sorting, summarising, reading documents).
|
||||
- **Modest and factual.** No promises I have not made (response times, weekly updates, start dates, search ranking). Only state what the owner confirmed. No superlatives.
|
||||
- **No internal method vocabulary** on client-facing pages: no specs, TDD, mutation testing, coverage, pull requests. Translate into what the client gets: changes do not break things, a written update every week, predictable cost. Technical detail belongs on `/process` and `/notes`, for engineers.
|
||||
- Do not say: "everything", "fullstack for anyone", "vibe coding". No location or tax talk; say "remote, available during European business hours".
|
||||
- **No internal method vocabulary** on client-facing pages: no specs, TDD, mutation testing, coverage, pull requests. Translate into what the client gets: changes do not break things, predictable cost.
|
||||
- Do not say: "everything", "fullstack for anyone", "vibe coding". No location or tax talk; say "remote". Not "available during European business hours" (owner, 2026-10-05): he works as a digital nomad without fixed hours; meetings can be arranged, all-day availability cannot. This is said on the freelance page only.
|
||||
- Pick the visitor up: introduce me first, then let them choose their need (developer for a product, make an app reliable, just a website), then explain for that need what they get, how it runs, what it costs.
|
||||
- Language: English primary, German route `/de` for DACH.
|
||||
- Language (owner decision, 2026-10-04): German is the main language at the root, English lives under `/en`. The language is detected from the browser and can be switched in the header. The videos and their post texts exist in both languages too (`apps/video/src/content/videos.ts`).
|
||||
|
||||
### Audiences (priority order)
|
||||
|
||||
@@ -29,7 +31,10 @@ books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable
|
||||
- Offer: websites and web apps (Next.js/TypeScript, strong design), light backend (APIs, auth, integrations), CLI tools and automation, AI agents/MCP servers, architecture and code review, the spec/test/mutation process for teams, Web3 frontends. macOS apps on request only.
|
||||
- Do not offer (state it plainly): Windows apps, mobile apps, running client infrastructure, database-heavy work, email/DNS administration.
|
||||
|
||||
## 2. Differentiator: verified AI speed
|
||||
## 2. Differentiator: verified AI speed (internal, not shown on the site)
|
||||
|
||||
This is how the work is done. Under the copy rules above none of it appears in client-facing copy, and the pages
|
||||
named below (`/process`, the sample report) were not built: the site is one page (section 3).
|
||||
|
||||
Anyone can generate code. The scarce thing is code that survives production.
|
||||
Spec first -> red -> green -> refactor -> mutation check on changed code -> coverage as gap finder -> a delivery report with exact commands and results on every PR.
|
||||
@@ -57,9 +62,10 @@ The old site (`/blog`, `/case-studies`, `/contact`, `/websites`, `/technologies`
|
||||
## 4. Design
|
||||
|
||||
- Light, the original look of the site: white, oversized type, binary texture, one accent (the yellow marker).
|
||||
- The hero headline is built from running binary digits ("SHIP FAST. / BREAK NOTHING."). The owner approved it: do not change the hero design.
|
||||
- The hero headline is built from running binary digits ("WEB APPS / & WEBSITES."). The owner approved the design: do not change it.
|
||||
- One left axis for logo, navigation and content. No sidebars. Few sections, each with one job.
|
||||
- Availability line fed by one constant, shown in the hero, the sticky booking bar and the closing section.
|
||||
- No availability line and no booking bar: both were dropped with the calendar. The hero shows two facts instead (15 years in web; remote, EU core hours).
|
||||
- Dark surfaces carry light: `BitAura` (light along an edge, digits drifting off it), `BitSeam` (lit top edge of a dark section) and `BitGlow` (light following the pointer on `data-glow` surfaces, including the dark button).
|
||||
|
||||
## 5. Offers and pricing
|
||||
|
||||
@@ -82,7 +88,20 @@ Audit of an AI-built codebase, test foundation, mutation baseline, prioritized r
|
||||
|
||||
- Sprint: time-boxed package (example 40 h, 3,800 EUR) with a clear scope list. Extra work is a follow-up block, not open scope. 50% at start, 50% at launch.
|
||||
- Site: static Next.js, no CMS, no server, no database.
|
||||
- Hosting: managed platform (Cloudflare Pages or Vercel) in my account, flat monthly subscription (example 49-99 EUR) including hosting, SSL, domain renewal at cost, 1 h of changes per month. Deployment: git push builds automatically, client approves via a preview URL by email.
|
||||
- Care after the launch (owner decision 2026-10-05, replaces the managed-platform idea): one offer,
|
||||
"Betreuung", 89 EUR per month net, cancel monthly. Hosting on the owner's own servers in Germany (EU
|
||||
hosting matters to German clients; no Vercel or Cloudflare), domain, security and updates, and small
|
||||
changes by email (a text, a picture, opening hours; internally about 30 minutes a month). No CMS and no
|
||||
login for the client. The first three months are included in the project price. Anything bigger (a new
|
||||
page, a new feature) gets a price first. Reply within two working days, no on-call. The client can leave
|
||||
and take everything along.
|
||||
- Larger clients: the same care with all non-editorial changes included, about 250 EUR per month (one
|
||||
existing client pays 3,000 EUR a year). Offered in the quote, not listed on the website.
|
||||
- CMS only as an exception and at extra cost (file-based first; Payload only when the site lives on
|
||||
maintained data such as a product catalogue).
|
||||
- Operations: serve client sites as static files from one shared web server, not one container per client.
|
||||
- Not on the website yet: "green hosting". The owner wants it as an argument; state it only once the
|
||||
provider's proof (renewable energy) is at hand.
|
||||
- Changes: by email or message, no CMS training.
|
||||
- Domain: client is the registrant; transfer-out and source handover on request.
|
||||
- **Email and DNS are out of scope.** Never switch nameservers. Only the two records `A/CNAME` for apex and `www` are changed, after exporting the existing records, and mail delivery is checked afterwards. Mailboxes stay with the client's provider.
|
||||
@@ -92,8 +111,9 @@ Audit of an AI-built codebase, test foundation, mutation baseline, prioritized r
|
||||
## 6. Getting clients (low effort first)
|
||||
|
||||
1. Own network: former colleagues from i22, Trusted Shops, sdox.io, Sevenval. Ten personal messages.
|
||||
2. LinkedIn and X: one post a week from real work (process, numbers, diagrams), one pinned "next free block" post.
|
||||
3. Platforms (freelancermap, freelance.de, Web3 job boards) as a secondary channel.
|
||||
2. LinkedIn first; plan, rhythm and measures in `plans/content-strategy.md`. A set of short looping videos, one per service, with post texts in English and German, is produced in `apps/video` (`node scripts/export.ts` writes `out/library`).
|
||||
3. Google Ads for German clients: search campaigns for "website" and "automation". Concept, ad texts and open prerequisites in `plans/google-ads.md`.
|
||||
4. Platforms (freelancermap, freelance.de, Web3 job boards) as a secondary channel.
|
||||
4. Direct outreach: 10 targeted messages a week with one template.
|
||||
5. Testimonials: one line each from existing clients.
|
||||
|
||||
@@ -110,7 +130,7 @@ Audit of an AI-built codebase, test foundation, mutation baseline, prioritized r
|
||||
- Impressum needs a serviceable address (DDG section 5); settle it with a tax advisor. Do not publish tax, visa or payment-provider details.
|
||||
- Scheinselbststaendigkeit: prefer 15-20 h blocks over one client at 35-40 h.
|
||||
|
||||
## 9. Implementation order
|
||||
## 9. Implementation order (history: superseded by the one-page site in section 3)
|
||||
|
||||
1. Cal.com event and `/book`.
|
||||
2. Spec tests for availability and redirects, then new hero, nav and offers; remove old SMB sections.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# Google Ads concept: German clients at low cost
|
||||
|
||||
Status: draft for the owner's review. Nothing has been created in the Ads account, and no API call has been made.
|
||||
Wording and prices come from the website's German texts (`apps/web/src/content/de.ts`). Search volumes and click prices are **not** in
|
||||
this document: they have to come from the Keyword Planner once API access works (section 7).
|
||||
|
||||
## 1. Goal and principle
|
||||
|
||||
Goal: enquiries by email from German companies, for the two services a layperson searches for:
|
||||
a website, and process automation. Small budget, so every euro goes to people who are already looking.
|
||||
|
||||
- **Search ads first.** Someone who types "Website erstellen lassen" has a need today. Video and display ads
|
||||
reach people who do not, and cost more per enquiry.
|
||||
- **Narrow, then widen.** Few exact search terms, one country, business hours. Widen only what produces enquiries.
|
||||
- **Measure enquiries, not clicks.** Without counting email clicks per search term, nothing can be steered.
|
||||
- The videos (`apps/video`) are for LinkedIn posts and, later, for showing to people who already visited the site.
|
||||
|
||||
## 2. Before the first euro is spent
|
||||
|
||||
| # | Prerequisite | Why | State |
|
||||
| - | :----------- | :-- | :---- |
|
||||
| 1 | German landing page | German ad, German page. | Done: German is the main language of the site since 2026-10-04. Ads lead to the homepage, to the section of the service. Own pages per campaign would convert better, but the site is one page by decision |
|
||||
| 2 | Count email clicks | `EmailLink` does not record a click today. Umami is on the site and needs no cookie banner. | Open: add an event, e.g. `contact_email_click`, with the campaign from the URL |
|
||||
| 3 | Campaign tagging | Every ad URL carries `utm_source=google&utm_medium=cpc&utm_campaign=<name>&utm_term={keyword}` so Umami shows which search term led to an email click. | With the ads |
|
||||
| 4 | Developer token and customer number | The Ads API rejects every call without them. `gcloud` login with the Ads scope and the enabled API exist (project `mintelme-402610`). | Open: owner provides both |
|
||||
| 5 | Payment method and billing country in the Ads account | Cannot be checked from here. | Open: owner |
|
||||
|
||||
On prerequisite 2: Google's own conversion tag would let Google optimise bids by itself, but it sets cookies and
|
||||
needs a consent banner. Recommendation: start without it, judge by Umami, and decide about the tag after the
|
||||
first month.
|
||||
|
||||
## 3. Account structure
|
||||
|
||||
Two search campaigns at the start, one ad group each, so that budget and results stay readable.
|
||||
|
||||
| Setting | Value | Reason |
|
||||
| :------ | :---- | :----- |
|
||||
| Networks | Google Search only; search partners and Display off | Partners and Display bring cheap clicks that rarely enquire |
|
||||
| Location | Germany, "presence" only (people who are there, not people interested in it) | Default setting includes people abroad |
|
||||
| Language | German | |
|
||||
| Schedule | Monday to Friday, 7:00 to 19:00 | Business owners search during working hours; review after two weeks |
|
||||
| Match types | Phrase and exact only, no broad match | Broad match spends a small budget on unrelated searches |
|
||||
| Bidding | Maximise clicks with a click-price cap | There is no conversion data yet for automatic bidding |
|
||||
| Budget | A fixed daily amount per campaign, set by the owner | See section 6 |
|
||||
|
||||
## 4. Campaign "Website"
|
||||
|
||||
Landing page: the German homepage, services section, case "website".
|
||||
|
||||
Search terms (phrase and exact):
|
||||
|
||||
- website erstellen lassen
|
||||
- homepage erstellen lassen
|
||||
- firmenwebsite erstellen lassen
|
||||
- webseite erstellen lassen kosten
|
||||
- website für unternehmen erstellen lassen
|
||||
- webdesigner freelancer
|
||||
- webentwickler freelancer
|
||||
|
||||
Excluded terms: kostenlos, gratis, selbst, selber, baukasten, wix, jimdo, squarespace, wordpress, shopify,
|
||||
onlineshop, vorlage, template, kurs, lernen, ausbildung, studium, job, jobs, stellenangebot, gehalt, praktikum.
|
||||
|
||||
Responsive search ad. Headlines (at most 30 characters each):
|
||||
|
||||
- H: Website erstellen lassen
|
||||
- H: Firmenwebsite vom Entwickler
|
||||
- H: Ab 3.800 € netto
|
||||
- H: 15 Jahre Web-Erfahrung
|
||||
- H: Design, Bau und Hosting
|
||||
- H: Änderungen per E-Mail
|
||||
- H: Kein Baukasten nötig
|
||||
- H: Erstgespräch: 20 Minuten
|
||||
- H: Freelancer statt Agentur
|
||||
- H: Passt auf Handy und Desktop
|
||||
- H: Marc Mintel, Webentwickler
|
||||
- H: Individuelles Design
|
||||
|
||||
Descriptions (at most 90 characters each):
|
||||
|
||||
- D: Sie sagen, was Ihr Unternehmen macht. Ich gestalte, baue und betreue Ihre Website.
|
||||
- D: Ein Design für Ihr Unternehmen statt einer Vorlage. Auf Handy, Tablet und Desktop.
|
||||
- D: Um das Hosting kümmere ich mich. Änderungen schicken Sie mir einfach per E-Mail.
|
||||
- D: Schätzung nach dem ersten Gespräch. Eine typische Firmenwebsite ab 3.800 € netto.
|
||||
|
||||
Sitelinks: "So läuft es ab", "Was es kostet", "Meine Arbeiten", "Kontakt".
|
||||
|
||||
## 5. Campaign "Automatisierung"
|
||||
|
||||
Landing page: the German homepage, services section, case "automation".
|
||||
|
||||
Search terms (phrase and exact):
|
||||
|
||||
- prozesse automatisieren
|
||||
- geschäftsprozesse automatisieren
|
||||
- prozessautomatisierung unternehmen
|
||||
- schnittstelle programmieren lassen
|
||||
- api anbindung programmieren lassen
|
||||
- rechnungen automatisch erfassen
|
||||
- systeme verbinden schnittstelle
|
||||
|
||||
Excluded terms: definition, beispiele, bachelorarbeit, masterarbeit, studium, kurs, schulung, zertifizierung,
|
||||
job, jobs, stellenangebot, gehalt, kostenlos, sap, uipath, power automate, zapier, make.
|
||||
|
||||
The last four are tool names. People searching for them want the tool, not a developer. Review after two weeks:
|
||||
"zapier" or "make" plus "einrichten lassen" could be worth an own ad group.
|
||||
|
||||
Responsive search ad. Headlines:
|
||||
|
||||
- H: Prozesse automatisieren
|
||||
- H: Systeme verbinden lassen
|
||||
- H: Schluss mit Copy-and-paste
|
||||
- H: Schnittstellen vom Entwickler
|
||||
- H: Rechnungen automatisch lesen
|
||||
- H: Von Bestellung bis Rechnung
|
||||
- H: 100 € pro Stunde, netto
|
||||
- H: Erst Schätzung, dann Budget
|
||||
- H: Erstgespräch: 20 Minuten
|
||||
- H: 15 Jahre Web-Erfahrung
|
||||
- H: Ihre Tools, verbunden
|
||||
- H: Keine Anfrage geht verloren
|
||||
|
||||
Descriptions:
|
||||
|
||||
- D: Ich verbinde die Tools, die Sie schon nutzen. Wiederkehrende Schritte laufen von selbst.
|
||||
- D: Daten von Hand von einem Tool ins andere tippen? Das lässt sich meist automatisieren.
|
||||
- D: Ich sehe mir den Ablauf an und schätze den Aufwand. Die Schätzung wird Ihr Budget.
|
||||
- D: Menschen entscheiden die Ausnahmen, die Routine läuft von selbst. Erstgespräch: 20 Min.
|
||||
|
||||
Sitelinks: "Beispiele", "So läuft es ab", "Was es kostet", "Kontakt".
|
||||
|
||||
## 6. Budget and when to stop
|
||||
|
||||
The amounts are the owner's decision. A workable way to set them:
|
||||
|
||||
- Decide what one new client is worth, and how much of that may go into finding them.
|
||||
- Run both campaigns for four weeks at a fixed daily amount. Do not change anything in the first week.
|
||||
- Weekly, 15 minutes: read the search terms report, add wasted terms to the excluded list, pause search terms
|
||||
that got clicks but no email click.
|
||||
- After four weeks: a campaign that produced no enquiry is stopped or rebuilt, not given more budget.
|
||||
- Automatic bidding on enquiries only once there are enough of them to learn from (Google's guidance is in the
|
||||
order of 30 conversions a month; check the current figure before switching).
|
||||
|
||||
## 7. Later steps
|
||||
|
||||
1. **Numbers first.** With API access: pull monthly searches and click-price ranges for the terms above
|
||||
(Keyword Planner), drop terms nobody searches, and set the click-price cap from real figures.
|
||||
2. **Developer campaign.** Terms like "react entwickler freelancer", "next.js entwickler freelance",
|
||||
"frontend entwickler freelancer". Fewer searches, and staffing platforms bid on them. Start only when the
|
||||
first two campaigns are understood. Video: `ProductDeveloper-de`.
|
||||
3. **"Make an app reliable".** Wait until the offer and its price are settled.
|
||||
4. **Videos as ads.** Upload the 16:9 versions (`*-de-wide.mp4`) to YouTube and show them only to people who
|
||||
visited the site in the last 30 days. This needs Google's tag and therefore the consent banner (section 2).
|
||||
|
||||
## 8. What Claude may do with the API
|
||||
|
||||
- Read: list the account, campaigns, search terms and Keyword Planner figures.
|
||||
- Prepare: create campaigns, ad groups, ads and excluded terms **paused**, after the owner approved this document.
|
||||
- Never: enable a campaign, set or raise a budget, or change billing. That stays with the owner.
|
||||
+52
-24
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Server request shell. Reads the incoming request once, classifies it via
|
||||
* the pure domain decision, and records a page view without touching the
|
||||
* response. All analytics happen server-side; no tracking script ships to
|
||||
* the browser.
|
||||
* Server request shell. Reads the incoming request once and does two things,
|
||||
* both decided by pure functions in src/domain:
|
||||
*
|
||||
* 1. Picks the language of the request and maps the public address onto the
|
||||
* app directory (routes.ts).
|
||||
* 2. Records a page view server-side (pageview-decision.ts). No tracking
|
||||
* script ships to the browser.
|
||||
*
|
||||
* Page views are browser navigations (`sec-fetch-dest: document`).
|
||||
* Router-internal headers (RSC, next-router-prefetch) are stripped by the
|
||||
@@ -12,7 +15,12 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
import { recordPageviewToUmami } from "@/src/analytics/recordPageviewToUmami";
|
||||
import { LOCALE_COOKIE } from "@/src/domain/locale";
|
||||
import { decidePageview } from "@/src/domain/pageview-decision";
|
||||
import { routeRequest } from "@/src/domain/routes";
|
||||
|
||||
const PERMANENT = 308;
|
||||
const TEMPORARY = 307;
|
||||
|
||||
const primaryLanguage = (acceptLanguage: string | null): string =>
|
||||
acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||
@@ -22,32 +30,52 @@ const firstForwardedIp = (forwardedFor: string | null): string =>
|
||||
|
||||
const hostnameWithoutPort = (host: string): string => host.replace(/:\d+$/, "");
|
||||
|
||||
export default function proxy(request: NextRequest): NextResponse {
|
||||
const decision = decidePageview({
|
||||
method: request.method,
|
||||
secFetchDest: request.headers.get("sec-fetch-dest") ?? "",
|
||||
export function proxy(request: NextRequest): NextResponse {
|
||||
const route = routeRequest({
|
||||
pathname: request.nextUrl.pathname,
|
||||
search: request.nextUrl.search,
|
||||
hostname: hostnameWithoutPort(
|
||||
request.headers.get("x-forwarded-host") ??
|
||||
request.headers.get("host") ??
|
||||
"",
|
||||
),
|
||||
referrer: request.headers.get("referer") ?? "",
|
||||
language: primaryLanguage(request.headers.get("accept-language")),
|
||||
userAgent: request.headers.get("user-agent") ?? "",
|
||||
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
|
||||
cookie: request.cookies.get(LOCALE_COOKIE)?.value,
|
||||
acceptLanguage: request.headers.get("accept-language"),
|
||||
});
|
||||
|
||||
if (decision.track) {
|
||||
recordPageviewToUmami(decision.facts);
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = route.to;
|
||||
const response =
|
||||
route.kind === "redirect"
|
||||
? NextResponse.redirect(url, route.permanent ? PERMANENT : TEMPORARY)
|
||||
: NextResponse.rewrite(url);
|
||||
|
||||
// The answer depends on the visitor's language: caches must keep one per language.
|
||||
if (route.negotiated) {
|
||||
response.headers.append("Vary", "Accept-Language, Cookie");
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
// A redirect is not a view: the request that follows it is counted instead.
|
||||
if (route.kind !== "redirect") {
|
||||
const pageview = decidePageview({
|
||||
method: request.method,
|
||||
secFetchDest: request.headers.get("sec-fetch-dest") ?? "",
|
||||
// The public address the visitor asked for, not the internal one it maps to.
|
||||
pathname: request.nextUrl.pathname,
|
||||
search: request.nextUrl.search,
|
||||
hostname: hostnameWithoutPort(
|
||||
request.headers.get("x-forwarded-host") ??
|
||||
request.headers.get("host") ??
|
||||
"",
|
||||
),
|
||||
referrer: request.headers.get("referer") ?? "",
|
||||
language: primaryLanguage(request.headers.get("accept-language")),
|
||||
userAgent: request.headers.get("user-agent") ?? "",
|
||||
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
|
||||
});
|
||||
if (pageview.track) {
|
||||
recordPageviewToUmami(pageview.facts);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|.*\\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|mjs|cjs|js|map|txt|xml|json|webmanifest|woff2?|ttf|otf|mp4|webm|mov|pdf|zip)$).*)",
|
||||
],
|
||||
// Pages only: not Next's own files, the error proxy, or files.
|
||||
matcher: ["/((?!_next/|errors/|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://mintel.me/sitemap.xml
|
||||
@@ -1,25 +1,37 @@
|
||||
// Renders the share image (Open Graph and Twitter) from the real hero, so it always
|
||||
// matches the site. Run it after the hero headline or its design changes:
|
||||
// Renders the share images (Open Graph and Twitter) from the real hero, one per
|
||||
// language, so they always match the site. The hero is shown the way the dark
|
||||
// sections of the site look: light type on a dark surface, a lit edge, and
|
||||
// binary digits coming off it. Run it after the hero headline or its design
|
||||
// changes:
|
||||
//
|
||||
// pnpm --filter @mintel/web share-image (uses the live site)
|
||||
// pnpm --filter @mintel/web share-image http://mintel.localhost/
|
||||
// pnpm --filter @mintel/web share-image http://localhost:3000
|
||||
//
|
||||
// It drives the locally installed Chrome through Playwright and writes
|
||||
// app/(site)/opengraph-image.png and app/(site)/twitter-image.png.
|
||||
import { copyFileSync } from "node:fs";
|
||||
// src/assets/share/share-de.png and share-en.png (used by app/[locale]/layout.tsx).
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const url = process.argv[2] ?? "https://mintel.me/";
|
||||
const out = "app/(site)/opengraph-image.png";
|
||||
const origin = (process.argv[2] ?? "https://mintel.me").replace(/\/$/, "");
|
||||
// The browser language matches the page, so the language detection lets it through.
|
||||
const SHOTS = [
|
||||
{ locale: "de", path: "/", browserLocale: "de-DE" },
|
||||
{ locale: "en", path: "/en", browserLocale: "en-US" },
|
||||
];
|
||||
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
await page.goto(url, { waitUntil: "load", timeout: 90000 });
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
header, footer, #hero-cta, [class*="fixed"] { display: none !important; }
|
||||
for (const shot of SHOTS) {
|
||||
const out = `src/assets/share/share-${shot.locale}.png`;
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: shot.browserLocale,
|
||||
});
|
||||
await page.goto(`${origin}${shot.path}`, {
|
||||
waitUntil: "load",
|
||||
timeout: 90000,
|
||||
});
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
header, footer, #hero-cta, [class*="fixed"], nextjs-portal { display: none !important; }
|
||||
main > div > section:first-of-type > div.relative { min-height: 630px !important; height: 630px !important; padding: 0 72px !important; justify-content: center !important; max-width: none !important; }
|
||||
main > div > section:first-of-type ul { display: none !important; }
|
||||
main > div > section:first-of-type h1 { font-size: 150px !important; }
|
||||
@@ -27,28 +39,93 @@ await page.addStyleTag({
|
||||
main > div > section:first-of-type .grid { margin-top: 0 !important; display: block !important; }
|
||||
main > div > section:first-of-type h1 span:nth-of-type(2) { color: rgba(15, 23, 42, 0.2) !important; }
|
||||
main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
|
||||
html, body { overflow: hidden !important; }
|
||||
html, body { overflow: hidden !important; background: #000 !important; }
|
||||
/* Dark version of the hero: inverted, with the hues turned back. */
|
||||
main > div > section:first-of-type { filter: invert(1) hue-rotate(180deg) !important; }
|
||||
`,
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const section = document.querySelector("main section");
|
||||
const tag = document.createElement("div");
|
||||
tag.textContent = "MARC MINTEL";
|
||||
tag.style.cssText =
|
||||
"position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
|
||||
const site = document.createElement("div");
|
||||
site.textContent = "mintel.me";
|
||||
site.style.cssText =
|
||||
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
|
||||
section.style.position = "relative";
|
||||
section.append(tag, site);
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await page.waitForTimeout(4500);
|
||||
await page.screenshot({
|
||||
path: out,
|
||||
clip: { x: 0, y: 0, width: 1200, height: 630 },
|
||||
});
|
||||
});
|
||||
// Let the page finish starting up first; it would discard the added labels.
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(() => {
|
||||
const section = document.querySelector("main section");
|
||||
const tag = document.createElement("div");
|
||||
tag.textContent = "MARC MINTEL";
|
||||
tag.style.cssText =
|
||||
"position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
|
||||
const site = document.createElement("div");
|
||||
site.textContent = "mintel.me";
|
||||
site.style.cssText =
|
||||
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
|
||||
section.style.position = "relative";
|
||||
section.append(tag, site);
|
||||
|
||||
// Everything below lies over the inverted hero and is not inverted itself.
|
||||
const W = 1200;
|
||||
const H = 630;
|
||||
const layer = (css) => {
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText = `position:absolute;left:0;top:0;width:${W}px;height:${H}px;z-index:60;pointer-events:none;${css}`;
|
||||
document.body.append(el);
|
||||
return el;
|
||||
};
|
||||
document.body.style.position = "relative";
|
||||
// Black becomes the slate of the site's dark sections; light type stays light.
|
||||
layer("background:#0f172a;mix-blend-mode:screen");
|
||||
// Light coming from the lower right corner.
|
||||
layer(
|
||||
"mix-blend-mode:screen;background:radial-gradient(58% 62% at 92% 108%, rgba(99,102,241,.85), transparent 70%),radial-gradient(34% 40% at 104% -6%, rgba(129,140,248,.4), transparent 70%)",
|
||||
);
|
||||
// A frame whose lower right edge is lit, with a soft glow behind it.
|
||||
const INSET = 22;
|
||||
const edge = (blur, width, opacity) => {
|
||||
const el = layer(
|
||||
`left:${INSET}px;top:${INSET}px;width:${W - 2 * INSET}px;height:${H - 2 * INSET}px;box-sizing:border-box;border-radius:30px;padding:${width}px;opacity:${opacity};filter:blur(${blur}px);` +
|
||||
"background:conic-gradient(from 60deg at 50% 50%, transparent 0 8%, rgba(129,140,248,.95) 22%, #fff 30%, rgba(129,140,248,.95) 40%, transparent 56% 100%);" +
|
||||
"-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
|
||||
);
|
||||
return el;
|
||||
};
|
||||
layer(
|
||||
`left:${INSET}px;top:${INSET}px;width:${W - 2 * INSET}px;height:${H - 2 * INSET}px;box-sizing:border-box;border-radius:30px;border:1px solid rgba(165,180,252,.16)`,
|
||||
);
|
||||
edge(26, 14, 0.9);
|
||||
edge(8, 5, 0.9);
|
||||
edge(0, 2, 1);
|
||||
|
||||
// Digits coming off the lit edge. Seeded, so the picture is the same on every run.
|
||||
let seed = 20261004;
|
||||
const random = () => {
|
||||
seed = (seed * 1664525 + 1013904223) % 4294967296;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
const digits = layer(
|
||||
"font:500 15px var(--font-jetbrains-mono), ui-monospace, Menlo, monospace;color:#c7d2fe",
|
||||
);
|
||||
for (let i = 0; i < 150; i++) {
|
||||
// Along the bottom edge (right half) and up the right edge (lower half).
|
||||
const alongBottom = random() < 0.62;
|
||||
const spread = Math.pow(random(), 1.8);
|
||||
const x = alongBottom ? W * 0.42 + random() * (W * 0.58 - INSET - 30) : 0;
|
||||
// Under the lead text the digits stay close to the edge.
|
||||
const reach = spread * (alongBottom && x < 820 ? 54 : 120);
|
||||
const left = alongBottom ? x : W - INSET - 14 - reach;
|
||||
const y = alongBottom
|
||||
? H - INSET - 16 - reach
|
||||
: H * 0.3 + random() * (H * 0.7 - INSET - 30);
|
||||
const digit = document.createElement("span");
|
||||
digit.textContent = random() < 0.5 ? "0" : "1";
|
||||
const big = random() < 0.2;
|
||||
digit.style.cssText = `position:absolute;left:${left.toFixed(1)}px;top:${y.toFixed(1)}px;opacity:${((1 - spread) * (0.35 + random() * 0.65)).toFixed(2)};font-size:${big ? 19 : 14}px;font-weight:${big ? 700 : 500}`;
|
||||
digits.append(digit);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await page.waitForTimeout(4500);
|
||||
await page.screenshot({
|
||||
path: out,
|
||||
clip: { x: 0, y: 0, width: 1200, height: 630 },
|
||||
});
|
||||
await page.close();
|
||||
console.log("written:", out);
|
||||
}
|
||||
await browser.close();
|
||||
copyFileSync(out, "app/(site)/twitter-image.png");
|
||||
console.log("written:", out, "and app/(site)/twitter-image.png");
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 277 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
@@ -1,29 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
type ButtonVariant = "primary" | "outline" | "ghost";
|
||||
type ButtonSize = "normal" | "large";
|
||||
|
||||
interface ButtonProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
variant?: "primary" | "outline" | "ghost";
|
||||
size?: "normal" | "large";
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
showArrow?: boolean;
|
||||
onClick?: (e: React.MouseEvent<any>) => void;
|
||||
[key: string]: any;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
const BASE =
|
||||
"group relative inline-flex cursor-pointer items-center justify-center gap-3 rounded-full font-sans font-bold uppercase not-italic no-underline transition-all duration-300";
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
normal: "py-2.5 pl-7 pr-2.5 text-[11px] tracking-[0.18em]",
|
||||
large: "py-3 pl-8 pr-3 text-[12px] tracking-[0.18em]",
|
||||
};
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bit-cta bg-slate-900 text-white shadow-lg shadow-slate-900/10 hover:-translate-y-0.5",
|
||||
outline:
|
||||
"bit-cta bit-cta-quiet border border-slate-300 bg-white text-slate-900 hover:-translate-y-0.5",
|
||||
ghost: "bg-transparent text-slate-500 hover:text-slate-900",
|
||||
};
|
||||
|
||||
/**
|
||||
* Premium Button: Pill-shaped, binary-accent hover effect, unified design.
|
||||
*
|
||||
* On hover:
|
||||
* - A stream of binary characters scrolls across the button background
|
||||
* - Primary: white binary on dark bg
|
||||
* - Outline: blue binary on transparent bg
|
||||
* - Subtle, fast, and satisfying
|
||||
* Button: the pill-shaped call to action. A light travels round its edge
|
||||
* (`bit-cta` in globals.css) and the dark variant carries a spot of light that
|
||||
* follows the pointer (see BitGlow); the label itself stays still.
|
||||
*/
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
href,
|
||||
@@ -33,135 +46,50 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
className = "",
|
||||
showArrow = true,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
const [displayText, setDisplayText] = React.useState<string | null>(null);
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Binary scramble on hover
|
||||
React.useEffect(() => {
|
||||
if (!hovered) {
|
||||
setDisplayText(null);
|
||||
return;
|
||||
}
|
||||
const original = contentRef.current?.textContent || "";
|
||||
if (!original) return;
|
||||
const chars = original.split("");
|
||||
const total = 20;
|
||||
let frame = 0;
|
||||
const iv = setInterval(() => {
|
||||
frame++;
|
||||
const s = chars.map((c, i) => {
|
||||
if (c === " ") return " ";
|
||||
const settle = (frame / total) * chars.length;
|
||||
if (i < settle) return c;
|
||||
return Math.random() > 0.5 ? "1" : "0";
|
||||
});
|
||||
setDisplayText(s.join(""));
|
||||
if (frame >= total) {
|
||||
setDisplayText(null);
|
||||
clearInterval(iv);
|
||||
}
|
||||
}, 1000 / 60);
|
||||
return () => clearInterval(iv);
|
||||
}, [hovered]);
|
||||
|
||||
const [binaryStr, setBinaryStr] = React.useState(
|
||||
"0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1",
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setBinaryStr(
|
||||
Array.from({ length: 60 }, () => (Math.random() > 0.5 ? "0" : "1")).join(
|
||||
" ",
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const base =
|
||||
"relative inline-flex items-center justify-center gap-3 overflow-hidden rounded-full font-bold uppercase tracking-[0.15em] transition-all duration-300 group cursor-pointer";
|
||||
|
||||
const sizes: Record<string, string> = {
|
||||
normal: "px-8 py-4 text-[10px]",
|
||||
large: "px-10 py-5 text-[11px]",
|
||||
};
|
||||
|
||||
const variants: Record<string, string> = {
|
||||
primary:
|
||||
"bg-slate-900 text-white hover:shadow-xl hover:shadow-slate-900/20 hover:-translate-y-0.5",
|
||||
outline:
|
||||
"border border-slate-200 bg-transparent text-slate-900 hover:border-slate-400 hover:-translate-y-0.5",
|
||||
ghost: "bg-transparent text-slate-500 hover:text-slate-900",
|
||||
};
|
||||
|
||||
// Binary stream overlay colors by variant
|
||||
const binaryColor =
|
||||
variant === "primary"
|
||||
? "rgba(255,255,255,0.12)"
|
||||
: variant === "outline"
|
||||
? "rgba(59,130,246,0.15)"
|
||||
: "rgba(148,163,184,0.15)";
|
||||
|
||||
const inner = (
|
||||
<motion.span
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
|
||||
<span
|
||||
data-glow={variant === "primary" ? "" : undefined}
|
||||
className={`${BASE} ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||
>
|
||||
{/* Binary stream hover overlay */}
|
||||
<span
|
||||
className="absolute inset-0 flex items-center pointer-events-none overflow-hidden"
|
||||
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.3s ease" }}
|
||||
>
|
||||
<motion.span
|
||||
className="whitespace-nowrap font-mono text-xs tracking-[0.4em] select-none"
|
||||
style={{ color: binaryColor }}
|
||||
animate={hovered ? { x: [0, -200] } : { x: 0 }}
|
||||
transition={
|
||||
hovered ? { duration: 3, repeat: Infinity, ease: "linear" } : {}
|
||||
}
|
||||
>
|
||||
{binaryStr} {binaryStr}
|
||||
</motion.span>
|
||||
</span>
|
||||
|
||||
{/* Shimmer line on hover (top edge) */}
|
||||
<span
|
||||
className="absolute top-0 left-0 right-0 h-px pointer-events-none"
|
||||
style={{
|
||||
opacity: hovered ? 1 : 0,
|
||||
transition: "opacity 0.5s ease",
|
||||
background:
|
||||
variant === "primary"
|
||||
? "linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)"
|
||||
: "linear-gradient(90deg, transparent, rgba(59,130,246,0.15), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<span
|
||||
className="relative z-10 flex items-center gap-3"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
<span ref={contentRef}>{displayText ?? children}</span>
|
||||
{variant === "primary" && (
|
||||
<span className="bit-glow bit-glow-spot" aria-hidden="true" />
|
||||
)}
|
||||
<span className="relative z-10 flex items-center gap-4">
|
||||
{children}
|
||||
{showArrow && (
|
||||
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform duration-300" />
|
||||
<span
|
||||
className={`grid h-8 w-8 place-items-center rounded-full transition-colors duration-300 ${
|
||||
variant === "primary"
|
||||
? "bg-white/10 group-hover:bg-white group-hover:text-slate-900"
|
||||
: "bg-slate-100 group-hover:bg-slate-900 group-hover:text-white"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ArrowRight
|
||||
className="h-3.5 w-3.5 transition-transform duration-300 group-hover:translate-x-0.5"
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (href.startsWith("#")) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
if (onClick) onClick(e);
|
||||
e.preventDefault();
|
||||
document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
|
||||
className="no-underline"
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
event.preventDefault();
|
||||
if (href.length > 1) {
|
||||
document
|
||||
.querySelector(href)
|
||||
?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
@@ -169,7 +97,7 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} onClick={onClick} {...props}>
|
||||
<Link href={href} onClick={onClick} className="no-underline">
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { Content } from "@/src/content/types";
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
|
||||
type Value = { readonly locale: Locale; readonly content: Content };
|
||||
|
||||
const ContentContext = React.createContext<Value | null>(null);
|
||||
|
||||
/**
|
||||
* ContentProvider: hands the language of the page and its texts to the client
|
||||
* components. The layout picks both; only the texts of that one language are
|
||||
* sent to the browser.
|
||||
*/
|
||||
export const ContentProvider: React.FC<
|
||||
Value & { children: React.ReactNode }
|
||||
> = ({ locale, content, children }) => {
|
||||
const value = React.useMemo(() => ({ locale, content }), [locale, content]);
|
||||
return (
|
||||
<ContentContext.Provider value={value}>{children}</ContentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useValue = (): Value => {
|
||||
const value = React.useContext(ContentContext);
|
||||
if (value === null) {
|
||||
throw new Error("useContent and useLocale need a ContentProvider above.");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const useContent = (): Content => useValue().content;
|
||||
export const useLocale = (): Locale => useValue().locale;
|
||||
@@ -56,10 +56,23 @@ export const AbstractCircuit: React.FC<{
|
||||
// ── Seed characters ──
|
||||
const LANE = 28;
|
||||
const GAP = 20;
|
||||
const PHONE_WIDTH = 640;
|
||||
const PHONE_INK = 2.4;
|
||||
// On white the digits were close to invisible (owner feedback).
|
||||
const DESKTOP_INK = 2.1;
|
||||
/** Digits near the pointer light up in the indigo of the site. */
|
||||
const POINTER_RADIUS = 190;
|
||||
let pointer: { x: number; y: number } | null = null;
|
||||
const PHONE_GROW = 2;
|
||||
|
||||
const seed = () => {
|
||||
const chars: Char[] = [];
|
||||
const { w, h } = s;
|
||||
// On a phone the field is small and held close: the digits need more
|
||||
// size and ink there, or they are not seen at all.
|
||||
const small = w < PHONE_WIDTH;
|
||||
const ink = small ? PHONE_INK : DESKTOP_INK;
|
||||
const grow = small ? PHONE_GROW : 0;
|
||||
|
||||
// Horizontal lanes
|
||||
for (let y = 0; y < h; y += LANE) {
|
||||
@@ -73,8 +86,8 @@ export const AbstractCircuit: React.FC<{
|
||||
val: Math.random() > 0.5 ? 1 : 0,
|
||||
speed: spd,
|
||||
vertical: false,
|
||||
size: 9 + Math.floor(Math.random() * 2),
|
||||
baseAlpha: 0.035 + Math.random() * 0.045,
|
||||
size: 9 + grow + Math.floor(Math.random() * 2),
|
||||
baseAlpha: (0.035 + Math.random() * 0.045) * ink,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
flipIn: 120 + Math.floor(Math.random() * 400),
|
||||
});
|
||||
@@ -93,8 +106,8 @@ export const AbstractCircuit: React.FC<{
|
||||
val: Math.random() > 0.5 ? 1 : 0,
|
||||
speed: spd,
|
||||
vertical: true,
|
||||
size: 8 + Math.floor(Math.random() * 2),
|
||||
baseAlpha: 0.025 + Math.random() * 0.035,
|
||||
size: 8 + grow + Math.floor(Math.random() * 2),
|
||||
baseAlpha: (0.025 + Math.random() * 0.035) * ink,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
flipIn: 150 + Math.floor(Math.random() * 500),
|
||||
});
|
||||
@@ -140,14 +153,24 @@ export const AbstractCircuit: React.FC<{
|
||||
const breath = Math.sin(t + c.phase) * 0.015;
|
||||
|
||||
let alpha = c.baseAlpha + breath;
|
||||
const near = pointer
|
||||
? Math.max(
|
||||
0,
|
||||
1 - Math.hypot(c.x - pointer.x, c.y - pointer.y) / POINTER_RADIUS,
|
||||
)
|
||||
: 0;
|
||||
alpha += near * 0.75;
|
||||
|
||||
// ── Draw ──
|
||||
const sz = c.size;
|
||||
ctx.font = `bold ${sz}px "SF Mono", "Fira Code", "Cascadia Code", "Menlo", monospace`;
|
||||
|
||||
const shade = Math.min(1, Math.max(alpha, 0));
|
||||
ctx.fillStyle = invert
|
||||
? `rgba(255,255,255,${Math.max(alpha, 0)})`
|
||||
: `rgba(100,116,139,${Math.max(alpha, 0)})`;
|
||||
? `rgba(255,255,255,${shade})`
|
||||
: near > 0.05
|
||||
? `rgba(79,70,229,${shade})`
|
||||
: `rgba(100,116,139,${shade})`;
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
@@ -159,7 +182,15 @@ export const AbstractCircuit: React.FC<{
|
||||
|
||||
// ── Event listeners ──
|
||||
// Listen on the PARENT element (the section) to capture all mouse movement
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const box = canvas.getBoundingClientRect();
|
||||
pointer =
|
||||
event.pointerType === "mouse"
|
||||
? { x: event.clientX - box.left, y: event.clientY - box.top }
|
||||
: null;
|
||||
};
|
||||
window.addEventListener("resize", resize);
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
|
||||
resize();
|
||||
raf = requestAnimationFrame(render);
|
||||
@@ -167,6 +198,7 @@ export const AbstractCircuit: React.FC<{
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
};
|
||||
}, [invert]);
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import { BinaryTicker } from "./bits/BinaryTicker";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import { LanguageSwitch } from "./LanguageSwitch";
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const locale = useLocale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
@@ -38,24 +45,31 @@ export const Footer: React.FC = () => {
|
||||
<span>© {currentYear}</span>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 md:justify-end">
|
||||
<Link
|
||||
href="/#contact"
|
||||
href={`${pathFor("home", locale)}#contact`}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Get in touch
|
||||
{nav.contactLong}
|
||||
</Link>
|
||||
<Link
|
||||
href="/imprint"
|
||||
href={pathFor("freelance", locale)}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Imprint
|
||||
{nav.freelance}
|
||||
</Link>
|
||||
<Link
|
||||
href="/privacy"
|
||||
href={pathFor("imprint", locale)}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Privacy
|
||||
{nav.imprint}
|
||||
</Link>
|
||||
<Link
|
||||
href={pathFor("privacy", locale)}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
{nav.privacy}
|
||||
</Link>
|
||||
</div>
|
||||
<LanguageSwitch variant="text" />
|
||||
{/* Subtle binary decoration */}
|
||||
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
|
||||
{[77, 73, 78].map((code) => (
|
||||
|
||||
@@ -3,12 +3,19 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
||||
import { DecodeText } from "./bits/DecodeText";
|
||||
import { ScrollBits } from "./bits/ScrollBits";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import { LanguageSwitch } from "./LanguageSwitch";
|
||||
|
||||
/** Header: the logo and one action. Everything else lives on the homepage. */
|
||||
/** Header: the logo, the link to the freelance page, the language switch and one action. */
|
||||
export const Header: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const locale = useLocale();
|
||||
const home = pathFor("home", locale);
|
||||
const freelance = pathFor("freelance", locale);
|
||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -32,11 +39,11 @@ export const Header: React.FC = () => {
|
||||
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 md:px-12 py-4 flex items-center justify-between relative z-10">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Marc Mintel, back to top"
|
||||
href={home}
|
||||
aria-label={nav.logoLabel}
|
||||
onClick={(event) => {
|
||||
// Already on the homepage: scroll up instead of reloading the route.
|
||||
if (window.location.pathname === "/") {
|
||||
if (window.location.pathname === home) {
|
||||
event.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
@@ -56,15 +63,24 @@ export const Header: React.FC = () => {
|
||||
|
||||
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
|
||||
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
style={{
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<DecodeText text="Get in touch" trigger="hover" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5 md:gap-4">
|
||||
<Link
|
||||
href={freelance}
|
||||
className="hidden font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 no-underline transition-colors duration-300 hover:text-slate-900 md:block"
|
||||
>
|
||||
{nav.freelance}
|
||||
</Link>
|
||||
<LanguageSwitch />
|
||||
<Link
|
||||
href={`${home}#contact`}
|
||||
className="bit-cta bit-cta-quiet font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
style={{
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<DecodeText text={nav.contact} trigger="hover" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useContent } from "./ContentProvider";
|
||||
|
||||
export const InteractiveElements: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
|
||||
@@ -42,7 +44,7 @@ export const InteractiveElements: React.FC = () => {
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`floating-back-to-top ${showBackToTop ? "visible" : ""}`}
|
||||
aria-label="Back to top"
|
||||
aria-label={nav.backToTop}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { LOCALES, localeCookie, type Locale } from "@/src/domain/locale";
|
||||
import { switchPath } from "@/src/domain/routes";
|
||||
import { DecodeText } from "./bits/DecodeText";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import {
|
||||
getIdleState,
|
||||
getSwitchState,
|
||||
startSwitch,
|
||||
subscribe,
|
||||
} from "./locale-switch-store";
|
||||
|
||||
const EASE = "cubic-bezier(0.23, 1, 0.32, 1)";
|
||||
|
||||
/**
|
||||
* LanguageSwitch: the two languages side by side, with a marker that slides to
|
||||
* the chosen one. Choosing a language closes the bit curtain (see BitCurtain),
|
||||
* swaps the page behind it and keeps the reading position. The choice is stored
|
||||
* in a cookie so that the language detection does not undo it.
|
||||
*
|
||||
* The options are plain links to the other language's address, so the switch
|
||||
* also works without JavaScript and for search engines.
|
||||
*/
|
||||
export const LanguageSwitch: React.FC<{
|
||||
/** `pill` for the header, `text` for the footer. */
|
||||
variant?: "pill" | "text";
|
||||
}> = ({ variant = "pill" }) => {
|
||||
const locale = useLocale();
|
||||
const { nav } = useContent();
|
||||
const pathname = usePathname();
|
||||
const state = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSwitchState,
|
||||
getIdleState,
|
||||
);
|
||||
// The marker moves as soon as a language is chosen, before the page follows.
|
||||
const shown = state.phase === "idle" ? locale : state.target;
|
||||
|
||||
const choose = (target: Locale) => (event: React.MouseEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (target === locale) return;
|
||||
document.cookie = localeCookie(target);
|
||||
startSwitch(locale, target, switchPath(pathname, target));
|
||||
};
|
||||
|
||||
if (variant === "text") {
|
||||
return (
|
||||
<nav aria-label={nav.language} className="flex gap-x-4">
|
||||
{LOCALES.map((option) => (
|
||||
<a
|
||||
key={option}
|
||||
href={switchPath(pathname, option)}
|
||||
hrefLang={option}
|
||||
lang={option}
|
||||
aria-current={option === locale ? "true" : undefined}
|
||||
onClick={choose(option)}
|
||||
className={`no-underline transition-colors duration-300 hover:text-slate-900 ${
|
||||
option === locale ? "text-slate-900" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{nav.languageNames[option]}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={nav.language}
|
||||
className="group/lang relative flex items-center rounded-full border border-slate-200 bg-white/70 p-[3px] font-mono text-[10px] font-bold tracking-[0.2em] transition-colors duration-500 hover:border-slate-400"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-[3px] left-[3px] w-[calc(50%-3px)] rounded-full bg-slate-900 shadow-sm transition-transform duration-500"
|
||||
style={{
|
||||
transform: `translateX(${LOCALES.indexOf(shown) * 100}%)`,
|
||||
transitionTimingFunction: EASE,
|
||||
}}
|
||||
/>
|
||||
{LOCALES.map((option) => (
|
||||
<a
|
||||
key={option}
|
||||
href={switchPath(pathname, option)}
|
||||
hrefLang={option}
|
||||
lang={option}
|
||||
aria-label={nav.languageNames[option]}
|
||||
aria-current={option === locale ? "true" : undefined}
|
||||
title={nav.languageNames[option]}
|
||||
onClick={choose(option)}
|
||||
className={`relative z-10 grid h-8 w-10 place-items-center rounded-full pl-[0.2em] no-underline transition-colors duration-300 md:h-9 md:w-11 ${
|
||||
option === shown
|
||||
? "text-white"
|
||||
: "text-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<DecodeText text={option.toUpperCase()} trigger="hover" />
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* SwipeRow: on phones the items stand side by side and are swiped through,
|
||||
* with the next one peeking in; from tablet width on they fall back into the
|
||||
* grid given by `className`. It keeps long lists of equal items from
|
||||
* stretching the page on a small screen. Styles: `swipe-row` in globals.css.
|
||||
*/
|
||||
export const SwipeRow: React.FC<{
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ className = "", children }) => (
|
||||
<div className={`swipe-row ${className}`}>
|
||||
{React.Children.map(children, (child) => (
|
||||
<div className="swipe-slide">{child}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type AuraSource = "top" | "right" | "bottom" | "left" | "around";
|
||||
type AuraTone = "dark" | "light";
|
||||
/** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it. */
|
||||
type AuraMotion = "sparks" | "rain";
|
||||
|
||||
interface BitAuraProps {
|
||||
/** The edge the energy sits on, or `around` to let it travel round the element. */
|
||||
source: AuraSource;
|
||||
/** The background the element stands on: `dark` adds light, `light` only ink digits. */
|
||||
tone: AuraTone;
|
||||
motion?: AuraMotion;
|
||||
/** Share of the usual amount of digits (1 = default). */
|
||||
density?: number;
|
||||
/** Corner radius of the lit element in px. */
|
||||
radius?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** A bright zone on the outline, placed by its position along the perimeter (0..1). */
|
||||
interface Spot {
|
||||
at: number;
|
||||
power: number;
|
||||
}
|
||||
|
||||
interface Digit {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
age: number;
|
||||
life: number;
|
||||
seed: number;
|
||||
power: number;
|
||||
large: boolean;
|
||||
/** Outward normal at the point of the outline the digit belongs to. */
|
||||
nx: number;
|
||||
ny: number;
|
||||
}
|
||||
|
||||
interface Palette {
|
||||
blend: GlobalCompositeOperation;
|
||||
glow: string;
|
||||
core: string;
|
||||
digit: string;
|
||||
gain: number;
|
||||
/** Share of the digit sparks that are kept (1 = all). */
|
||||
density: number;
|
||||
}
|
||||
|
||||
const MARGIN = 180;
|
||||
const FRAME_MS = 1000 / 30;
|
||||
const MAX_STEP_S = 0.1;
|
||||
const MAX_PIXEL_RATIO = 2;
|
||||
/** `rounded-3xl` in this project's Tailwind theme. */
|
||||
const DEFAULT_RADIUS = 32;
|
||||
const STILL_FRAME_STEPS = 90;
|
||||
|
||||
/** The glow is the outline stroked from wide and faint to thin and bright. */
|
||||
const GLOW_LAYERS = [
|
||||
{ width: 150, alpha: 0.035, hot: false },
|
||||
{ width: 110, alpha: 0.05, hot: false },
|
||||
{ width: 78, alpha: 0.07, hot: false },
|
||||
{ width: 52, alpha: 0.1, hot: false },
|
||||
{ width: 32, alpha: 0.15, hot: false },
|
||||
{ width: 18, alpha: 0.22, hot: false },
|
||||
{ width: 7, alpha: 0.45, hot: true },
|
||||
{ width: 2, alpha: 0.95, hot: true },
|
||||
] as const;
|
||||
|
||||
const LIGHT_PALETTE: Palette = {
|
||||
blend: "source-over",
|
||||
glow: "15, 23, 42",
|
||||
core: "15, 23, 42",
|
||||
digit: "79, 70, 229",
|
||||
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
|
||||
gain: 0,
|
||||
density: 0.6,
|
||||
};
|
||||
|
||||
const DARK_PALETTE: Palette = {
|
||||
blend: "lighter",
|
||||
glow: "99, 102, 241",
|
||||
core: "224, 231, 255",
|
||||
digit: "199, 210, 254",
|
||||
gain: 1,
|
||||
density: 1,
|
||||
};
|
||||
|
||||
const MOTION_DENSITY: Record<AuraMotion, number> = {
|
||||
sparks: 0.45,
|
||||
rain: 0.3,
|
||||
};
|
||||
|
||||
/** How often a drifting digit switches between 0 and 1. */
|
||||
const FLIPS_PER_S = 0.9;
|
||||
|
||||
const fraction = (value: number) => value - Math.floor(value);
|
||||
|
||||
const perimeter = ({ width, height }: Box) => 2 * (width + height);
|
||||
|
||||
/** Point and outward normal on the outline, walking clockwise from the top left corner. */
|
||||
const outlinePoint = (at: number, { width, height }: Box) => {
|
||||
let distance = fraction(at) * 2 * (width + height);
|
||||
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
|
||||
distance -= width;
|
||||
if (distance < height) return { x: width, y: distance, nx: 1, ny: 0 };
|
||||
distance -= height;
|
||||
if (distance < width) return { x: width - distance, y: height, nx: 0, ny: 1 };
|
||||
distance -= width;
|
||||
return { x: 0, y: height - distance, nx: -1, ny: 0 };
|
||||
};
|
||||
|
||||
/** The stretch of the perimeter (0..1) that one edge covers. */
|
||||
const edgeRange = (
|
||||
source: Exclude<AuraSource, "around">,
|
||||
box: Box,
|
||||
): readonly [number, number] => {
|
||||
const total = perimeter(box);
|
||||
const top = box.width / total;
|
||||
const right = (box.width + box.height) / total;
|
||||
const bottom = (2 * box.width + box.height) / total;
|
||||
if (source === "top") return [0, top];
|
||||
if (source === "right") return [top, right];
|
||||
if (source === "bottom") return [right, bottom];
|
||||
return [bottom, 1];
|
||||
};
|
||||
|
||||
/** Where the energy is at time `t` (seconds): it wanders along one edge or circles the element. */
|
||||
const spotsAt = (
|
||||
source: AuraSource,
|
||||
motion: AuraMotion,
|
||||
t: number,
|
||||
box: Box,
|
||||
): readonly Spot[] => {
|
||||
if (source === "around") {
|
||||
return [
|
||||
{ at: fraction(t * 0.03), power: 1 },
|
||||
{ at: fraction(t * 0.03 + 0.5), power: 0.7 },
|
||||
{ at: fraction(1.2 - t * 0.018), power: 0.5 },
|
||||
];
|
||||
}
|
||||
const [from, to] = edgeRange(source, box);
|
||||
const along = (share: number) => from + (to - from) * share;
|
||||
return [
|
||||
{
|
||||
at: along(0.5 + 0.32 * Math.sin(t * 0.5)),
|
||||
power: 0.85 + 0.15 * Math.sin(t * 0.9),
|
||||
},
|
||||
{ at: along(0.5 + 0.4 * Math.sin(t * 0.31 + 2)), power: 0.6 },
|
||||
{
|
||||
at: along(0.5 + 0.45 * Math.sin(t * 0.83 + 4)),
|
||||
power: 0.35 + 0.2 * Math.sin(t * 1.3),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const spawnDigit = (
|
||||
spots: readonly Spot[],
|
||||
motion: AuraMotion,
|
||||
box: Box,
|
||||
): Digit => {
|
||||
const total = spots.reduce((sum, spot) => sum + spot.power, 0);
|
||||
let pick = Math.random() * total;
|
||||
const spot =
|
||||
spots.find((candidate) => (pick -= candidate.power) <= 0) ?? spots[0];
|
||||
const power = spot?.power ?? 1;
|
||||
const base = {
|
||||
age: 0,
|
||||
seed: Math.random(),
|
||||
power,
|
||||
large: Math.random() < 0.22,
|
||||
};
|
||||
|
||||
const wide = motion === "rain" ? 0.12 : 0.07;
|
||||
const scatter = (Math.random() + Math.random() + Math.random() - 1.5) * wide;
|
||||
const point = outlinePoint((spot?.at ?? 0) + scatter, box);
|
||||
|
||||
if (motion === "rain") {
|
||||
const distance = 40 + Math.random() * 80;
|
||||
const speed = 22 + Math.random() * 26;
|
||||
return {
|
||||
...base,
|
||||
x: point.x + point.nx * distance,
|
||||
y: point.y + point.ny * distance,
|
||||
vx: -point.nx * speed,
|
||||
vy: -point.ny * speed,
|
||||
nx: point.nx,
|
||||
ny: point.ny,
|
||||
life: distance / speed,
|
||||
};
|
||||
}
|
||||
|
||||
const speed = 10 + Math.random() * 24;
|
||||
const slide = (Math.random() - 0.5) * 12;
|
||||
return {
|
||||
...base,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
vx: point.nx * speed - point.ny * slide,
|
||||
vy: point.ny * speed + point.nx * slide,
|
||||
nx: point.nx,
|
||||
ny: point.ny,
|
||||
life: 2.4 + Math.random() * 2.6,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* BitAura: energy that hugs the outline of the element it wraps. The glow flows
|
||||
* along the edge and binary digits drift off it like sparks. Decorative only:
|
||||
* it runs while in view and stands still when reduced motion is requested.
|
||||
*/
|
||||
export const BitAura: React.FC<BitAuraProps> = ({
|
||||
source,
|
||||
tone,
|
||||
motion = "sparks",
|
||||
density = 1,
|
||||
radius = DEFAULT_RADIUS,
|
||||
className = "",
|
||||
children,
|
||||
}) => {
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!wrapper || !canvas || !context) return;
|
||||
|
||||
const palette = tone === "light" ? LIGHT_PALETTE : DARK_PALETTE;
|
||||
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const fontFamily = window.getComputedStyle(canvas).fontFamily;
|
||||
let box: Box = { width: 0, height: 0 };
|
||||
let digits: Digit[] = [];
|
||||
let owed = 0;
|
||||
let clock = Math.random() * 40;
|
||||
let last = 0;
|
||||
let frame = 0;
|
||||
let visible = false;
|
||||
|
||||
const step = (seconds: number) => {
|
||||
clock += seconds;
|
||||
const spots = spotsAt(source, motion, clock, box);
|
||||
const rate =
|
||||
Math.min(90, Math.max(28, perimeter(box) / 30)) *
|
||||
palette.density *
|
||||
MOTION_DENSITY[motion] *
|
||||
density;
|
||||
owed += rate * seconds;
|
||||
while (owed >= 1) {
|
||||
digits.push(spawnDigit(spots, motion, box));
|
||||
owed -= 1;
|
||||
}
|
||||
const drag = 1 - 0.6 * seconds;
|
||||
digits = digits.filter((digit) => {
|
||||
digit.age += seconds;
|
||||
if (motion !== "rain") {
|
||||
digit.vx *= drag;
|
||||
digit.vy = digit.vy * drag - 7 * seconds;
|
||||
}
|
||||
digit.x += digit.vx * seconds;
|
||||
digit.y += digit.vy * seconds;
|
||||
return digit.age < digit.life;
|
||||
});
|
||||
return spots;
|
||||
};
|
||||
|
||||
const draw = (spots: readonly Spot[]) => {
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
|
||||
context.setTransform(ratio, 0, 0, ratio, MARGIN * ratio, MARGIN * ratio);
|
||||
context.clearRect(
|
||||
-MARGIN,
|
||||
-MARGIN,
|
||||
box.width + 2 * MARGIN,
|
||||
box.height + 2 * MARGIN,
|
||||
);
|
||||
context.globalCompositeOperation = palette.blend;
|
||||
context.globalAlpha = 1;
|
||||
|
||||
context.beginPath();
|
||||
context.roundRect(0, 0, box.width, box.height, radius);
|
||||
const reach = Math.min(560, Math.max(220, perimeter(box) * 0.2));
|
||||
for (const spot of palette.gain > 0 ? spots : []) {
|
||||
const point = outlinePoint(spot.at, box);
|
||||
for (const layer of GLOW_LAYERS) {
|
||||
const colour = layer.hot ? palette.core : palette.glow;
|
||||
const light = context.createRadialGradient(
|
||||
point.x,
|
||||
point.y,
|
||||
0,
|
||||
point.x,
|
||||
point.y,
|
||||
reach,
|
||||
);
|
||||
light.addColorStop(
|
||||
0,
|
||||
`rgba(${colour}, ${layer.alpha * spot.power * palette.gain})`,
|
||||
);
|
||||
light.addColorStop(1, `rgba(${colour}, 0)`);
|
||||
context.strokeStyle = light;
|
||||
context.lineWidth = layer.width;
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
context.fillStyle = `rgb(${palette.digit})`;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
for (const large of [false, true]) {
|
||||
context.font = `${large ? 700 : 500} ${large ? 14 : 11}px ${fontFamily}`;
|
||||
for (const digit of digits) {
|
||||
if (digit.large !== large) continue;
|
||||
const fade = Math.sin((Math.PI * digit.age) / digit.life);
|
||||
context.globalAlpha = Math.min(1, fade * digit.power * 1.1);
|
||||
const one =
|
||||
Math.floor(digit.age * FLIPS_PER_S + digit.seed * 10) % 2 === 0;
|
||||
context.fillText(one ? "1" : "0", digit.x, digit.y);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
box = { width: rect.width, height: rect.height };
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
|
||||
canvas.width = Math.round((box.width + 2 * MARGIN) * ratio);
|
||||
canvas.height = Math.round((box.height + 2 * MARGIN) * ratio);
|
||||
if (still) {
|
||||
digits = [];
|
||||
let spots = spotsAt(source, motion, clock, box);
|
||||
for (let i = 0; i < STILL_FRAME_STEPS; i++)
|
||||
spots = step(FRAME_MS / 1000);
|
||||
draw(spots);
|
||||
}
|
||||
};
|
||||
|
||||
const tick = (now: number) => {
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
if (now - last < FRAME_MS) return;
|
||||
const seconds = Math.min(MAX_STEP_S, (now - last) / 1000);
|
||||
last = now;
|
||||
draw(step(seconds));
|
||||
};
|
||||
|
||||
const sizeObserver = new ResizeObserver(resize);
|
||||
sizeObserver.observe(wrapper);
|
||||
resize();
|
||||
|
||||
const viewObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const next = entry?.isIntersecting ?? false;
|
||||
if (next === visible || still) return;
|
||||
visible = next;
|
||||
if (visible) {
|
||||
last = performance.now();
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
} else {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
},
|
||||
{ rootMargin: `${MARGIN}px` },
|
||||
);
|
||||
viewObserver.observe(wrapper);
|
||||
|
||||
return () => {
|
||||
sizeObserver.disconnect();
|
||||
viewObserver.disconnect();
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [source, tone, motion, density, radius]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
// The wrapper has to be positioned; keep the caller's own positioning if it brings one.
|
||||
className={
|
||||
className.split(" ").includes("absolute")
|
||||
? className
|
||||
: `relative ${className}`
|
||||
}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="bit-aura pointer-events-none absolute font-mono"
|
||||
style={{
|
||||
left: -MARGIN,
|
||||
top: -MARGIN,
|
||||
width: `calc(100% + ${2 * MARGIN}px)`,
|
||||
height: `calc(100% + ${2 * MARGIN}px)`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative h-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import {
|
||||
endSwitch,
|
||||
enterPhase,
|
||||
getIdleState,
|
||||
getSwitchState,
|
||||
subscribe,
|
||||
type SwitchState,
|
||||
} from "@/src/components/locale-switch-store";
|
||||
import {
|
||||
captureAnchor,
|
||||
restoreAnchor,
|
||||
type SectionBox,
|
||||
} from "@/src/domain/scroll-anchor";
|
||||
|
||||
const COVER_MS = 460;
|
||||
const REVEAL_MS = 560;
|
||||
/** The curtain stays closed at least this long, so the language name can be read. */
|
||||
const HOLD_MS = 320;
|
||||
const LABEL_DELAY_MS = 140;
|
||||
const LABEL_MS = 420;
|
||||
/** If the new page has not arrived by then, load it the plain way. */
|
||||
const GIVE_UP_MS = 6000;
|
||||
|
||||
const CELL = 16;
|
||||
/** How ragged the edge of the curtain is, as a share of the screen width. */
|
||||
const SPREAD = 0.24;
|
||||
const TRAIL = 11;
|
||||
|
||||
const INK = "#0f172a"; // slate-900
|
||||
const MARK = "#fde68a";
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
const easeInOut = (t: number): number =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
/** A fixed pseudo-random number per row, so the edge looks the same after the page swap. */
|
||||
const rowLag = (row: number): number => {
|
||||
const x = Math.sin(row * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const sectionBoxes = (): SectionBox[] =>
|
||||
Array.from(document.querySelectorAll("main section")).map((section) => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
return { top: rect.top + window.scrollY, height: rect.height };
|
||||
});
|
||||
|
||||
/** A tile of faint digits that textures the closed curtain. */
|
||||
const makeTile = (dpr: number, mono: string): HTMLCanvasElement => {
|
||||
const cells = 8;
|
||||
const tile = document.createElement("canvas");
|
||||
tile.width = cells * CELL * dpr;
|
||||
tile.height = cells * CELL * dpr;
|
||||
const ctx = tile.getContext("2d");
|
||||
if (!ctx) return tile;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = INK;
|
||||
ctx.fillRect(0, 0, cells * CELL, cells * CELL);
|
||||
ctx.font = `500 11px ${mono}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(148, 163, 184, 0.16)";
|
||||
for (let row = 0; row < cells; row++) {
|
||||
for (let col = 0; col < cells; col++) {
|
||||
const bit = rowLag(row * cells + col + 1) < 0.5 ? "0" : "1";
|
||||
ctx.fillText(bit, col * CELL + CELL / 2, row * CELL + CELL / 2);
|
||||
}
|
||||
}
|
||||
return tile;
|
||||
};
|
||||
|
||||
/**
|
||||
* BitCurtain: the transition of the language switch. A curtain of binary digits
|
||||
* closes over the page from left to right, the name of the new language resolves
|
||||
* out of bits on it, the page is swapped behind it, and the curtain opens again
|
||||
* on the same spot of the page in the other language.
|
||||
*
|
||||
* With reduced motion there is no curtain: the page is swapped at once.
|
||||
*/
|
||||
export const BitCurtain: React.FC = () => {
|
||||
const locale = useLocale();
|
||||
const { nav } = useContent();
|
||||
const router = useRouter();
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const state = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSwitchState,
|
||||
getIdleState,
|
||||
);
|
||||
const active = state.phase !== "idle";
|
||||
|
||||
// The loop below reads these through refs, so it survives re-renders.
|
||||
const localeRef = React.useRef(locale);
|
||||
localeRef.current = locale;
|
||||
const namesRef = React.useRef(nav.languageNames);
|
||||
namesRef.current = nav.languageNames;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d") ?? null;
|
||||
|
||||
const mono =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--font-jetbrains-mono")
|
||||
.trim() || "ui-monospace";
|
||||
const font = `${mono}, ui-monospace, Menlo, monospace`;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let pattern: CanvasPattern | null = null;
|
||||
|
||||
const resize = () => {
|
||||
if (!canvas || !ctx) return;
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
canvas.width = Math.ceil(width * dpr);
|
||||
canvas.height = Math.ceil(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
pattern = ctx.createPattern(makeTile(dpr, font), "repeat");
|
||||
// The tile is drawn at device resolution; scale it back to CSS pixels.
|
||||
pattern?.setTransform(new DOMMatrix().scale(1 / dpr));
|
||||
};
|
||||
|
||||
const paint = (current: Exclude<SwitchState, { phase: "idle" }>) => {
|
||||
if (!ctx) return;
|
||||
const now = performance.now();
|
||||
const elapsed = now - current.phaseAt;
|
||||
const opening = current.phase === "reveal";
|
||||
const progress =
|
||||
current.phase === "hold"
|
||||
? 1
|
||||
: easeInOut(clamp01(elapsed / (opening ? REVEAL_MS : COVER_MS)));
|
||||
const rows = Math.ceil(height / CELL);
|
||||
|
||||
// The edge of the curtain in each row. Closing, the curtain is left of it;
|
||||
// opening, right of it.
|
||||
const edge = (row: number): number =>
|
||||
current.phase === "hold"
|
||||
? width
|
||||
: clamp01(progress * (1 + SPREAD) - rowLag(row) * SPREAD) * width;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const x = edge(row);
|
||||
if (opening) ctx.rect(x, row * CELL, width - x, CELL);
|
||||
else ctx.rect(0, row * CELL, x, CELL);
|
||||
}
|
||||
ctx.clip();
|
||||
ctx.fillStyle = pattern ?? INK;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// The name of the language, resolving out of bits. Clipped to the curtain.
|
||||
const sinceStart = now - current.startedAt - LABEL_DELAY_MS;
|
||||
const name = namesRef.current[current.target].toUpperCase();
|
||||
const shown = Math.floor(clamp01(sinceStart / LABEL_MS) * name.length);
|
||||
const text = Array.from(name)
|
||||
.map((char, i) => (i < shown ? char : Math.random() < 0.5 ? "0" : "1"))
|
||||
.join("");
|
||||
const size = Math.min(width * 0.15, 168);
|
||||
ctx.globalAlpha = clamp01(sinceStart / 160);
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.font = `800 ${size}px ${font}`;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillText(text, width / 2, height / 2 + size * 0.2);
|
||||
const textWidth = ctx.measureText(text).width;
|
||||
ctx.fillStyle = MARK;
|
||||
ctx.fillRect(
|
||||
width / 2 - textWidth / 2,
|
||||
height / 2 + size * 0.42,
|
||||
textWidth * clamp01(sinceStart / LABEL_MS),
|
||||
Math.max(3, size * 0.035),
|
||||
);
|
||||
ctx.font = `500 12px ${font}`;
|
||||
ctx.fillStyle = "#94a3b8";
|
||||
ctx.fillText(
|
||||
`${current.from.toUpperCase()} → ${current.target.toUpperCase()}`,
|
||||
width / 2,
|
||||
height / 2 - size * 0.78,
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.restore();
|
||||
|
||||
// Loose digits along the moving edge.
|
||||
if (current.phase !== "hold") {
|
||||
ctx.font = `700 12px ${font}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const x = edge(row);
|
||||
if (x <= 0 || x >= width) continue;
|
||||
for (let k = -2; k < TRAIL; k++) {
|
||||
// k counts cells from the edge into the curtain; negative is on the page.
|
||||
const cx = x + (opening ? 1 : -1) * (k + 0.5) * CELL;
|
||||
const onPage = k < 0;
|
||||
ctx.globalAlpha = onPage ? 0.5 : (1 - k / TRAIL) * 0.9;
|
||||
ctx.fillStyle = onPage ? INK : k < 2 ? MARK : "#ffffff";
|
||||
ctx.fillText(
|
||||
Math.random() < 0.5 ? "0" : "1",
|
||||
cx,
|
||||
row * CELL + CELL / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
};
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
const current = getSwitchState();
|
||||
if (current.phase === "idle") return;
|
||||
const elapsed = performance.now() - current.phaseAt;
|
||||
|
||||
if (current.phase === "cover" && (still || elapsed >= COVER_MS)) {
|
||||
enterPhase("hold", captureAnchor(sectionBoxes(), window.scrollY));
|
||||
router.push(current.href, { scroll: false });
|
||||
} else if (current.phase === "hold") {
|
||||
const arrived = localeRef.current === current.target;
|
||||
if (arrived && (still || elapsed >= HOLD_MS)) {
|
||||
window.scrollTo({
|
||||
top: restoreAnchor(current.anchor, sectionBoxes()),
|
||||
behavior: "instant",
|
||||
});
|
||||
if (still) endSwitch();
|
||||
else enterPhase("reveal");
|
||||
} else if (elapsed > GIVE_UP_MS) {
|
||||
endSwitch();
|
||||
window.location.assign(current.href);
|
||||
return;
|
||||
}
|
||||
} else if (current.phase === "reveal" && elapsed >= REVEAL_MS) {
|
||||
endSwitch();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = getSwitchState();
|
||||
if (next.phase === "idle") return;
|
||||
if (!still) paint(next);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const started = getSwitchState();
|
||||
if (started.phase !== "idle") router.prefetch(started.href);
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [active, router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`fixed inset-0 z-[300] motion-reduce:hidden ${active ? "block" : "hidden"}`}
|
||||
style={{
|
||||
// Closed: solid behind the canvas, so the page swap never shows through.
|
||||
backgroundColor: state.phase === "hold" ? INK : "transparent",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} className="block h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
const SURFACE = "[data-glow]";
|
||||
|
||||
/**
|
||||
* BitGlow: a light that follows the pointer across every `data-glow` surface.
|
||||
* It only publishes the pointer position as `--gx` / `--gy` on those surfaces;
|
||||
* the light itself is drawn in CSS by a `.bit-glow` child (see globals.css).
|
||||
* Purely decorative, and off when reduced motion is requested.
|
||||
*/
|
||||
export const BitGlow: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let frame = 0;
|
||||
let latest: PointerEvent | null = null;
|
||||
|
||||
const publish = () => {
|
||||
frame = 0;
|
||||
if (!latest) return;
|
||||
const { clientX, clientY, target } = latest;
|
||||
let surface =
|
||||
target instanceof Element ? target.closest<HTMLElement>(SURFACE) : null;
|
||||
// Surfaces nest (a button inside a dark section): each one gets its own position.
|
||||
while (surface) {
|
||||
const rect = surface.getBoundingClientRect();
|
||||
surface.style.setProperty("--gx", `${clientX - rect.left}px`);
|
||||
surface.style.setProperty("--gy", `${clientY - rect.top}px`);
|
||||
surface =
|
||||
surface.parentElement?.closest<HTMLElement>(SURFACE) ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
latest = event;
|
||||
if (frame === 0) frame = window.requestAnimationFrame(publish);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
if (frame !== 0) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { BitAura } from "./BitAura";
|
||||
|
||||
/**
|
||||
* BitSeam: a lit top edge for a dark section, with digits drifting down from it.
|
||||
* The section needs `relative` and has to clip its overflow.
|
||||
*/
|
||||
export const BitSeam: React.FC<{ density?: number }> = ({ density = 0.4 }) => (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BitAura source="bottom" tone="dark" radius={0} density={density}>
|
||||
<div className="h-0" />
|
||||
</BitAura>
|
||||
</div>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user