Compare commits

..
28 Commits
Author SHA1 Message Date
mmintelandClaude Opus 5.5 710d3d06e6 feat: remote without fixed office hours, said on the freelance page
Build & Deploy / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 7m6s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🩺 Smoke Test (push) Successful in 4s
The site said "available during European business hours". That is not how
the owner works: he works from wherever he is and plans his own time;
meetings can be arranged, all-day availability cannot. The freelance page
now says so in its booking section, and the homepage answer on how to reach
him no longer promises business hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:45:18 +02:00
mmintelandClaude Opus 5.5 2cb15608dd feat: contact topics per audience, room above the last sentence, a hero line that says what a client gets
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Canceled after 4m30s
Build & Deploy / 🚀 Deploy (push) Canceled after 0s
Build & Deploy / 🩺 Smoke Test (push) Canceled after 0s
Build & Deploy / 🔔 Notify (push) Canceled after 0s
Contact: the message starter offered every topic on both pages. The homepage
now offers automation, a website, or something else; the freelance page a
developer, a reliable app, or something else.

Thread: where it crosses the page above the last sentence it ran too close
to the text. It keeps more distance now, on phones too.

Hero: the line under the hero listed years, working hours and two links,
which says nothing to a client (owner). It now names four things a client
gets: one contact who builds it himself, an offer before anything costs
money, changes by email, servers in Germany.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:32:33 +02:00
mmintelandClaude Opus 5.5 af66f61c26 feat: care offer for websites, with its price and its limits
What happens after a website is live was one vague sentence about a monthly
fee to be agreed. It is now one clear offer in the website case and in the
questions: 89 EUR per month, cancel monthly, the site runs on servers in
Germany, small changes by email are included, no editing system and no login.
The limits are said as plainly as what is included: the first three months
are part of the project price, anything bigger gets a price first, replies
within two working days, no on-call, and the client can leave with everything.

The decision and the parts that are not on the website (the larger variant,
CMS as an exception, how the sites are served) are in the concept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:21:46 +02:00
mmintelandClaude Opus 5.5 7a71296e6d fix: questions and answers are all open instead of an accordion
Opening one question pushed everything below it down. The answers are two or
three lines long, so they are simply shown: two columns on wide screens, one
on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:12:59 +02:00
mmintelandClaude Opus 5.5 f3797bfe47 feat: separate questions for clients and for teams
The homepage answered questions that only concern teams booking by the hour
(notice period and billing by effort, joining an existing team, what is out
of scope). Those are now on the freelance page. The homepage answers what a
client commissioning a website or automation asks: is it a problem that I am
not technical, what does it cost, who owns it, who looks after the website
afterwards, how do I reach you.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:08:48 +02:00
mmintelandClaude Opus 5.5 bee03398d0 fix: the story thread no longer jumps up the page before its end
When the last chapter left the trace in the right margin, the way to the end
point on the left was drawn as one 45 degree line. Over the full page width
that line started more than a thousand pixels further up, so the trace and
the bit at its head shot up to the top right. The trace now crosses the page
just above its end and comes straight down beside the last sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:04:48 +02:00
mmintelandClaude Opus 5.5 1b6a96225e feat: the homepage speaks to people who commission a result
Owner: the homepage is for people who need a website or process automation
and have no app of their own; companies that book by the hour belong on the
freelance page.

Homepage: two cases instead of three (automation, website). The price box
with its steps and figures is replaced by one box: you get an offer before
anything costs money. No rates or weekly prices there any more.

Freelance page: gets the developer case and "make an app reliable" with
their steps and prices, and the section on how working together goes.

Headlines the owner called unclear are rewritten: the automation section
("Your tools do the routine work. Nobody retypes anything.") and the
working section ("How working together goes. Direct and easy to follow.").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:50:34 +02:00
mmintelandClaude Opus 5.5 f0c25b802b feat: drawings in circuit style, visible hero digits, hourly booking only on the freelance page
Drawings: "How I work" and the step drawings under "How it runs" now use
the same language as the story thread. Systems and people are chips with
pins, connections are tracks with 45 degree bends and solder points, and
what travels is a bit instead of a round dot (circuit.tsx).

Hero: the digits in the background were close to invisible on white. They
are about twice as strong now, and the ones near the pointer light up indigo.

Homepage vs. freelance page: the homepage is for clients who buy a result,
so it shows three cases. The developer case (100 EUR per hour, booked by the
month) and its steps live on the freelance page only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:37:26 +02:00
mmintelandClaude Opus 5.5 affd4b7ee7 feat: the story thread looks like a track on a circuit board
Owner: the direction is right, but a smooth line is not developer style;
everything else on the site is binary. The thread is now a trace: straight
runs with 45 degree bends, solder points where it bends or meets a figure, a
second fainter track beside it, figures as components (pins on the
rectangular ones, the chapter number in binary inside), and a bit that flips
between 1 and 0 as it travels at the head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:42:52 +02:00
mmintelandClaude Opus 5.5 3e0c9a1605 feat: a red thread through the homepage, in words and as a line
The rail and the "next, you are wondering" links are gone (owner: flat, and
the question stood twice in a row). The page now reads from top to bottom:

Every chapter opens with one sentence, and the sentences follow each other
as the way of a project: you need someone who builds it, first we find out
what you need, sometimes it is less work by hand, sometimes a meeting, then
it always runs the same way, in the end something runs, a few questions
remain, and it starts with an email.

One line runs down the whole page and draws itself as the visitor scrolls.
Under each sentence it crosses the page, stands a figure on the line
(diamond, hexagon, screen, circle, square, triangle) and continues down the
other side; a glowing dot travels at its head and comes to rest in front of
the last sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:35:55 +02:00
mmintelandClaude Opus 5.5 90120b60db feat: the homepage leads the visitor through as one story
The page was a row of sections to scroll past, and a film in one of them did
not change that. It now follows the questions a visitor has, in the order
they come up: who is this, can he do what I need, what does it look like,
what else, how does it go, has he done it, what is open, how do I start.

Each chapter ends by asking the next question as a large link that leads on,
and the next chapter answers it. A rail at the edge shows every chapter as a
station with a dot travelling along it (on phones: a pill with the current
chapter and one tap to the next), and the header names the current question.

Where the visitor is, is pure logic in src/domain/chapters.ts. The scroll
film section and the bridge sentences are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:24:52 +02:00
mmintelandClaude Opus 5.5 e6f00cbbb6 Merge feat/scroll-film: scroll film, estimates, monthly booking, and the releases up to v2.5.0
Local main had only the video work; this brings in everything released since
(freelance page, story order, phone layout, share image) plus the two
unreleased commits, so the local site shows the current state.

Also fixes the pre-commit hook for this checkout: ESLint walked into the
worktrees under .claude/worktrees and found several tsconfig roots, so every
commit from here failed. The worktrees are now ignored and the parser has a
fixed tsconfig root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:13:33 +02:00
mmintelandClaude Opus 5.5 f50bb553e8 feat: the automation chapter opens with a scroll film
The homepage read as sections to scroll past. The automation chapter now
opens the way the social media films do: one continuous shot on a dark
stage that stays in place while the visitor scrolls. Scrolling moves the
camera (close on a dot hopping between two squares, pull back to a whole
office carrying things from tool to tool, freeze, lines take over) and
changes the statement; a second clock keeps the dots moving when the
visitor stops. The statements are the approved ones from the film.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:09:50 +02:00
mmintelandClaude Opus 5.5 c7f8672cd0 feat: estimates on the homepage, monthly booking on the freelance page
The homepage showed a price list per case, including a day rate. Buyers of
a result need a way to a price, not rates: each case now shows three steps
to an estimate, one line for orientation and a button that opens the
contact message with that topic chosen.

The developer case is booked by the month, not by the day. The day rate is
gone; the freelance page has the booking terms: 100 EUR per hour, fixed
days of the week agreed for one month at a time, an example month, monthly
invoice, two weeks notice to the end of a month.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:01:23 +02:00
mmintelandClaude Opus 5.5 6fcf893903 Merge feat/video-playground: content strategy, image posts and posting plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:00:43 +02:00
mmintelandClaude Opus 5.5 5cbcb2eac4 feat: content strategy, image posts and a posting plan for the films
Adds twelve image posts made the same way as the films: six posters
(one picture of each film with its idea in two lines) and six cards
that answer a question clients ask before the first call. The answers
come from the questions and principles on the website.

The posting plan is data (six weeks, film, card and poster per week),
and the export writes the library in that order, by language, week and
day, with the text to post. All video posts now open with the hook of
their film. The reasoning is in plans/content-strategy.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:00:28 +02:00
mmintelandClaude Opus 5.5 2dab728129 Merge feat/video-playground: social media films, ads concept and video skill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:23:32 +02:00
mmintelandClaude Opus 5.5 4bfbcbcacd feat: video films for social media, with the skill to make more
Adds apps/video, a standalone Remotion package (not part of the pnpm
workspace, so it never reaches the web build or the Docker image). It
renders six looping films, one per service, in German and English, as
4:5 for LinkedIn and 16:9 for YouTube and ads, together with the text
to post and an overview page.

Each film is one continuous shot: close on something abstract, the
camera pulls back to show what it really is, the form changes, and the
picture folds back into its opening. The slide deck code from the
earlier attempts is still in the package and no longer used.

Also adds the Google Ads concept, brings the relaunch concept in line
with the site as built, and a project skill that describes how to make
further films.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:23:01 +02:00
mmintelandClaude Opus 5.5 83616433e4 ci: smoke test also checks the freelance page
Build & Deploy / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m58s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:48:57 +02:00
mmintelandClaude Opus 5.5 c70833a725 feat: freelance page, a story on the homepage, and design fixes
The homepage was too long and read as a list of sections. The technical
ones (project fit, tools, how a change is secured) move to a page of their
own, /freelance and /en/freelance, linked from the header, the footer and a
pointer on the homepage. The rest is reordered into one story (who, what you
need, examples, how working together goes, results, questions, contact) and
each section opens with a sentence that leads over from the one before.

routes.ts: a page may now have the same name in both languages. Before,
/en/<german name> was always moved to the English name, which for such a
page would have redirected the address onto itself.

Design: list titles such as "Passt gut" are real headings instead of small
grey labels. The automation examples, their description and the run are one
box, so it is clear they belong together. Digits on light backgrounds are
the blue of the panels instead of black. The square corner marks on the
rounded service cards are gone. The years in the career line are readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:34:38 +02:00
mmintelandClaude Opus 5.5 11e991e54a fix: German copy says Interfaces, not Oberflächen
"Oberflächen" reads like kitchen worktops, not like user interfaces. The
German texts now use "Interface" throughout, and the German share image is
rendered again because it shows the hero lead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:07:14 +02:00
mmintelandClaude Opus 5.5 c67c13d82f feat: share image in the look of the site's dark sections
Build & Deploy / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m48s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 3s
The link preview was a plain white screenshot of the hero. It is still
rendered from the real hero, so it cannot drift from the site, but now as
light type on the dark surface with a lit frame and binary digits coming
off its edge. The digits are seeded, so every run gives the same picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:53:01 +02:00
mmintelandClaude Opus 5.5 ca0eddfde8 feat: a shorter page on phones
On a phone the homepage was about 29 screens long; it is now about 22.
The desktop layout is unchanged to the pixel.

Lists of equal items (automation notes, project fit, principles, reasons for
a web presentation, by hand vs. automated) are swiped through side by side
(SwipeRow) instead of stacked. The four service cards form a compact 2x2
grid, step lists spell out only the current step, the workflow legend and
the per-project task lists are left out, and sections use less padding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:50:26 +02:00
mmintelandClaude Opus 5.5 5b037d7e0c ci: smoke test checks the addresses of both languages
Since the German version the legal pages live at /impressum and /datenschutz
and under /en/imprint and /en/privacy. The smoke test still expected
/imprint and /privacy to answer 200 and /impressum to redirect, so it has
been red on every deploy while the site was fine. A check that is always red
no longer shows a real outage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:50:26 +02:00
mmintelandClaude Opus 5.5 6f6e52dd63 feat: section on web presentations, in German and English
Build & Deploy / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 6m37s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
A presentation built as a website instead of a slide file: why, for what,
and when slides are enough. The main drawing shows the same presentation as
a slide and as a web page on a projector, a laptop and a phone; the slide
keeps its format and shrinks, the page rearranges. Four small drawings stand
next to the reasons. No prices: none are agreed for this yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:58:35 +02:00
mmintelandClaude Opus 5.5 666b1d7547 fix: hero digits are readable on phones
On a phone the headline was built from 4px cells and the background field
kept its desktop ink, so the digits were specks or not visible at all. The
headline now uses cells of at least 6px there, and the background digits are
larger and stronger below 640px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:58:35 +02:00
mmintelandClaude Opus 5.5 60f094a754 fix: quiet sparks instead of dashed rings around the automation panel
The pulsing dashed rings around the example workflow read as a second,
broken frame. The panel now sheds a few digits at its left edge like the
other frames on light backgrounds, and the ring motion, which nothing else
used, is removed from BitAura.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:58:34 +02:00
mmintelandClaude Opus 5.5 f04e8c8463 feat: contact address with a sheen and a light on its underline
The address is the one thing to act on in the contact section, but it was a
plain underlined line next to a glowing message card. A slow sheen now runs
through the letters and a light travels along the underline; on hover the
whole line lights up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:47:11 +02:00
101 changed files with 11713 additions and 764 deletions
+110
View File
@@ -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.
+3 -3
View File
@@ -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)"
+33 -8
View File
@@ -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,9 +25,14 @@ 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 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 `/`
- 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
@@ -39,8 +44,8 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
## Two languages (German is the main one)
- German lives at the root (`/`, `/impressum`, `/datenschutz`), English under `/en` (`/en/imprint`,
`/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses
- 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.
@@ -54,8 +59,28 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
- 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`, `BitCurtain` (the language switch transition). 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`).
+3
View File
@@ -0,0 +1,3 @@
node_modules
out
.remotion
+102
View File
@@ -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.
+27
View File
@@ -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"
}
}
+3632
View File
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

+1
View File
@@ -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

+7
View File
@@ -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);
+205
View File
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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")}`);
+50
View File
@@ -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}
/>
)),
),
)}
</>
);
+70
View File
@@ -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>
);
};
+39
View File
@@ -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>
);
};
+102
View File
@@ -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>
);
};
+94
View File
@@ -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>
);
+134
View File
@@ -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>
);
};
+14
View File
@@ -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;
+42
View File
@@ -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;
+10
View File
@@ -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,
};
+23
View File
@@ -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);
};
+236
View File
@@ -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`,
},
},
},
};
+70
View File
@@ -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);
}
});
});
+72
View File
@@ -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" },
],
},
];
+51
View File
@@ -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");
});
});
+388
View File
@@ -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`,
},
},
};
+107
View File
@@ -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>
);
+94
View File
@@ -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>
);
+129
View File
@@ -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>
);
+125
View File
@@ -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>
);
+200
View File
@@ -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>
);
};
+196
View File
@@ -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>
);
+133
View File
@@ -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>
);
+75
View File
@@ -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;
+20
View File
@@ -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));
+311
View File
@@ -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>
);
+259
View File
@@ -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>
);
+87
View File
@@ -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} />
);
};
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { Root } from "./Root";
registerRoot(Root);
+82
View File
@@ -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);
});
});
+82
View File
@@ -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,
};
};
+60
View File
@@ -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("");
});
});
+34
View File
@@ -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("");
+64
View File
@@ -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);
});
});
+35
View File
@@ -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(", ")})`;
};
+49
View File
@@ -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);
});
});
+24
View File
@@ -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,
};
};
+17
View File
@@ -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,
];
+38
View File
@@ -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>
);
};
+306
View File
@@ -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);
+19
View File
@@ -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>
),
});
+21
View File
@@ -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"
]
}
+272
View File
@@ -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>
);
}
+127 -95
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BitAura } from "@/src/components/bits/BitAura";
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";
@@ -11,10 +11,14 @@ 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 {
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";
@@ -35,9 +39,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
/** 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 MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
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,
@@ -51,8 +59,16 @@ const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
export default async function LandingPage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const { hello, services, automation, technology, working, work, faq } =
content;
const {
hello,
services,
automation,
presentations,
story,
freelance,
work,
faq,
} = content;
return (
<div className="relative flex flex-col bg-white">
@@ -66,6 +82,7 @@ export default async function LandingPage({ params }: Props) {
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}
@@ -92,16 +109,42 @@ export default async function LandingPage({ params }: Props) {
<Block
index="02"
label={services.label}
chapter={{ line: story.services, figure: "diamond", flip: false }}
title={<Title title={services.title} />}
>
<Reveal>
<PathPicker />
<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>
@@ -110,11 +153,11 @@ export default async function LandingPage({ params }: Props) {
<Reveal>
<AutomationFlow />
</Reveal>
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
<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">
<p className={`${MONO} m-0`}>{group.title}</p>
<h3 className={LIST_TITLE}>{group.title}</h3>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
@@ -128,107 +171,86 @@ export default async function LandingPage({ params }: Props) {
</div>
</Reveal>
))}
</div>
</SwipeRow>
</Block>
<Block
index="04"
label={technology.label}
title={<Title title={technology.title} />}
label={presentations.label}
chapter={{ line: story.presentations, figure: "screen", flip: false }}
tone="dark"
title={<Title title={presentations.title} muted="text-slate-500" />}
>
<div 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"}`}
>
<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`}>{technology.techLabel}</p>
<div className="mt-8 grid grid-cols-1 gap-10 sm:grid-cols-2 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>
))}
<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>
</div>
</BitAura>
</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">
{technology.pipelineTitle}
</h3>
<p className={`${BODY} mb-12 mt-6 max-w-3xl`}>
{technology.pipelineIntro}
</p>
<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>
<PipelineRun />
<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={working.label}
title={<Title title={working.title} />}
>
<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>
<div 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>
))}
</div>
</Block>
<Block
index="06"
label={work.label}
chapter={{ line: story.work, figure: "square", flip: true }}
tone="dark"
title={<Title title={work.title} muted="text-slate-500" />}
>
@@ -240,13 +262,23 @@ export default async function LandingPage({ params }: Props) {
</Reveal>
</Block>
<Block index="07" label={faq.label} title={<Title title={faq.title} />}>
<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} />
<ClosingCta
contact={content.contact}
topics={HOME_TOPICS}
story={story.contact}
/>
<StoryThread />
</div>
);
}
+182 -45
View File
@@ -1018,47 +1018,6 @@ 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: "";
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);
pointer-events: none;
}
.bit-card::before {
top: 12px;
right: 12px;
border-left: 0;
border-bottom: 0;
transform: translate(-6px, 6px);
}
.bit-card::after {
bottom: 12px;
left: 12px;
border-right: 0;
border-top: 0;
transform: translate(6px, -6px);
}
.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);
}
/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */
.bit-glow {
position: absolute;
@@ -1127,10 +1086,6 @@ pre:has(code[class*="language-"]) {
.bit-glow {
display: none;
}
.bit-card::before,
.bit-card::after {
transition: none;
}
}
/*
@@ -1224,6 +1179,87 @@ pre:has(code[class*="language-"]) {
}
}
/*
* 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;
@@ -1271,6 +1307,107 @@ pre:has(code[class*="language-"]) {
}
}
/*
* 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.
*/
+3 -3
View File
@@ -3,13 +3,13 @@ import { SITE_URL } from "@/src/content/site";
import { sitemapEntries } from "@/src/domain/routes";
/**
* The site is a single page in two languages; the legal pages are not meant for
* search results. No modification date: it would have to be the date the content
* 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"]).map((entry) => ({
return sitemapEntries(SITE_URL, ["home", "freelance"]).map((entry) => ({
url: entry.url,
alternates: { languages: { ...entry.alternates.languages } },
}));
+101
View File
@@ -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?
+32 -12
View File
@@ -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 (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.
- 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.
+158
View File
@@ -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.
+67 -3
View File
@@ -1,6 +1,8 @@
// Renders the share images (Open Graph and Twitter) from the real hero, one per
// language, so they always match the site. Run it after the hero headline or its
// design changes:
// 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://localhost:3000
@@ -37,7 +39,9 @@ for (const shot of SHOTS) {
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; }
`,
});
// Let the page finish starting up first; it would discard the added labels.
@@ -54,6 +58,66 @@ for (const shot of SHOTS) {
"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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 278 KiB

@@ -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]);
+6
View File
@@ -50,6 +50,12 @@ export const Footer: React.FC = () => {
>
{nav.contactLong}
</Link>
<Link
href={pathFor("freelance", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
{nav.freelance}
</Link>
<Link
href={pathFor("imprint", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
+10 -2
View File
@@ -10,10 +10,12 @@ import { ScrollBits } from "./bits/ScrollBits";
import { useContent, useLocale } from "./ContentProvider";
import { LanguageSwitch } from "./LanguageSwitch";
/** Header: the logo, the language switch 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 home = pathFor("home", useLocale());
const locale = useLocale();
const home = pathFor("home", locale);
const freelance = pathFor("freelance", locale);
const [isScrolled, setIsScrolled] = React.useState(false);
React.useEffect(() => {
@@ -62,6 +64,12 @@ export const Header: React.FC = () => {
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
<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`}
+18
View File
@@ -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>
);
+3 -37
View File
@@ -4,8 +4,8 @@ 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, `pulse` sends rings outward. */
type AuraMotion = "sparks" | "rain" | "pulse";
/** 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. */
@@ -53,8 +53,6 @@ interface Palette {
core: string;
digit: string;
gain: number;
/** Opacity of the pulse rings at the edge. */
ring: number;
/** Share of the digit sparks that are kept (1 = all). */
density: number;
}
@@ -83,10 +81,9 @@ const LIGHT_PALETTE: Palette = {
blend: "source-over",
glow: "15, 23, 42",
core: "15, 23, 42",
digit: "15, 23, 42",
digit: "79, 70, 229",
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
gain: 0,
ring: 0.45,
density: 0.6,
};
@@ -96,21 +93,16 @@ const DARK_PALETTE: Palette = {
core: "224, 231, 255",
digit: "199, 210, 254",
gain: 1,
ring: 0.8,
density: 1,
};
const MOTION_DENSITY: Record<AuraMotion, number> = {
sparks: 0.45,
rain: 0.3,
pulse: 0.2,
};
const PULSE_RINGS = 3;
const PULSE_PERIOD_S = 4.2;
/** How often a drifting digit switches between 0 and 1. */
const FLIPS_PER_S = 0.9;
const PULSE_REACH = 56;
const fraction = (value: number) => value - Math.floor(value);
@@ -150,12 +142,6 @@ const spotsAt = (
t: number,
box: Box,
): readonly Spot[] => {
if (motion === "pulse") {
return [0, 1, 2, 3].map((corner) => ({
at: (corner + 0.5) / 4,
power: 0.45 + 0.25 * Math.sin(t * 0.8 - corner * 1.57),
}));
}
if (source === "around") {
return [
{ at: fraction(t * 0.03), power: 1 },
@@ -327,26 +313,6 @@ export const BitAura: React.FC<BitAuraProps> = ({
}
}
if (motion === "pulse") {
context.setLineDash([3, 7]);
context.lineWidth = 1.5;
for (let ring = 0; ring < PULSE_RINGS; ring++) {
const phase = fraction(clock / PULSE_PERIOD_S + ring / PULSE_RINGS);
const grow = phase * PULSE_REACH;
context.strokeStyle = `rgba(${palette.core}, ${(1 - phase) ** 2 * palette.ring})`;
context.beginPath();
context.roundRect(
-grow,
-grow,
box.width + 2 * grow,
box.height + 2 * grow,
radius + grow,
);
context.stroke();
}
context.setLineDash([]);
}
context.fillStyle = `rgb(${palette.digit})`;
context.textAlign = "center";
context.textBaseline = "middle";
+5 -2
View File
@@ -35,6 +35,7 @@ const LIGHT = "255, 255, 255";
const INTRO_MS = 1600;
const FRAME_MS = 1000 / 30;
const POINTER_RADIUS = 170;
const MIN_CELL = 6;
const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
@@ -155,9 +156,11 @@ export const BitHeadline: React.FC<{
});
const fontSize = parseFloat(getComputedStyle(wrap).fontSize) || 100;
cell = Math.round(Math.min(9, Math.max(4, fontSize / 19)));
// Digits below about 6px are specks, not digits: on phones the cells stay
// readable and the letters are built from fewer of them.
cell = Math.round(Math.min(9, Math.max(MIN_CELL, fontSize / 19)));
// Small type needs a stronger wash under the digits to keep its shape.
wash = fontSize < 100 ? 0.2 : 0.07;
wash = fontSize < 100 ? 0.24 : 0.07;
cols = Math.ceil(width / cell);
rows = Math.ceil(height / cell);
bits = new Uint8Array(cols * rows);
+12 -2
View File
@@ -4,12 +4,16 @@ import { BitSeam } from "@/src/components/bits/BitSeam";
import { BitWipe } from "@/src/components/bits/BitWipe";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Reveal } from "@/src/components/Reveal";
import { ChapterIntro } from "@/src/components/story/ChapterIntro";
import type { Figure } from "@/src/components/story/threadPath";
interface BlockProps {
/** Two-digit section index, shown with its binary form. */
index: string;
label: string;
title: React.ReactNode;
/** Makes the section a chapter of the story: its sentence and the figure the thread forms. */
chapter?: { line: string; figure: Figure; flip: boolean };
id?: string;
/** `dark` turns the section into a dark stage. */
tone?: "light" | "dark";
@@ -24,6 +28,7 @@ export const Block: React.FC<BlockProps> = ({
index,
label,
title,
chapter,
id,
tone = "light",
children,
@@ -49,7 +54,12 @@ export const Block: React.FC<BlockProps> = ({
/>
)}
{dark && <BitSeam />}
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-14 md:px-12 md:py-36">
{chapter && (
<div className="-mt-14 md:-mt-36">
<ChapterIntro {...chapter} dark={dark} />
</div>
)}
<Reveal>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span
@@ -78,7 +88,7 @@ export const Block: React.FC<BlockProps> = ({
{title}
</h2>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
<div className="mt-10 md:mt-24">{children}</div>
</div>
</section>
);
+20 -6
View File
@@ -8,9 +8,13 @@ import { BitHeadline } from "./BitHeadline";
import { ContactComposer } from "./ContactComposer";
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
contact,
}) => (
export const ClosingCta: React.FC<{
contact: Content["contact"];
/** What visitors of this page may want: the topics of the message starter. */
topics: readonly string[];
/** The last sentence of the story; the story thread ends in front of it. */
story?: string;
}> = ({ contact, topics, story }) => (
<section
id="contact"
data-glow
@@ -26,8 +30,18 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
/>
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
<BitSeam />
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-12 px-5 pb-16 pt-28 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<Reveal className="lg:col-span-6">
{story && (
<p className="relative m-0 mb-10 max-w-xl font-sans text-2xl font-bold leading-[1.1] tracking-tight text-white md:mb-14 md:text-5xl">
<span
data-thread-end=""
className="absolute -left-2.5 top-[0.55em] md:-left-5"
aria-hidden="true"
/>
{story}
</p>
)}
<BitHeadline
as="h2"
invert
@@ -38,12 +52,12 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
{contact.lead}
</p>
<div className="mt-8">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-2xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-4xl" />
<EmailLink className="bit-mail font-sans text-3xl font-bold tracking-tight no-underline md:text-5xl" />
</div>
</Reveal>
<Reveal delay={0.15} className="lg:col-span-6">
<BitAura source="around" tone="dark">
<ContactComposer />
<ContactComposer topicIds={topics} />
</BitAura>
</Reveal>
</div>
@@ -1,5 +1,6 @@
"use client";
import { onContactTopic } from "./contactTopic";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
@@ -50,9 +51,16 @@ const withBlanks = (text: string): React.ReactNode =>
* opens it in the visitor's own mail app, ready to finish and send. The address
* is assembled in the browser, so it never appears in the page source.
*/
export const ContactComposer: React.FC = () => {
export const ContactComposer: React.FC<{
/** The topics this page offers, in this order. */
topicIds: readonly string[];
}> = ({ topicIds }) => {
const { contact } = useContent();
const topics = contact.topics;
const topics = React.useMemo(
() =>
topicIds.flatMap((id) => contact.topics.filter((item) => item.id === id)),
[contact.topics, topicIds],
);
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
const [address, setAddress] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
@@ -61,6 +69,15 @@ export const ContactComposer: React.FC = () => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
// A button elsewhere on the page can open the message with its topic chosen.
React.useEffect(
() =>
onContactTopic((id) => {
if (topics.some((item) => item.id === id)) setTopicId(id);
}),
[topics],
);
const topic = topics.find((item) => item.id === topicId) ?? topics[0];
const typed = useTyped(topic?.draft.body ?? "");
+13 -17
View File
@@ -1,26 +1,22 @@
import * as React from "react";
import type { FaqItem } from "@/src/content/types";
/**
* Faq: a handful of short questions with their answers, all open. No accordion:
* opening one pushed everything below it down (owner feedback), and with
* answers of two or three lines there is nothing worth hiding.
*/
export const Faq: React.FC<{ items: readonly FaqItem[] }> = ({ items }) => (
<div className="grid grid-cols-1 gap-x-12 md:grid-cols-2">
<dl className="m-0 grid grid-cols-1 gap-x-12 gap-y-10 md:grid-cols-2 md:gap-y-14">
{items.map((item) => (
<details
key={item.question}
className="group border-t border-slate-200 py-6 open:pb-8"
>
<summary className="flex cursor-pointer list-none items-start justify-between gap-6 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl [&::-webkit-details-marker]:hidden">
<div key={item.question} className="border-t border-slate-900 pt-6">
<dt className="font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
{item.question}
<span
className="mt-1 shrink-0 font-mono text-xl text-slate-400 transition-transform duration-300 group-open:rotate-45"
aria-hidden="true"
>
+
</span>
</summary>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
</dt>
<dd className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{item.answer}
</p>
</details>
</dd>
</div>
))}
</div>
</dl>
);
+8 -23
View File
@@ -8,7 +8,7 @@ export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
hero,
}) => (
<section className="relative overflow-hidden bg-white">
<div className="absolute inset-0 opacity-70">
<div className="absolute inset-0">
<AbstractCircuit />
</div>
@@ -43,35 +43,20 @@ export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
</div>
</div>
<ul className="m-0 mt-14 flex list-none flex-wrap gap-x-8 gap-y-3 border-t border-slate-200 p-0 pt-6 md:mt-20">
{/* What a client gets, in a few words. */}
<ul className="m-0 mt-14 grid list-none grid-cols-1 gap-x-10 gap-y-3 border-t border-slate-200 p-0 pt-6 sm:grid-cols-2 md:mt-20 lg:grid-cols-4">
{hero.trust.map((item) => (
<li
key={item}
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
className="flex items-start gap-3 font-sans text-sm leading-snug text-slate-700 md:text-base"
>
<span
className="mt-[0.45em] h-1.5 w-1.5 shrink-0 bg-indigo-600"
aria-hidden="true"
/>
{item}
</li>
))}
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
{hero.builtAndRunning}{" "}
<a
href="https://klz-cables.com"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 underline-offset-4 hover:underline"
>
klz-cables.com
</a>
{" · "}
<a
href="https://e-tib.com"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 underline-offset-4 hover:underline"
>
e-tib.com
</a>
</li>
</ul>
</div>
</section>
+127 -39
View File
@@ -2,12 +2,12 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Button } from "@/src/components/Button";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { useContent } from "@/src/components/ContentProvider";
import type { ServicePath } from "@/src/content/types";
import { TopicButton } from "./TopicButton";
import type { ServicePath, ServicePathId } from "@/src/content/types";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const STEP_MS = 4800;
@@ -92,7 +92,7 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
setStep(index);
setAuto(false);
}}
className={`relative flex w-full gap-5 border-t py-6 text-left transition-colors duration-300 md:gap-6 ${
className={`relative flex w-full gap-5 border-t py-4 text-left transition-colors duration-300 md:gap-6 md:py-6 ${
selected ? "border-slate-900" : "border-slate-200"
}`}
>
@@ -122,8 +122,11 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
{item.title}
</span>
<span
className={`mt-1.5 block font-sans text-base leading-relaxed transition-colors duration-300 ${
selected ? "text-slate-600" : "text-slate-400"
className={`mt-1.5 font-sans text-base leading-relaxed transition-colors duration-300 ${
// On phones only the current step is spelled out.
selected
? "block text-slate-600"
: "hidden text-slate-400 md:block"
}`}
>
{item.text}
@@ -143,9 +146,17 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
* what they get, how it runs, what it costs, what to do next.
* All three panels stay in the DOM so the content is readable without JavaScript.
*/
export const PathPicker: React.FC = () => {
export const PathPicker: React.FC<{
/** The cases to show, in this order. */
ids: readonly ServicePathId[];
/** `offer`: ask for an offer, no figures. `estimate`: the way to a price, with figures. */
pricing: "offer" | "estimate";
}> = ({ ids, pricing }) => {
const { services } = useContent();
const paths = services.paths;
const { estimate, care } = services;
const paths = ids.flatMap((id) =>
services.paths.filter((path) => path.id === id),
);
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
return (
@@ -153,7 +164,7 @@ export const PathPicker: React.FC = () => {
<div
role="tablist"
aria-label={services.tablistLabel}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-4"
>
{paths.map((path, index) => {
const selected = path.id === activeId;
@@ -167,7 +178,7 @@ export const PathPicker: React.FC = () => {
aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(path.id)}
data-glow
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
className={`group flex h-full flex-col justify-between rounded-3xl border p-4 text-left transition-all duration-300 md:p-7 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
@@ -177,11 +188,11 @@ export const PathPicker: React.FC = () => {
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1}
</span>
<span className="mt-3 block font-sans text-xl font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
<span className="mt-3 block font-sans text-base font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
{path.need}
</span>
<span
className={`mt-2 block font-sans text-sm leading-relaxed md:mt-4 md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
className={`mt-2 hidden font-sans text-sm leading-relaxed md:mt-4 md:block md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
>
{path.forWhom}
</span>
@@ -199,7 +210,7 @@ export const PathPicker: React.FC = () => {
id={`path-panel-${path.id}`}
aria-labelledby={`path-tab-${path.id}`}
hidden={!selected}
className="mt-12 md:mt-20"
className="mt-8 md:mt-20"
>
<motion.div
key={selected ? "on" : "off"}
@@ -240,40 +251,117 @@ export const PathPicker: React.FC = () => {
<PathStory path={path} active={selected} />
</div>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>{services.whatItCosts}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{path.pricingModel}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{path.pricingText}
</p>
{pricing === "offer" && path.id === "website" && (
<div className="mt-12 rounded-3xl border border-slate-200 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>{care.label}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-7">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{care.title}
</p>
<p className="m-0 mt-4 max-w-2xl font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{care.text}
</p>
</div>
<div className="md:col-span-5">
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
{care.price.label}
</p>
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
{care.price.amount}
</p>
<p className={`${MONO} m-0 mt-2`}>{care.price.note}</p>
</div>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:col-span-6">
{path.prices.map((price) => (
<div key={price.label}>
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
{price.label}
<div className="mt-10 grid grid-cols-1 gap-10 border-t border-slate-200 pt-8 md:grid-cols-2 md:gap-12">
{[
{ title: care.includedLabel, items: care.included },
{ title: care.termsLabel, items: care.terms },
].map((group) => (
<div key={group.title}>
<p className="m-0 font-sans text-lg font-bold tracking-tight text-slate-900 md:text-xl">
{group.title}
</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={`${MONO} m-0 mt-2`}>{price.note}</p>
<ul className="m-0 mt-3 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className="border-b border-slate-100 py-3 font-sans text-base leading-relaxed text-slate-600 md:text-lg"
>
{item}
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-10 flex flex-col gap-6 border-t border-slate-200 pt-8 md:flex-row md:items-center md:justify-between">
<p className="m-0 max-w-2xl font-sans text-base leading-relaxed text-slate-500">
{path.smallPrint}
</p>
<Button href="#contact" size="large">
{path.cta}
</Button>
)}
{pricing === "offer" ? (
<div className="mt-12 flex flex-col gap-8 rounded-3xl bg-slate-50 p-6 md:mt-16 md:flex-row md:items-center md:justify-between md:gap-12 md:p-10">
<div className="max-w-2xl">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{services.offer.title}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{services.offer.text}
</p>
</div>
<div className="shrink-0">
<TopicButton topic={path.id}>
{services.offer.cta}
</TopicButton>
</div>
</div>
</div>
) : (
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>{estimate.label}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-7">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{estimate.title}
</p>
<ol className="m-0 mt-6 list-none p-0">
{estimate.steps.map((item, index) => (
<li
key={item.title}
className="flex gap-5 border-t border-slate-200 py-4"
>
<span className="w-6 shrink-0 font-sans text-2xl font-black leading-none tracking-tighter text-slate-300">
{index + 1}
</span>
<span>
<span className="block font-sans text-lg font-bold tracking-tight text-slate-900">
{item.title}
</span>
<span className="mt-1 block font-sans text-base leading-relaxed text-slate-500">
{item.text}
</span>
</span>
</li>
))}
</ol>
</div>
<div className="flex flex-col md:col-span-5">
<p className={`${MONO} m-0`}>
{estimate.orientationLabel}
</p>
<p className="m-0 mt-3 font-sans text-2xl font-black leading-tight tracking-tighter text-slate-900 md:text-4xl">
{path.orientation}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500">
{path.orientationNote} {estimate.net}
</p>
<div className="mt-8 flex flex-col items-start gap-5 md:mt-auto md:pt-8">
<TopicButton topic={path.id}>
{estimate.cta}
</TopicButton>
</div>
</div>
</div>
</div>
)}
</motion.div>
</div>
);
@@ -18,10 +18,13 @@ export const PortraitSection: React.FC<{
label: string;
title: React.ReactNode;
portraitAlt: string;
/** The sentence of the story this chapter opens with. */
story: string;
children: React.ReactNode;
}> = ({ id, index, label, title, portraitAlt, children }) => (
}> = ({ id, index, label, title, portraitAlt, story, children }) => (
<section
id={id}
data-thread-start=""
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
>
{/* Portrait: right side on wide screens, top on small ones. */}
@@ -58,6 +61,9 @@ export const PortraitSection: React.FC<{
viewport={{ once: true, margin: "-10%" }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<p className="m-0 mb-10 max-w-2xl font-sans text-2xl font-bold leading-[1.1] tracking-tight text-white md:mb-16 md:text-5xl">
{story}
</p>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-white">
{index}
@@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import { Button } from "@/src/components/Button";
import { chooseContactTopic } from "./contactTopic";
/** A call to action that jumps to the contact section with a topic already chosen. */
export const TopicButton: React.FC<{
topic: string;
children: React.ReactNode;
}> = ({ topic, children }) => (
<Button
href="#contact"
size="large"
onClick={() => chooseContactTopic(topic)}
>
{children}
</Button>
);
@@ -0,0 +1,19 @@
/** The contact section listens for this to open with a given topic chosen. */
const EVENT = "mintel:contact-topic";
/** Ask the contact section to choose a topic (see ContactComposer). */
export const chooseContactTopic = (topicId: string): void => {
window.dispatchEvent(new CustomEvent<string>(EVENT, { detail: topicId }));
};
export const onContactTopic = (
listener: (topicId: string) => void,
): (() => void) => {
const handle = (event: Event) => {
if (event instanceof CustomEvent && typeof event.detail === "string") {
listener(event.detail);
}
};
window.addEventListener(EVENT, handle);
return () => window.removeEventListener(EVENT, handle);
};
@@ -1,6 +1,7 @@
"use client";
import { BitAura } from "@/src/components/bits/BitAura";
import { SwipeRow } from "@/src/components/SwipeRow";
import * as React from "react";
import { useNarrow } from "./useNarrow";
import { useContent } from "@/src/components/ContentProvider";
@@ -222,40 +223,35 @@ export const AutomationFlow: React.FC = () => {
return (
<div ref={ref}>
<div
role="tablist"
aria-label={automation.examplesLabel}
className="flex flex-wrap gap-2"
>
{workflows.map((item) => {
const selected = item.id === flow.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setFlowId(item.id)}
className={`rounded-full border px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
}`}
>
{item.name}
</button>
);
})}
</div>
<p className="m-0 mt-6 max-w-3xl font-sans text-lg leading-relaxed text-slate-600 md:text-xl">
{flow.summary}
</p>
<BitAura source="around" tone="light" motion="pulse" className="mt-8">
{/* The examples, their description and the run are one box: they belong together. */}
<BitAura source="left" tone="light">
<div className="overflow-hidden rounded-3xl bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-800 px-5 py-4 md:px-7">
<div
role="tablist"
aria-label={automation.examplesLabel}
className="flex flex-wrap gap-2"
>
{workflows.map((item) => {
const selected = item.id === flow.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setFlowId(item.id)}
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-white bg-white text-slate-900"
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
}`}
>
{item.name}
</button>
);
})}
</div>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
@@ -263,6 +259,9 @@ export const AutomationFlow: React.FC = () => {
{finished ? automation.runComplete : automation.running}
</span>
</div>
<p className="m-0 max-w-4xl px-5 pb-2 pt-6 font-sans text-lg leading-relaxed text-slate-200 md:px-7 md:pt-8 md:text-xl">
{flow.summary}
</p>
<div>
<svg
@@ -391,7 +390,7 @@ export const AutomationFlow: React.FC = () => {
</div>
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
<div className="px-5 py-5 md:col-span-7 md:border-r md:border-slate-800 md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>{automation.log}</p>
<ol
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
@@ -413,7 +412,7 @@ export const AutomationFlow: React.FC = () => {
))}
</ol>
</div>
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
<ul className="m-0 hidden list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:flex md:px-7">
{KINDS.filter((kind) =>
flow.nodes.some((node) => node.kind === kind),
).map((kind) => (
@@ -439,23 +438,28 @@ export const AutomationFlow: React.FC = () => {
))}
</ul>
</div>
<div className="border-t border-slate-800 px-5 py-6 md:px-7 md:py-8">
<SwipeRow className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10">
<div>
<p className={`${MONO} m-0 text-slate-500`}>
{automation.byHand}
</p>
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-400 md:text-lg">
{flow.byHand}
</p>
</div>
<div>
<p className={`${MONO} m-0 text-emerald-400`}>
{automation.automated}
</p>
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-white md:text-lg">
{flow.automated}
</p>
</div>
</SwipeRow>
</div>
</div>
</BitAura>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="rounded-3xl border border-slate-200 p-6 md:p-8">
<p className={`${MONO} m-0 text-slate-400`}>{automation.byHand}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
{flow.byHand}
</p>
</div>
<div className="rounded-3xl border border-slate-900 p-6 md:p-8">
<p className={`${MONO} m-0 text-slate-900`}>{automation.automated}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
{flow.automated}
</p>
</div>
</div>
</div>
);
};
@@ -46,7 +46,7 @@ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
}`}
aria-hidden="true"
/>
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400 md:col-span-3 md:pt-1.5">
<span className="font-mono text-sm font-bold uppercase tracking-[0.14em] text-indigo-200 md:col-span-3 md:pt-1.5 md:text-base">
{station.years}
</span>
<span className="md:col-span-9">
@@ -1,52 +1,11 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { useContent } from "@/src/components/ContentProvider";
import { Bit, Chip, CIRCUIT, Pad, Track } from "./circuit";
import { useNarrow } from "./useNarrow";
const INK = "#f8fafc";
const LINE = "#475569";
const MARK = "#fde68a";
const NODE_W = 112;
const NODE_H = 38;
const Node: React.FC<{
x: number;
y: number;
label: string;
size: number;
solid?: boolean;
muted?: boolean;
}> = ({ x, y, label, size, solid = false, muted = false }) => (
<g>
<rect
x={x - NODE_W / 2}
y={y - NODE_H / 2}
width={NODE_W}
height={NODE_H}
rx={NODE_H / 2}
fill={solid ? INK : "#0f172a"}
stroke={muted ? LINE : INK}
strokeWidth={1.5}
strokeDasharray={muted ? "4 4" : undefined}
/>
<text
x={x}
y={y + size * 0.36}
textAnchor="middle"
style={{
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: size,
letterSpacing: "0.12em",
textTransform: "uppercase",
fill: solid ? "#0f172a" : muted ? "#94a3b8" : INK,
}}
>
{label}
</text>
</g>
);
const CHIP = { width: 104, height: 36 } as const;
const Caption: React.FC<{
x: number;
@@ -63,35 +22,40 @@ const Caption: React.FC<{
fontSize: size,
letterSpacing: "0.12em",
textTransform: "uppercase",
fill: strong ? INK : "#64748b",
fill: strong ? CIRCUIT.ink : "#64748b",
}}
>
{children}
</text>
);
/** The signal gets weaker with every hand it passes through. */
const fading = (stops: readonly number[]): number[] =>
stops.map((_, i) => 0.95 - (i / stops.length) * 0.75);
/**
* ContactDiagram: a request passing slowly from hand to hand, next to the direct
* line you get when you talk to the person who does the work. Wide screens show
* the two lanes as rows, phones as two columns.
* ContactDiagram: a request passing slowly from chip to chip along a broken
* track and losing strength, next to the direct track you get when you talk to
* the person who does the work. Wide screens show the two lanes as rows, phones
* as two columns.
*/
export const ContactDiagram: React.FC = () => {
const tall = useNarrow();
const copy = useContent().working.diagram;
const chain = copy.chain;
const label = copy.description;
if (tall) {
// Two columns: the chain on the left, the direct line on the right.
// Two columns: the chain on the left, the direct track on the right.
const ys = chain.map((_, i) => 96 + i * 84);
const first = ys[0] ?? 96;
const last = ys[ys.length - 1] ?? 432;
const stops = ys.flatMap((y) => [y, y]);
const edge = CHIP.height / 2;
return (
<svg
viewBox="0 0 340 500"
role="img"
aria-label={label}
aria-label={copy.description}
className="block h-auto w-full"
>
<Caption x={24} y={38} size={12}>
@@ -100,60 +64,47 @@ export const ContactDiagram: React.FC = () => {
<Caption x={196} y={38} size={12} strong>
{copy.withMe}
</Caption>
<line
x1={90}
x2={90}
y1={first}
y2={last}
stroke={LINE}
strokeWidth={1.5}
strokeDasharray="4 4"
/>
<Track d={`M88 ${first}V${last}`} muted />
{chain.map((name, i) => (
<Node
<Chip
key={name}
x={90}
x={88}
y={ys[i] ?? 0}
{...CHIP}
label={name}
size={12}
muted={i > 0}
size={11}
tone={i > 0 ? "muted" : "line"}
pins={3}
/>
))}
<motion.circle
r={5}
cx={160}
fill={MARK}
initial={{ cy: first }}
animate={{
cy: stops,
opacity: stops.map((_, i) => 0.95 - (i / stops.length) * 0.6),
}}
transition={{
duration: 9,
repeat: Infinity,
repeatDelay: 0.8,
ease: "easeInOut",
}}
<Bit
xs={stops.map(() => 156)}
ys={stops}
opacity={fading(stops)}
duration={9}
pause={0.8}
/>
<line
x1={250}
x2={250}
y1={first}
y2={last}
stroke={INK}
strokeWidth={2}
<Track d={`M252 ${first + edge}V${last - edge}`} bus />
<Chip x={252} y={first} {...CHIP} label={copy.you} size={11} pins={3} />
<Chip
x={252}
y={last}
{...CHIP}
label={copy.me}
size={11}
tone="lit"
pins={3}
/>
<Node x={250} y={first} label={copy.you} size={12} />
<Node x={250} y={last} label={copy.me} size={12} solid />
<motion.circle
r={5}
cx={250}
fill={MARK}
initial={{ cy: first + NODE_H / 2 }}
animate={{
cy: [first + NODE_H / 2, last - NODE_H / 2, first + NODE_H / 2],
}}
transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }}
<Pad x={252} y={first + edge} />
<Pad x={252} y={last - edge} lit />
<Bit
xs={[252, 252, 252]}
ys={[first + edge + 10, last - edge - 10, first + edge + 10]}
opacity={[1, 1, 1]}
duration={2.4}
pause={0}
/>
</svg>
);
@@ -163,11 +114,12 @@ export const ContactDiagram: React.FC = () => {
const first = xs[0] ?? 76;
const last = xs[xs.length - 1] ?? 604;
const stops = xs.flatMap((x) => [x, x]);
const edge = CHIP.width / 2;
return (
<svg
viewBox="0 0 680 250"
role="img"
aria-label={label}
aria-label={copy.description}
className="block h-auto w-full"
>
<defs>
@@ -182,67 +134,43 @@ export const ContactDiagram: React.FC = () => {
</defs>
<rect width="680" height="250" fill="url(#contact-grid)" />
<Caption x={20} y={30} size={10}>
<Caption x={20} y={26} size={10}>
{copy.oftenLong}
</Caption>
<line
x1={first}
x2={last}
y1={74}
y2={74}
stroke={LINE}
strokeWidth={1.5}
strokeDasharray="4 4"
/>
<Track d={`M${first} 82H${last}`} muted />
{chain.map((name, i) => (
<Node
<Chip
key={name}
x={xs[i] ?? 0}
y={74}
y={82}
{...CHIP}
label={name}
size={10}
muted={i > 0}
tone={i > 0 ? "muted" : "line"}
/>
))}
<motion.circle
r={5}
cy={46}
fill={MARK}
initial={{ cx: first }}
animate={{
cx: stops,
opacity: stops.map((_, i) => 0.95 - (i / stops.length) * 0.6),
}}
transition={{
duration: 9,
repeat: Infinity,
repeatDelay: 0.8,
ease: "easeInOut",
}}
<Bit
xs={stops}
ys={stops.map(() => 46)}
opacity={fading(stops)}
duration={9}
pause={0.8}
/>
<Caption x={20} y={160} size={10} strong>
{copy.withMeLong}
</Caption>
<line
x1={first}
x2={last}
y1={204}
y2={204}
stroke={INK}
strokeWidth={2}
/>
<Node x={first} y={204} label={copy.you} size={10} />
<Node x={last} y={204} label={copy.me} size={10} solid />
<motion.circle
r={5}
cy={204}
fill={MARK}
initial={{ cx: first + NODE_W / 2 }}
animate={{
cx: [first + NODE_W / 2, last - NODE_W / 2, first + NODE_W / 2],
}}
transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }}
<Track d={`M${first + edge} 204H${last - edge}`} bus />
<Chip x={first} y={204} {...CHIP} label={copy.you} size={10} />
<Chip x={last} y={204} {...CHIP} label={copy.me} size={10} tone="lit" />
<Pad x={first + edge} y={204} />
<Pad x={last - edge} y={204} lit />
<Bit
xs={[first + edge + 14, last - edge - 14, first + edge + 14]}
ys={[204, 204, 204]}
opacity={[1, 1, 1]}
duration={2.4}
pause={0}
/>
</svg>
);
@@ -2,6 +2,7 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Bit, Chip, Pad, Track } from "./circuit";
import { useContent } from "@/src/components/ContentProvider";
import type { SceneLabels } from "@/src/content/types";
import { fill } from "@/src/domain/template";
@@ -48,29 +49,6 @@ type Scene = React.FC<{ l: SceneLabels }>;
type Pt = readonly [number, number];
/** Points along a cubic curve, for packets that travel on it. */
const along = (p0: Pt, p1: Pt, p2: Pt, p3: Pt, samples = 12) => {
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i <= samples; i++) {
const t = i / samples;
const u = 1 - t;
xs.push(
u * u * u * p0[0] +
3 * u * u * t * p1[0] +
3 * u * t * t * p2[0] +
t * t * t * p3[0],
);
ys.push(
u * u * u * p0[1] +
3 * u * u * t * p1[1] +
3 * u * t * t * p2[1] +
t * t * t * p3[1],
);
}
return { xs, ys };
};
/** A small packet that keeps travelling a route. */
const Packet: React.FC<{
xs: readonly number[];
@@ -78,28 +56,7 @@ const Packet: React.FC<{
delay?: number;
duration?: number;
pause?: number;
}> = ({ xs, ys, delay = 0, duration = 1.2, pause = 1.4 }) => (
<motion.circle
r={3.5}
fill={MARK}
stroke={MARK}
strokeOpacity={0.28}
strokeWidth={6}
initial={{ cx: xs[0], cy: ys[0], opacity: 0 }}
animate={{
cx: [...xs],
cy: [...ys],
opacity: xs.map((_, i) => (i === 0 || i === xs.length - 1 ? 0 : 1)),
}}
transition={{
duration,
delay,
repeat: Infinity,
repeatDelay: pause,
ease: "easeInOut",
}}
/>
);
}> = (props) => <Bit {...props} />;
const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
x,
@@ -544,44 +501,45 @@ const Decide: Scene = ({ l }) => (
>
{l.you}
</text>
<Pad x={116} y={150} />
{[70, 150, 230]
.map((y, i) => ({ y, label: l.options[i] ?? "" }))
.map((option, i) => (
<g key={option.label}>
<motion.path
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
fill="none"
stroke={INK}
strokeWidth={FINE}
{...draw(0.3 + i * 0.4)}
/>
<Packet
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
delay={1.6 + i * 1.3}
duration={1.1}
pause={2.8}
/>
<motion.g {...appear(0.7 + i * 0.4, 0)}>
<rect
x={250}
y={option.y - 16}
width={180}
height={32}
rx={16}
fill={PAPER}
stroke={LINE}
.map((option, i) => {
// Straight out, one 45 degree bend, straight in.
const bend = Math.abs(option.y - 150);
const turn = 150 + bend;
return (
<g key={option.label}>
<motion.path
d={`M116 150H150L${turn} ${option.y}H250`}
fill="none"
stroke={INK}
strokeWidth={1.4}
strokeLinecap="square"
{...draw(0.3 + i * 0.4)}
/>
<text
x={340}
y={option.y + 4}
textAnchor="middle"
style={{ ...MONO, fill: INK }}
>
{option.label}
</text>
</motion.g>
</g>
))}
<Packet
xs={[116, 150, turn, 250]}
ys={[150, 150, option.y, option.y]}
delay={1.6 + i * 1.3}
duration={1.1}
pause={2.8}
/>
<motion.g {...appear(0.7 + i * 0.4, 0)}>
<Chip
x={340}
y={option.y}
width={180}
height={32}
label={option.label}
size="var(--scene-label, 10px)"
pins={6}
/>
<Pad x={250} y={option.y} />
</motion.g>
</g>
);
})}
</g>
);
@@ -852,21 +810,34 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
y,
label,
}) => (
<g>
<rect
x={x - 56}
y={y - 22}
width={112}
height={44}
rx={10}
fill={SOFT}
stroke={RIM}
strokeWidth={FINE}
/>
<text x={x} y={y + 4} textAnchor="middle" style={{ ...MONO, fill: INK }}>
{label}
</text>
</g>
<Chip
x={x}
y={y}
width={112}
height={44}
label={label}
size="var(--scene-label, 10px)"
/>
);
/** Where the tracks between the tools end. */
const TOOL_PADS: readonly Pt[] = [
[146, 80],
[334, 80],
[90, 102],
[90, 178],
[390, 102],
[390, 178],
[146, 200],
[334, 200],
];
const ToolPads: React.FC<{ delay: number }> = ({ delay }) => (
<motion.g {...appear(delay, 0)}>
{TOOL_PADS.map(([x, y]) => (
<Pad key={`${x}-${y}`} x={x} y={y} />
))}
</motion.g>
);
/** Automation, step 2: today a person carries the data from tool to tool. */
@@ -937,6 +908,7 @@ const Wiring: Scene = ({ l }) => (
{TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))}
<ToolPads delay={1.9} />
<motion.text
x={240}
y={268}
@@ -950,42 +922,35 @@ const Wiring: Scene = ({ l }) => (
);
/** Automation, step 4: the work travels between the systems on its own. */
/** The four runs between the tools, each as from x, from y, to x, to y. */
const RUNS: readonly (readonly [number, number, number, number])[] = [
[146, 80, 334, 80],
[390, 102, 390, 178],
[334, 200, 146, 200],
[90, 178, 90, 102],
];
const Running: Scene = ({ l }) => (
<g>
{WIRES.map((d) => (
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={1.5} />
))}
{[
{ from: [146, 80], to: [334, 80], delay: 0 },
{ from: [390, 102], to: [390, 178], delay: 0.7 },
{ from: [334, 200], to: [146, 200], delay: 1.4 },
{ from: [90, 178], to: [90, 102], delay: 2.1 },
].map((dot) => (
<motion.circle
key={dot.delay}
r={3.5}
fill={MARK}
stroke={MARK}
strokeOpacity={0.28}
strokeWidth={6}
initial={{ cx: dot.from[0], cy: dot.from[1], opacity: 0 }}
animate={{
cx: [dot.from[0]!, dot.to[0]!],
cy: [dot.from[1]!, dot.to[1]!],
opacity: [0, 1, 1, 0],
}}
transition={{
duration: 1.4,
delay: dot.delay,
repeat: Infinity,
repeatDelay: 1.4,
ease: "easeInOut",
}}
/>
<Track key={d} d={d} bus />
))}
{TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))}
<ToolPads delay={0} />
{RUNS.map(([fx, fy, tx, ty], i) => (
<Bit
key={i}
xs={[fx, fx, tx, tx]}
ys={[fy, fy, ty, ty]}
opacity={[0, 1, 1, 0]}
delay={i * 0.7}
duration={1.4}
pause={1.4}
value={i % 2 === 0 ? 1 : 0}
/>
))}
<motion.text
x={240}
y={268}
@@ -241,7 +241,10 @@ export const PipelineRun: React.FC = () => {
</p>
<p
className={`m-0 mt-2 font-sans text-base leading-relaxed transition-colors duration-300 ${
active ? "text-slate-600" : "text-slate-400"
// On phones only the current step is spelled out.
active
? "block text-slate-600"
: "hidden text-slate-400 md:block"
}`}
>
{item.text}
@@ -0,0 +1,622 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { useContent } from "@/src/components/ContentProvider";
import { useNarrow } from "./useNarrow";
/**
* WebPresentation: the drawings of the web presentations section. The main one
* shows the same presentation as a slide file and as a website on three
* screens; four small ones stand next to the reasons. Same line language as
* PathScenes.
*/
const INK = "#f8fafc";
const LINE = "#475569";
const RIM = "#64748b";
const SOFT = "#1e293b";
const MARK = "#fde68a";
const LIVE = "#10b981";
const TINT = "rgba(248, 250, 252, 0.14)";
const FINE = 1.1;
const STEP_MS = 3600;
const MOVE = { duration: 0.7, ease: [0.16, 1, 0.3, 1] as const };
const MONO: React.CSSProperties = {
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: "var(--scene-label, 10px)",
letterSpacing: "0.14em",
textTransform: "uppercase",
fill: "#94a3b8",
};
interface Size {
width: number;
height: number;
}
interface Rect extends Size {
x: number;
y: number;
}
/** Projector, laptop, phone. */
const SCREENS: readonly Size[] = [
{ width: 300, height: 170 },
{ width: 230, height: 150 },
{ width: 96, height: 190 },
];
const SLIDE: Size = { width: 160, height: 90 };
const PAD = 12;
const GAP = 8;
const centred = (size: Size, cx: number, cy: number): Rect => ({
...size,
x: cx - size.width / 2,
y: cy - size.height / 2,
});
/** The slide keeps its format: it is scaled down until it fits the screen. */
const fitSlide = (screen: Rect) => {
const scale = Math.min(
(screen.width - 2 * PAD) / SLIDE.width,
(screen.height - 2 * PAD) / SLIDE.height,
);
return {
scale,
x: screen.x + (screen.width - SLIDE.width * scale) / 2,
y: screen.y + (screen.height - SLIDE.height * scale) / 2,
};
};
/** The web page rearranges: three columns, two columns and a row, or one column. */
const webLayout = (screen: Rect, index: number) => {
const left = screen.x + PAD;
const inner = screen.width - 2 * PAD;
const header: Rect = { x: left, y: screen.y + PAD, width: inner, height: 12 };
const top = header.y + header.height + GAP;
const area = screen.y + screen.height - PAD - top;
if (index === 0) {
const width = (inner - 2 * GAP) / 3;
return {
header,
cards: [0, 1, 2].map((i) => ({
x: left + i * (width + GAP),
y: top,
width,
height: area,
})),
};
}
if (index === 1) {
const width = (inner - GAP) / 2;
const upper = (area - GAP) * 0.56;
return {
header,
cards: [
{ x: left, y: top, width, height: upper },
{ x: left + width + GAP, y: top, width, height: upper },
{
x: left,
y: top + upper + GAP,
width: inner,
height: area - upper - GAP,
},
],
};
}
const height = (area - 2 * GAP) / 3;
return {
header,
cards: [0, 1, 2].map((i) => ({
x: left,
y: top + i * (height + GAP),
width: inner,
height,
})),
};
};
const Frame: React.FC<{ rect: Rect }> = ({ rect }) => (
<motion.rect
rx={12}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
initial={false}
animate={{
attrX: rect.x,
attrY: rect.y,
width: rect.width,
height: rect.height,
}}
transition={MOVE}
/>
);
/** One 16:9 slide, drawn at its own size and scaled as a whole. */
const Slide: React.FC<{ screen: Rect }> = ({ screen }) => {
const fit = fitSlide(screen);
return (
<motion.g
style={{ originX: 0, originY: 0 }}
initial={false}
animate={{ x: fit.x, y: fit.y, scale: fit.scale }}
transition={MOVE}
>
<rect
width={SLIDE.width}
height={SLIDE.height}
rx={4}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<line
x1={12}
x2={84}
y1={18}
y2={18}
stroke={INK}
strokeWidth={4}
strokeLinecap="round"
/>
{[0, 1, 2].map((bullet) => (
<g key={bullet}>
<circle cx={14} cy={38 + bullet * 13} r={1.6} fill={RIM} />
<line
x1={20}
x2={20 + [58, 46, 52][bullet % 3]}
y1={38 + bullet * 13}
y2={38 + bullet * 13}
stroke={RIM}
strokeWidth={2.5}
strokeLinecap="round"
/>
</g>
))}
{[0, 1, 2, 3].map((bar) => {
const height = [18, 30, 24, 40][bar % 4];
return (
<rect
key={bar}
x={104 + bar * 11}
y={74 - height}
width={7}
height={height}
rx={2}
fill={bar === 3 ? MARK : RIM}
/>
);
})}
</motion.g>
);
};
const WebPage: React.FC<{ screen: Rect; index: number }> = ({
screen,
index,
}) => {
const { header, cards } = webLayout(screen, index);
return (
<g>
<motion.rect
rx={4}
fill={SOFT}
stroke={LINE}
strokeWidth={1}
initial={false}
animate={{
attrX: header.x,
attrY: header.y,
width: header.width,
height: header.height,
}}
transition={MOVE}
/>
{cards.map((card, i) => (
<g key={i}>
<motion.rect
rx={6}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
initial={false}
animate={{
attrX: card.x,
attrY: card.y,
width: card.width,
height: card.height,
}}
transition={MOVE}
/>
<motion.line
stroke={i === 2 ? MARK : INK}
strokeWidth={3}
strokeLinecap="round"
initial={false}
animate={{
x1: card.x + 9,
x2: card.x + Math.min(card.width - 9, 9 + 46),
y1: card.y + 12,
y2: card.y + 12,
}}
transition={MOVE}
/>
<motion.line
stroke={RIM}
strokeWidth={2.5}
strokeLinecap="round"
initial={false}
animate={{
x1: card.x + 9,
x2: card.x + Math.min(card.width - 9, 9 + 62),
y1: card.y + 23,
y2: card.y + 23,
}}
transition={MOVE}
/>
</g>
))}
</g>
);
};
/**
* PresentationCompare: the same presentation as a slide file and as a website,
* on a projector, a laptop and a phone. It steps through the screens on its
* own until the visitor picks one.
*/
export const PresentationCompare: React.FC = () => {
const { compare } = useContent().presentations;
const narrow = useNarrow();
const ref = React.useRef<HTMLDivElement>(null);
const [index, setIndex] = React.useState(0);
const [auto, setAuto] = React.useState(true);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry?.isIntersecting ?? false),
{ threshold: 0.3 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!auto || !inView) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = window.setInterval(
() => setIndex((current) => (current + 1) % SCREENS.length),
STEP_MS,
);
return () => window.clearInterval(timer);
}, [auto, inView]);
// Side by side on wide screens, one above the other on a phone.
const view = narrow
? { width: 380, height: 600 }
: { width: 760, height: 300 };
const stages = narrow
? [
{ cx: 190, cy: 150 },
{ cx: 190, cy: 450 },
]
: [
{ cx: 190, cy: 150 },
{ cx: 570, cy: 150 },
];
const size = SCREENS[index] ?? SCREENS[0];
if (!size) return null;
const slideScreen = centred(size, stages[0]?.cx ?? 0, stages[0]?.cy ?? 0);
const webScreen = centred(size, stages[1]?.cx ?? 0, stages[1]?.cy ?? 0);
const tooSmall = index === SCREENS.length - 1;
return (
<div ref={ref}>
<svg
viewBox={`0 0 ${view.width} ${view.height}`}
role="img"
aria-label={compare.description}
className="block h-auto w-full [--scene-label:13px] md:[--scene-label:10px]"
>
{[
{
stage: stages[0],
name: compare.slides,
note: compare.slideNotes[index],
colour: tooSmall ? MARK : "#94a3b8",
},
{
stage: stages[1],
name: compare.web,
note: compare.webNotes[index],
colour: LIVE,
},
].map(
(column) =>
column.stage && (
<g key={column.name}>
<text
x={column.stage.cx}
y={column.stage.cy - 118}
textAnchor="middle"
style={{ ...MONO, fill: INK }}
>
{column.name}
</text>
<motion.text
key={column.note}
x={column.stage.cx}
y={column.stage.cy + 128}
textAnchor="middle"
style={{ ...MONO, fill: column.colour }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.35 }}
>
{column.note}
</motion.text>
</g>
),
)}
<Frame rect={slideScreen} />
<Slide screen={slideScreen} />
<Frame rect={webScreen} />
<WebPage screen={webScreen} index={index} />
</svg>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2 md:mt-8">
<span className="mr-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
{compare.pickScreen}
</span>
{compare.screens.map((screen, i) => {
const selected = i === index;
return (
<button
key={screen}
type="button"
aria-pressed={selected}
onClick={() => {
setIndex(i);
setAuto(false);
}}
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-white bg-white text-slate-900"
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
}`}
>
{screen}
</button>
);
})}
</div>
</div>
);
};
const draw = (delay: number) => ({
initial: { pathLength: 0, opacity: 0 },
whileInView: { pathLength: 1, opacity: 1 },
viewport: { once: true, margin: "-10%" },
transition: { duration: 0.7, delay, ease: "easeOut" as const },
});
/** Every screen: a projector, a laptop and a phone drawn one after another. */
const ScreensDrawing: React.FC = () => (
<g fill="none" stroke={INK} strokeWidth={FINE} strokeLinejoin="round">
<motion.rect x={14} y={14} width={86} height={50} rx={5} {...draw(0)} />
<motion.path d="M 44 74 h 26 M 57 64 v 10" stroke={RIM} {...draw(0.2)} />
<motion.rect x={112} y={30} width={60} height={38} rx={4} {...draw(0.35)} />
<motion.path d="M 104 72 h 76" stroke={RIM} {...draw(0.5)} />
<motion.rect
x={190}
y={22}
width={28}
height={52}
rx={6}
fill={TINT}
{...draw(0.7)}
/>
</g>
);
/** One link: several copies of a file on one side, a single address on the other. */
const LinkDrawing: React.FC = () => (
<g>
{[0, 1, 2].map((copy) => (
<rect
key={copy}
x={18 + copy * 12}
y={18 + copy * 8}
width={38}
height={48}
rx={4}
fill="#0b1220"
stroke={LINE}
strokeWidth={1}
strokeDasharray="3 3"
/>
))}
<motion.path
d="M 96 46 h 30"
stroke={INK}
strokeWidth={FINE}
markerEnd="url(#reason-arrow)"
{...draw(0.2)}
/>
<rect
x={138}
y={32}
width={82}
height={28}
rx={14}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<motion.circle
cx={152}
cy={46}
r={3.5}
fill={LIVE}
animate={{ opacity: [1, 0.35, 1] }}
transition={{ duration: 1.8, repeat: Infinity }}
/>
<line
x1={162}
x2={208}
y1={46}
y2={46}
stroke={INK}
strokeWidth={2.5}
strokeLinecap="round"
/>
</g>
);
/** The real thing: a chart whose bars keep moving, because the figures are live. */
const LiveDrawing: React.FC = () => (
<g>
<rect
x={24}
y={12}
width={186}
height={66}
rx={8}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
/>
<line x1={38} x2={196} y1={66} y2={66} stroke={LINE} strokeWidth={1} />
{[0, 1, 2, 3, 4, 5].map((bar) => {
const low = [14, 22, 18, 30, 24, 36][bar % 6] ?? 20;
const high = [26, 16, 34, 22, 40, 28][bar % 6] ?? 30;
return (
<motion.rect
key={bar}
x={44 + bar * 25}
width={14}
rx={3}
fill={bar === 5 ? MARK : RIM}
initial={false}
animate={{
height: [low, high, low],
attrY: [66 - low, 66 - high, 66 - low],
}}
transition={{
duration: 3.6,
delay: bar * 0.25,
repeat: Infinity,
ease: "easeInOut",
}}
/>
);
})}
<motion.circle
cx={198}
cy={24}
r={3}
fill={LIVE}
animate={{ opacity: [1, 0.3, 1] }}
transition={{ duration: 1.6, repeat: Infinity }}
/>
</g>
);
/** Afterwards: a page that is read on its own and passed on. */
const AfterDrawing: React.FC = () => (
<g>
<rect
x={30}
y={10}
width={74}
height={70}
rx={6}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
{[0, 1, 2, 3].map((row) => (
<line
key={row}
x1={42}
x2={42 + [48, 40, 46, 30][row % 4]}
y1={26 + row * 12}
y2={26 + row * 12}
stroke={row === 0 ? INK : RIM}
strokeWidth={row === 0 ? 3 : 2.5}
strokeLinecap="round"
/>
))}
{[24, 46, 68].map((y, i) => (
<g key={y}>
<motion.path
d={`M 104 45 C 136 45 138 ${y} 168 ${y}`}
fill="none"
stroke={INK}
strokeWidth={FINE}
{...draw(0.2 + i * 0.2)}
/>
<circle
cx={180}
cy={y}
r={9}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
/>
<circle cx={180} cy={y - 2} r={2.6} fill={RIM} />
<path
d={`M 174.5 ${y + 5.5} q 5.5 -6 11 0`}
fill="none"
stroke={RIM}
strokeWidth={1.4}
strokeLinecap="round"
/>
</g>
))}
</g>
);
const REASON_DRAWINGS: readonly React.FC[] = [
ScreensDrawing,
LinkDrawing,
LiveDrawing,
AfterDrawing,
];
/** The small drawing next to one of the reasons, by its position in the list. */
export const ReasonDrawing: React.FC<{ index: number }> = ({ index }) => {
const Drawing = REASON_DRAWINGS[index];
if (!Drawing) return null;
return (
<svg
viewBox="0 0 234 90"
aria-hidden="true"
className="block h-auto w-full"
>
<defs>
<marker
id="reason-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
</marker>
</defs>
<Drawing />
</svg>
);
};
@@ -203,7 +203,7 @@ export const WorkShowcase: React.FC = () => {
{copy.text}
</p>
<ul className="m-0 mt-8 list-none p-0">
<ul className="m-0 mt-8 hidden list-none p-0 md:block">
{copy.did.map((line) => (
<li
key={line}
@@ -0,0 +1,210 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
/**
* The parts the drawings share with the story thread: everything is laid out
* like a circuit board. Systems and people are chips with pins, connections are
* tracks with solder points, and what travels along them is a bit.
*/
export const CIRCUIT = {
ink: "#f8fafc",
ground: "#0f172a",
line: "#475569",
rim: "#64748b",
soft: "#1e293b",
mark: "#fde68a",
live: "#10b981",
light: "#a5b4fc",
tint: "rgba(248, 250, 252, 0.14)",
} as const;
const MONO = "var(--font-jetbrains-mono), ui-monospace, monospace";
const PIN = 5;
type ChipTone = "line" | "lit" | "muted";
/** A system or a person: a chip with pins on its long sides and a label. */
export const Chip: React.FC<{
x: number;
y: number;
width: number;
height: number;
label: string;
/** Font size as a number or a CSS value. */
size: number | string;
tone?: ChipTone;
/** Pins per long side. */
pins?: number;
}> = ({ x, y, width, height, label, size, tone = "line", pins = 4 }) => {
const muted = tone === "muted";
const stroke = muted ? CIRCUIT.line : CIRCUIT.ink;
const left = x - width / 2;
const top = y - height / 2;
return (
<g>
{Array.from({ length: pins }, (_, i) => {
const px = left + ((i + 1) * width) / (pins + 1);
return (
<path
key={i}
d={`M${px} ${top}v${-PIN}M${px} ${top + height}v${PIN}`}
stroke={muted ? CIRCUIT.line : CIRCUIT.rim}
strokeWidth={1.25}
/>
);
})}
<rect
x={left}
y={top}
width={width}
height={height}
rx={4}
fill={tone === "lit" ? CIRCUIT.tint : CIRCUIT.ground}
stroke={stroke}
strokeWidth={tone === "lit" ? 1.5 : 1.1}
strokeDasharray={muted ? "4 4" : undefined}
style={
tone === "lit"
? { filter: "drop-shadow(0 0 6px rgba(165, 180, 252, 0.7))" }
: undefined
}
/>
{/* The notch that marks pin one. */}
<circle
cx={left + 7}
cy={top + 7}
r={1.6}
fill={muted ? CIRCUIT.line : CIRCUIT.rim}
/>
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="central"
style={{
fontFamily: MONO,
fontSize: size,
letterSpacing: "0.12em",
textTransform: "uppercase",
fill: muted ? "#94a3b8" : CIRCUIT.ink,
}}
>
{label}
</text>
</g>
);
};
/** A solder point: where a track ends, bends or meets a chip. */
export const Pad: React.FC<{
x: number;
y: number;
muted?: boolean;
lit?: boolean;
}> = ({ x, y, muted = false, lit = false }) => (
<circle
cx={x}
cy={y}
r={3.2}
fill={lit ? CIRCUIT.light : CIRCUIT.ground}
stroke={muted ? CIRCUIT.line : CIRCUIT.ink}
strokeWidth={1.25}
/>
);
/**
* A bit travelling a route, again and again. `xs` and `ys` are the stops; it
* fades in after the first and out before the last unless `opacity` says otherwise.
*/
export const Bit: React.FC<{
xs: readonly number[];
ys: readonly number[];
opacity?: readonly number[];
delay?: number;
duration?: number;
pause?: number;
/** The digit it shows. */
value?: 0 | 1;
}> = ({
xs,
ys,
opacity,
delay = 0,
duration = 1.2,
pause = 1.4,
value = 1,
}) => (
<motion.g
initial={{ x: xs[0], y: ys[0], opacity: 0 }}
animate={{
x: [...xs],
y: [...ys],
opacity: opacity
? [...opacity]
: xs.map((_, i) => (i === 0 || i === xs.length - 1 ? 0 : 1)),
}}
transition={{
duration,
delay,
repeat: Infinity,
repeatDelay: pause,
ease: "easeInOut",
}}
>
<rect
x={-6}
y={-6}
width={12}
height={12}
rx={2}
fill={CIRCUIT.mark}
style={{ filter: "drop-shadow(0 0 6px rgba(251, 191, 36, 0.9))" }}
/>
<text
textAnchor="middle"
dominantBaseline="central"
style={{
fontFamily: MONO,
fontSize: 9,
fontWeight: 700,
fill: CIRCUIT.ground,
}}
>
{value}
</text>
</motion.g>
);
/** A track: straight runs and 45 degree bends, with a second fainter track beside it. */
export const Track: React.FC<{
d: string;
muted?: boolean;
/** Draw the parallel second track. */
bus?: boolean;
}> = ({ d, muted = false, bus = false }) => (
<g fill="none" strokeLinecap="square" strokeLinejoin="miter">
{bus && (
<path
d={d}
stroke={CIRCUIT.light}
strokeWidth={1}
opacity={0.35}
transform="translate(4 4)"
/>
)}
<path
d={d}
stroke={muted ? CIRCUIT.line : CIRCUIT.ink}
strokeWidth={muted ? 1.25 : 1.6}
strokeDasharray={muted ? "4 4" : undefined}
style={
muted
? undefined
: { filter: "drop-shadow(0 0 4px rgba(165, 180, 252, 0.6))" }
}
/>
</g>
);
@@ -0,0 +1,36 @@
import * as React from "react";
import type { Figure } from "./threadPath";
/**
* The opening of a chapter: one sentence of the story, and the box in which
* the story thread stands its figure. The thread runs along the bottom edge of
* this row, so the sentence stands on the line. `flip` mirrors the row; the
* thread alternates sides from chapter to chapter.
*/
export const ChapterIntro: React.FC<{
line: string;
figure: Figure;
flip: boolean;
dark?: boolean;
}> = ({ line, figure, flip, dark = false }) => (
<div
className={`mb-12 flex flex-col pt-20 md:mb-24 md:items-end md:gap-16 md:pt-40 ${
flip ? "md:flex-row-reverse" : "md:flex-row"
}`}
>
<p
className={`order-2 m-0 mt-6 max-w-3xl font-sans text-2xl font-bold leading-[1.1] tracking-tight md:order-none md:mt-0 md:pb-6 md:text-5xl ${
flip ? "md:ml-auto md:text-right" : ""
} ${dark ? "text-white" : "text-slate-900"}`}
>
{line}
</p>
<span
data-thread-figure={figure}
className={`order-1 block h-[72px] w-[72px] shrink-0 md:order-none md:h-[132px] md:w-[132px] ${
flip ? "self-start md:mr-auto md:self-end" : "self-end md:ml-auto"
}`}
aria-hidden="true"
/>
</div>
);
@@ -0,0 +1,347 @@
"use client";
import * as React from "react";
import { deepestSoFar, lengthAtDepth } from "@/src/domain/thread";
import {
FIGURE_CENTRE,
FIGURE_PINS,
threadPath,
type Box,
type Figure,
type Point,
type Stop,
} from "./threadPath";
/** Marks the box a chapter's figure stands in; the value is the figure. */
export const FIGURE_ATTRIBUTE = "data-thread-figure";
/** Marks the point the line ends at. */
export const END_ATTRIBUTE = "data-thread-end";
/** Marks the section the line starts in. */
export const START_ATTRIBUTE = "data-thread-start";
/** Distance between the samples used to map scroll depth to drawn length, in px. */
const SAMPLE_STEP = 10;
/** The line is drawn down to this share of the window height. */
const READING_LINE = 0.66;
/** How quickly the drawing catches up with the scroll position, per frame. */
const FOLLOW = 0.075;
const CONTENT_WIDTH = 1400;
/** Offset of the second, parallel trace. */
const BUS_OFFSET = 5;
const PAD_RADIUS = 4;
/** Room between the last crossing of the trace and the sentence it ends at. */
const END_DROP = { wide: 120, narrow: 84 } as const;
/** The bit at the head of the trace flips after this much travel, in px. */
const FLIP_EVERY = 46;
/** The first chapter with a figure is the second of the page. */
const FIRST_CHAPTER = 2;
type Mark = { readonly x: number; readonly y: number; readonly dark: boolean };
type Part = {
readonly figure: Figure;
readonly box: Box;
readonly dark: boolean;
/** The chapter number as four binary digits. */
readonly label: string;
};
type Layout = {
readonly width: number;
readonly height: number;
readonly d: string;
readonly dark: readonly Box[];
readonly pads: readonly Mark[];
readonly parts: readonly Part[];
};
const FIGURES: readonly Figure[] = [
"diamond",
"hexagon",
"screen",
"circle",
"square",
"triangle",
];
const isFigure = (value: string | null): value is Figure =>
FIGURES.some((figure) => figure === value);
const inside = (box: Box, x: number, y: number): boolean =>
x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
/**
* StoryThread: one trace that runs down the whole homepage like a track on a
* circuit board and draws itself as the visitor scrolls. Under each chapter's
* sentence it crosses the page, stands a component on the track, and continues
* on the other side, so the eye is led from chapter to chapter. Solder points
* mark its bends, a second track runs beside it, and a bit travels at its head.
* Put it last inside the `relative` wrapper that holds the sections.
*/
export const StoryThread: React.FC = () => {
const svgRef = React.useRef<SVGSVGElement>(null);
const bitRef = React.useRef<HTMLSpanElement>(null);
const clipId = React.useId();
const [layout, setLayout] = React.useState<Layout | null>(null);
// Measure the page: where the figures stand, where the dark sections are.
React.useEffect(() => {
const wrapper = svgRef.current?.parentElement;
if (!wrapper) return;
let timer = 0;
const measure = () => {
const origin = wrapper.getBoundingClientRect();
const boxOf = (element: Element): Box => {
const rect = element.getBoundingClientRect();
return {
x: rect.left - origin.left,
y: rect.top - origin.top,
width: rect.width,
height: rect.height,
};
};
const stops: Stop[] = [];
wrapper.querySelectorAll(`[${FIGURE_ATTRIBUTE}]`).forEach((element) => {
const figure = element.getAttribute(FIGURE_ATTRIBUTE);
const box = boxOf(element);
// Hidden panels have no size: they are not on the visitor's way.
if (isFigure(figure) && box.width > 0) stops.push({ figure, box });
});
const startSection = wrapper.querySelector(`[${START_ATTRIBUTE}]`);
if (!startSection || stops.length === 0) {
setLayout(null);
return;
}
// The margins: half way between the edge of the window and the content.
const padding = origin.width >= 768 ? 48 : 20;
const margin =
(Math.max(0, origin.width - CONTENT_WIDTH) / 2 + padding) / 2;
const left = Math.round(margin);
const right = Math.round(origin.width - margin);
const endElement = wrapper.querySelector(`[${END_ATTRIBUTE}]`);
const endBox = endElement ? boxOf(endElement) : null;
const dark = Array.from(
wrapper.querySelectorAll("section.bg-slate-900"),
).map(boxOf);
const isDark = (x: number, y: number): boolean =>
dark.some((box) => inside(box, x, y));
const thread = threadPath({
start: [left, boxOf(startSection).y + 1],
stops,
end: endBox ? [endBox.x, endBox.y] : null,
endDrop: origin.width >= 768 ? END_DROP.wide : END_DROP.narrow,
left,
right,
});
setLayout({
width: origin.width,
height: origin.height,
d: thread.d,
dark,
pads: thread.pads.map(([x, y]: Point) => ({
x,
y,
dark: isDark(x, y),
})),
parts: stops.map((stop, index) => ({
...stop,
dark: isDark(
stop.box.x + stop.box.width / 2,
stop.box.y + stop.box.height / 2,
),
label: (index + FIRST_CHAPTER).toString(2).padStart(4, "0"),
})),
});
};
const schedule = () => {
window.clearTimeout(timer);
timer = window.setTimeout(measure, 120);
};
measure();
const observer = new ResizeObserver(schedule);
observer.observe(wrapper);
return () => {
observer.disconnect();
window.clearTimeout(timer);
};
}, []);
// Draw as far as the visitor has read, and move the bit to the head of the trace.
React.useEffect(() => {
const svg = svgRef.current;
const bit = bitRef.current;
if (!layout || !svg || !bit) return;
const traces = Array.from(
svg.querySelectorAll<SVGPathElement>("path[data-trace]"),
);
const main = traces[0];
if (!main) return;
const total = main.getTotalLength();
const points: DOMPoint[] = [];
for (let at = 0; at <= total; at += SAMPLE_STEP) {
points.push(main.getPointAtLength(at));
}
const depths = deepestSoFar(points.map((point) => point.y));
for (const trace of traces) trace.style.strokeDasharray = `${total}`;
// Solder points and components appear once the trace has reached them.
const reached = Array.from(
svg.querySelectorAll<SVGElement>("[data-at-x]"),
).map((element) => {
const x = Number(element.dataset.atX);
const y = Number(element.dataset.atY);
const distances = points.map((point) =>
Math.hypot(point.x - x, point.y - y),
);
const nearest = distances.indexOf(Math.min(...distances));
return { element, length: nearest * SAMPLE_STEP };
});
const show = (length: number) => {
const offset = `${total - length}`;
for (const trace of traces) trace.style.strokeDashoffset = offset;
for (const mark of reached) {
mark.element.style.opacity = length + 2 >= mark.length ? "1" : "0";
}
const head = main.getPointAtLength(length);
bit.style.transform = `translate(${head.x}px, ${head.y}px)`;
bit.style.opacity = length > 0 && length < total - 1 ? "1" : "0";
bit.textContent = Math.floor(length / FLIP_EVERY) % 2 === 0 ? "1" : "0";
};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
show(total);
return;
}
let frame = 0;
let drawn = 0;
const target = () => {
const top = svg.getBoundingClientRect().top;
const depth = window.innerHeight * READING_LINE - top;
return Math.min(total, lengthAtDepth(depths, SAMPLE_STEP, depth));
};
const tick = () => {
frame = window.requestAnimationFrame(tick);
const gap = target() - drawn;
if (Math.abs(gap) < 0.5) return;
drawn += gap * FOLLOW;
show(drawn);
};
show(0);
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [layout]);
const tone = (dark: boolean) =>
dark ? "story-thread-on-dark" : "story-thread-on-light";
return (
<>
<svg
ref={svgRef}
aria-hidden="true"
width={layout?.width ?? 0}
height={layout?.height ?? 0}
className="story-thread pointer-events-none absolute left-0 top-0 z-10"
fill="none"
>
{layout && (
<>
<defs>
<clipPath id={clipId}>
{layout.dark.map((box) => (
<rect key={`${box.y}-${box.height}`} {...box} />
))}
</clipPath>
</defs>
{/* The main track first: it is the one that is measured. */}
<path
data-trace=""
d={layout.d}
className="story-thread-on-light"
/>
<path
data-trace=""
d={layout.d}
className="story-thread-on-light story-thread-bus"
transform={`translate(${BUS_OFFSET} ${BUS_OFFSET})`}
/>
<g clipPath={`url(#${clipId})`}>
<path
data-trace=""
d={layout.d}
className="story-thread-on-dark"
/>
<path
data-trace=""
d={layout.d}
className="story-thread-on-dark story-thread-bus"
transform={`translate(${BUS_OFFSET} ${BUS_OFFSET})`}
/>
</g>
{layout.parts.map((part) => {
const place = ([ux, uy]: Point): Point => [
part.box.x + (ux / 100) * part.box.width,
part.box.y + (uy / 100) * part.box.height,
];
const [labelX, labelY] = place(FIGURE_CENTRE[part.figure]);
return (
<g
key={part.label}
data-at-x={part.box.x + part.box.width / 2}
data-at-y={part.box.y + part.box.height}
className={`story-thread-part ${tone(part.dark)}`}
>
{FIGURE_PINS[part.figure].map(([from, to]) => {
const [x1, y1] = place(from);
const [x2, y2] = place(to);
return (
<line
key={`${from[0]}-${from[1]}-${to[0]}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
/>
);
})}
<text
x={labelX}
y={labelY}
textAnchor="middle"
dominantBaseline="middle"
fontSize={Math.max(9, part.box.width * 0.105)}
>
{part.label}
</text>
</g>
);
})}
{layout.pads.map((pad) => (
<circle
key={`${pad.x}-${pad.y}`}
data-at-x={pad.x}
data-at-y={pad.y}
cx={pad.x}
cy={pad.y}
r={PAD_RADIUS}
className={`story-thread-pad ${tone(pad.dark)}`}
/>
))}
</>
)}
</svg>
<span
ref={bitRef}
aria-hidden="true"
className="story-thread-bit pointer-events-none absolute left-0 top-0 z-10 opacity-0"
/>
</>
);
};
+190
View File
@@ -0,0 +1,190 @@
/**
* Geometry of the story thread: one continuous trace down the page. At every
* chapter it runs across the full width under the chapter's sentence, stands a
* figure on that line, and continues down the other side.
*/
export type Figure =
| "diamond"
| "hexagon"
| "screen"
| "circle"
| "square"
| "triangle";
export type Box = {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
};
export type Stop = {
readonly figure: Figure;
/** Where the figure stands; the line runs along the bottom edge of this box. */
readonly box: Box;
};
export type Point = readonly [number, number];
/**
* Closed figures in a 100 x 100 box, starting and ending at the bottom centre
* (50, 100) and leaving it to the right. The line arrives there, goes once
* round the figure, and carries on.
*/
const POLYGONS: Readonly<Record<Exclude<Figure, "circle">, readonly Point[]>> =
{
diamond: [
[95, 55],
[50, 10],
[5, 55],
[50, 100],
],
hexagon: [
[75, 100],
[98, 57],
[75, 14],
[25, 14],
[2, 57],
[25, 100],
[50, 100],
],
screen: [
[98, 100],
[98, 36],
[2, 36],
[2, 100],
[50, 100],
],
square: [
[90, 100],
[90, 20],
[10, 20],
[10, 100],
[50, 100],
],
triangle: [
[98, 100],
[50, 14],
[2, 100],
[50, 100],
],
};
const CORNER = 36;
const round = (value: number): number => Math.round(value * 10) / 10;
/** The figure as path commands. `direction` is 1 when the line arrives heading right, -1 heading left. */
const figurePath = (figure: Figure, box: Box, direction: 1 | -1): string => {
const place = ([ux, uy]: Point): string => {
const x = direction === 1 ? ux : 100 - ux;
return `${round(box.x + (x / 100) * box.width)} ${round(box.y + (uy / 100) * box.height)}`;
};
if (figure === "circle") {
// Four quarter arcs of a circle with centre (50, 55) and radius 45.
const k = 24.85;
return [
`C${place([50 + k, 100])} ${place([95, 55 + k])} ${place([95, 55])}`,
`C${place([95, 55 - k])} ${place([50 + k, 10])} ${place([50, 10])}`,
`C${place([50 - k, 10])} ${place([5, 55 - k])} ${place([5, 55])}`,
`C${place([5, 55 + k])} ${place([50 - k, 100])} ${place([50, 100])}`,
].join("");
}
return POLYGONS[figure].map((point) => `L${place(point)}`).join("");
};
export type Thread = {
readonly d: string;
/** Where the trace branches or turns: the solder points, in the order the line passes them. */
readonly pads: readonly Point[];
};
/**
* The whole line, laid out like a trace on a circuit board: straight runs and
* 45 degree bends. It starts at `start` in the left margin, visits the stops in
* order (arriving from the left at the first, from the right at the second, and
* so on), and ends at `end`. `left` and `right` are the x of the two margins.
*/
export const threadPath = (thread: {
readonly start: Point;
readonly stops: readonly Stop[];
readonly end: Point | null;
/** How far above its end the trace crosses the page when it comes from the other side. */
readonly endDrop: number;
readonly left: number;
readonly right: number;
}): Thread => {
const { left, right } = thread;
const pads: Point[] = [thread.start];
let d = `M${round(thread.start[0])} ${round(thread.start[1])}`;
let side = left;
thread.stops.forEach((stop, index) => {
const direction = index % 2 === 0 ? 1 : -1;
const from = direction === 1 ? left : right;
const to = direction === 1 ? right : left;
const y = round(stop.box.y + stop.box.height);
const foot = round(stop.box.x + stop.box.width / 2);
d +=
`L${from} ${y - CORNER}L${from + direction * CORNER} ${y}` +
`L${foot} ${y}` +
figurePath(stop.figure, stop.box, direction) +
`L${to - direction * CORNER} ${y}L${to} ${y + CORNER}`;
pads.push([from, y - CORNER], [foot, y], [to, y + CORNER]);
side = to;
});
if (thread.end) {
const [x, y] = thread.end;
const away = Math.abs(x - side);
if (away <= 2 * CORNER) {
// The end is beside the margin the trace is in: one bend leads to it.
d += `L${side} ${round(y - away)}L${round(x)} ${round(y)}`;
} else {
// The end is on the other side: cross the page above it, then come
// straight down to it, beside the text. One long diagonal instead would start far up the page.
const inward = x < side ? -1 : 1;
const across = round(y - thread.endDrop);
d +=
`L${side} ${across - CORNER}L${side + inward * CORNER} ${across}` +
`L${round(x - inward * CORNER)} ${across}L${round(x)} ${across + CORNER}L${round(x)} ${round(y)}`;
pads.push([side, across - CORNER]);
}
pads.push([round(x), round(y)]);
}
return { d, pads };
};
/** Extra marks that make a figure read as a component: pins, as lines in the 100 x 100 box. */
export const FIGURE_PINS: Readonly<
Record<Figure, readonly (readonly [Point, Point])[]>
> = {
diamond: [],
hexagon: [],
circle: [],
triangle: [],
screen: [20, 35, 50, 65, 80].map((x) => [
[x, 36],
[x, 24],
]),
square: [38, 54, 70, 86].flatMap((y) => [
[
[10, y],
[-2, y],
],
[
[90, y],
[102, y],
],
]),
};
/** Where the label sits inside each figure, in the 100 x 100 box. */
export const FIGURE_CENTRE: Readonly<Record<Figure, Point>> = {
diamond: [50, 55],
hexagon: [50, 57],
screen: [50, 68],
circle: [50, 55],
square: [50, 60],
triangle: [50, 74],
};
+224 -71
View File
@@ -10,14 +10,14 @@ export const de: Content = {
site: {
title: "Freelance-Webentwickler für Web-Apps & Websites | Marc Mintel",
description:
"Freelance-Webentwickler mit 15 Jahren Erfahrung. Ich baue Web-Apps, Oberflächen und Websites mit React, Next.js und TypeScript und automatisiere Abläufe.",
"Freelance-Webentwickler mit 15 Jahren Erfahrung. Ich baue Web-Apps, Interfaces und Websites mit React, Next.js und TypeScript und automatisiere Abläufe.",
jobTitle: "Freelance-Webentwickler",
ogLocale: "de_DE",
shareImageAlt:
"Web-Apps und Websites. Marc Mintel, Freelance-Webentwickler mit 15 Jahren Erfahrung.",
knowsAbout: [
"Webanwendungen",
"Benutzeroberflächen",
"User Interfaces",
"Websites",
"Prozessautomatisierung",
"Web3-Frontends",
@@ -26,6 +26,7 @@ export const de: Content = {
},
nav: {
contact: "Kontakt",
freelance: "Freelance",
contactLong: GET_IN_TOUCH,
logoLabel: "Marc Mintel, nach oben",
imprint: "Impressum",
@@ -40,18 +41,22 @@ export const de: Content = {
{ text: "& WEBSITES.", tone: "bits" },
],
lead: "Freelance-Webentwickler mit 15 Jahren Erfahrung.",
leadStrong: "Ich baue Web-Apps, Oberflächen und Websites.",
leadStrong: "Ich baue Web-Apps, Interfaces und Websites.",
primaryCta: GET_IN_TOUCH,
secondaryCta: "Was ich mache",
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
builtAndRunning: "Gebaut und in Betrieb:",
trust: [
"Ein Ansprechpartner, der es selbst baut",
"Ein Angebot, bevor etwas kostet",
"Änderungen per E-Mail, kein System zu lernen",
"Server in Deutschland",
],
},
hello: {
label: "Hallo",
title: "Ich bin Marc.",
lead: "Ich baue seit 15 Jahren für das Web: zuerst in Agenturen, später als Senior-Entwickler und heute selbstständig.",
paragraphs: [
"Meine Stärke ist der Teil, den Menschen sehen und benutzen: Design und Oberfläche. Ich kenne mich auch mit dem aus, was dahinter liegt, und kann deshalb eine vollständige Anwendung bauen und nicht nur ihre Oberfläche.",
"Meine Stärke ist der Teil, den Menschen sehen und benutzen: Design und Interface. Ich kenne mich auch mit dem aus, was dahinter liegt, und kann deshalb eine vollständige Anwendung bauen und nicht nur ihr Interface.",
"Sie arbeiten direkt mit mir. Egal, ob Sie ein Produktteam leiten oder so etwas zum ersten Mal in Auftrag geben.",
],
portraitAlt: "Marc Mintel, Freelance-Webentwickler",
@@ -73,11 +78,60 @@ export const de: Content = {
},
services: {
label: "Leistungen",
title: { lead: "Was brauchen Sie?", rest: "Vier typische Fälle." },
title: { lead: "Was brauchen Sie?", rest: "Meist eines von beiden." },
tablistLabel: "Was brauchen Sie?",
youGet: "Das bekommen Sie",
howItRuns: "So läuft es ab",
whatItCosts: "Das kostet es",
care: {
label: "Nach dem Start",
title: "Ich kümmere mich weiter um Ihre Website, wenn Sie das möchten.",
text: "Sie müssen kein System bedienen und nichts selbst pflegen. Wenn etwas zu ändern ist, schreiben Sie mir eine E-Mail.",
price: {
label: "Betreuung",
amount: "89 EUR",
note: "im Monat, netto, monatlich kündbar",
},
includedLabel: "Das ist enthalten",
included: [
"Die Website läuft auf Servern in Deutschland",
"Adresse, Sicherheit und Technik sind erledigt",
"Kleine Änderungen: ein Text, ein Bild, neue Öffnungszeiten",
"Kein Redaktionssystem, kein Login",
],
termsLabel: "Gut zu wissen",
terms: [
"Die ersten drei Monate sind im Projektpreis enthalten.",
"Größeres, etwa eine neue Seite, bekommt vorher einen Preis.",
"Ich antworte innerhalb von zwei Werktagen. Eine Rufbereitschaft gibt es nicht.",
"Wenn Sie gehen möchten, bekommen Sie alles mit. Die Website gehört Ihnen.",
],
},
offer: {
title: "Sie bekommen ein Angebot, bevor etwas kostet.",
text: "Schreiben Sie mir kurz, was Sie brauchen. Wir sprechen etwa 20 Minuten, danach bekommen Sie ein schriftliches Angebot. Ich fange erst an, wenn Sie zugestimmt haben.",
cta: "Angebot anfragen",
},
estimate: {
label: "Was es kostet",
title: "So kommen Sie zu einem Preis",
steps: [
{
title: "Ein Gespräch, etwa 20 Minuten",
text: "Sie erzählen mir, was Sie brauchen.",
},
{
title: "Eine schriftliche Schätzung",
text: "Sie sehen die geschätzten Stunden und was darin enthalten ist.",
},
{
title: "Sie geben ein Budget frei",
text: "Ich rechne die tatsächlichen Stunden ab und sage früh Bescheid, wenn es nicht reicht.",
},
],
orientationLabel: "Zur Orientierung",
net: "Alle Preise netto.",
cta: "Schätzung anfragen",
},
paths: [
{
id: "developer",
@@ -86,10 +140,10 @@ export const de: Content = {
forWhom: "Start-ups, Produktteams, Agenturen, Web3-Teams",
headline: "Ein erfahrener Entwickler, der in Ihr Projekt einsteigt.",
intro:
"Sie haben ein Produkt und mehr Arbeit, als Ihr Team schafft. Ich arbeite an vereinbarten Wochentagen an dem, was als Nächstes auf Ihrer Liste steht: neue Funktionen, die Oberfläche oder die Teile, zu denen niemand kommt.",
"Sie haben ein Produkt und mehr Arbeit, als Ihr Team schafft. Ich arbeite an vereinbarten Wochentagen an dem, was als Nächstes auf Ihrer Liste steht: neue Funktionen, das Interface oder die Teile, zu denen niemand kommt.",
youGet: [
"Web-Apps, Dashboards, Kundenportale und interne Werkzeuge",
"Oberflächen mit Blick für Design und Bedienbarkeit",
"Interfaces mit Blick für Design und Bedienbarkeit",
"Web3-Frontends: Wallet-Anbindung, Live-Daten, Dashboards",
"Kleine Werkzeuge und Automatisierung für wiederkehrende Arbeit",
],
@@ -111,16 +165,9 @@ export const de: Content = {
text: "Die Vereinbarung kann mit zwei Wochen Frist zum Monatsende beendet werden.",
},
],
pricingModel: "Nach Aufwand, solange Ihr Projekt läuft",
pricingText:
"Ich arbeite an vereinbarten Wochentagen an Ihrem Projekt und rechne die Stunden ab, die ich tatsächlich gearbeitet habe. Es gibt kein Paket zu kaufen und keine Mindestlaufzeit.",
prices: [
{ label: "Pro Stunde", amount: "100 EUR", note: "netto" },
{ label: "Pro Tag", amount: "800 EUR", note: "8 Stunden, netto" },
],
smallPrint:
"Eine Rechnung pro Monat, mit den erfassten Stunden. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
cta: GET_IN_TOUCH,
orientation: "100 EUR pro Stunde, monatsweise gebucht",
orientationNote:
"Sie buchen feste Wochentage für jeweils einen Monat. Abgerechnet werden die Stunden, die ich tatsächlich gearbeitet habe.",
},
{
id: "rescue",
@@ -156,19 +203,9 @@ export const de: Content = {
text: "Mit mir, mit Ihrem eigenen Team oder gar nicht.",
},
],
pricingModel: "Eine feste Woche zum festen Preis",
pricingText:
"Der Umfang ist eine Woche meiner Zeit. Sie kennen die Kosten, bevor ich anfange.",
prices: [
{
label: "Eine Woche, 35 Stunden",
amount: "3.500 EUR",
note: "netto",
},
],
smallPrint:
"Eine Woche behebt nicht alles. Wenn ich im ersten Gespräch sehe, dass sie nicht reicht, um Ihnen zu nützen, sage ich Ihnen das.",
cta: GET_IN_TOUCH,
orientation: "3.500 EUR für eine feste Woche",
orientationNote:
"Der Umfang ist eine Woche meiner Zeit, 35 Stunden. Eine Woche behebt nicht alles: Wenn ich im ersten Gespräch sehe, dass sie nicht reicht, um Ihnen zu nützen, sage ich Ihnen das.",
},
{
id: "automation",
@@ -204,13 +241,9 @@ export const de: Content = {
text: "Sie bekommen eine Notiz dazu, was es tut und was zu prüfen ist, wenn sich eines der Programme ändert.",
},
],
pricingModel: "Erst eine Schätzung, dann ein Budget",
pricingText:
"Sobald ich den Ablauf gesehen habe, schätze ich die Stunden. Diese Schätzung wird zum Budget. Ich rechne die Stunden ab, die ich tatsächlich gebraucht habe, und sage früh Bescheid, wenn es nicht reicht.",
prices: [{ label: "Pro Stunde", amount: "100 EUR", note: "netto" }],
smallPrint:
orientation: "100 EUR pro Stunde, nach Schätzung",
orientationNote:
"Wie lange es dauert, hängt vom Ablauf ab. Deshalb gibt es keinen Preis, bevor ich ihn gesehen habe. Gebühren für die Programme selbst zahlen Sie.",
cta: GET_IN_TOUCH,
},
{
id: "website",
@@ -245,24 +278,9 @@ export const de: Content = {
text: "Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.",
},
],
pricingModel: "Erst eine Schätzung, dann ein Budget",
pricingText:
"Nach dem ersten Gespräch bekommen Sie eine Schätzung. Sie wird Ihr Budget: Ich rechne die Stunden ab, die tatsächlich angefallen sind. Die Schätzung hält in der Regel, und wenn nicht, sage ich es Ihnen, bevor das Budget aufgebraucht ist.",
prices: [
{
label: "Eine typische Unternehmenswebsite",
amount: "ab 3.800 EUR",
note: "einmalig, netto",
},
{
label: "Alles Weitere, pro Stunde",
amount: "100 EUR",
note: "netto",
},
],
smallPrint:
"Hosting und kleine Änderungen danach kosten einen monatlichen Betrag, den wir vor dem Start vereinbaren. E-Mail-Postfächer verwalte ich nicht; sie bleiben bei Ihrem Anbieter.",
cta: GET_IN_TOUCH,
orientation: "Ab 3.800 EUR, einmalig",
orientationNote:
"Das gilt für eine typische Unternehmenswebsite. Die Betreuung danach kostet 89 EUR im Monat.",
},
],
scenes: {
@@ -292,7 +310,10 @@ export const de: Content = {
},
automation: {
label: "Prozessautomatisierung",
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
title: {
lead: "Ihre Programme erledigen die Routine.",
rest: "Niemand tippt mehr ab.",
},
intro:
"In den meisten Unternehmen tragen Menschen Informationen von einem Programm ins andere: hier lesen, dort eintippen, eine E-Mail dazu schreiben. Automatisierung verbindet diese Programme, sodass die Arbeit von selbst vom einen zum nächsten wandert. Drei typische Beispiele:",
examplesLabel: "Beispielabläufe",
@@ -441,6 +462,140 @@ export const de: Content = {
},
],
},
presentations: {
label: "Web-Präsentationen",
title: { lead: "Eine Präsentation als Link.", rest: "Nicht als Anhang." },
intro:
"Die meisten Präsentationen sind noch Foliendateien: ein festes Format, verschickt als Anhang. Ich baue Präsentationen als Websites. Sie öffnen sich in jedem Browser, passen sich dem Bildschirm an, auf dem sie gezeigt werden, und können Dinge enthalten, die eine Folie nicht kann.",
compare: {
description:
"Eine Folie behält auf jedem Bildschirm ihr festes Format und wird auf dem Handy zu klein zum Lesen. Eine Web-Präsentation ordnet ihren Inhalt für jeden Bildschirm neu an.",
slides: "Foliendatei",
web: "Web-Präsentation",
pickScreen: "Gezeigt auf",
screens: ["Beamer", "Laptop", "Handy"],
slideNotes: [
"Für dieses Format gemacht",
"Passt noch",
"Verkleinert: zu klein zum Lesen",
],
webNotes: [
"Nutzt die volle Breite",
"Zwei Spalten",
"Eine Spalte, gut lesbar",
],
},
reasons: [
{
title: "Passt auf jeden Bildschirm",
text: "Eine Folie hat ein festes Format. Eine Web-Präsentation ordnet sich für Beamer, Laptop und Handy jeweils neu an.",
},
{
title: "Ein Link, ein Stand",
text: "Niemand muss fragen, welche Datei die aktuelle ist. Sie ändern es an einer Stelle, und jeder, der den Link öffnet, sieht diesen Stand.",
},
{
title: "Zeigt das Echte",
text: "Aktuelle Zahlen, eine funktionierende Demo oder ein klickbarer Prototyp können direkt in der Präsentation stehen, statt eines Screenshots davon.",
},
{
title: "Auch danach noch nützlich",
text: "Nach dem Termin ist sie eine Seite, die man allein lesen, weitergeben und wiederfinden kann.",
},
],
goodFor: {
title: "Typische Einsätze",
items: [
"Unternehmens- und Produktpräsentationen",
"Pitches und Angebote, die als Link verschickt werden",
"Berichte mit Zahlen, die sich ändern",
"Bildschirme auf Messen und Veranstaltungen",
],
},
honest: {
title: "Wann Folien reichen",
text: "Für einen einzelnen internen Termin ist eine Foliendatei schneller und günstiger. Eine Web-Präsentation lohnt sich, wenn sie oft gezeigt wird, an viele Menschen geht oder für Ihr Unternehmen steht.",
},
cta: "Nach einer Präsentation fragen",
},
story: {
hello: "Für ein Vorhaben im Web brauchen Sie jemanden, der es baut.",
services: "Als Erstes klären wir, was Sie wirklich brauchen.",
automation:
"Manchmal ist es gar keine neue Software, sondern weniger Handarbeit.",
presentations:
"Und manchmal ist es ein Termin, bei dem Sie überzeugen müssen.",
work: "Am Ende steht etwas, das läuft.",
faq: "Vorher bleiben meist ein paar Fragen.",
contact: "Und dann beginnt es mit einer E-Mail.",
},
freelance: {
metaTitle: "Freelance-Webentwickler für Ihr Team | Marc Mintel",
metaDescription:
"Für welche Projekte ich passe, womit ich arbeite (React, Next.js, TypeScript) und wie ich Änderungen absichere, bevor sie live gehen.",
label: "Freelance",
title: {
lead: "Ein Entwickler für Ihr Team.",
rest: "Die technischen Details.",
},
intro:
"Diese Seite ist für alle, die selbst entwickeln oder ein Team leiten: für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, bevor sie live gehen.",
pipelineLabel: "Absicherung",
faq: {
title: { lead: "Gut zu wissen,", rest: "bevor Sie buchen." },
items: [
{
question: "Wie lange sind wir gebunden?",
answer:
"Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
},
{
question:
"Können Sie mit unserem bestehenden Team und Code arbeiten?",
answer:
"Ja. Ich habe jahrelang in Agenturen und Produktteams gearbeitet, der Einstieg in ein bestehendes Projekt ist mir vertraut.",
},
{
question: "Wem gehört der Code?",
answer:
"Ihnen. Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.",
},
{
question: "Was machen Sie nicht?",
answer:
"Mobile Apps für die App-Stores, Windows-Software und den Betrieb Ihrer Server. Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.",
},
],
},
cases: { lead: "Wofür Teams mich buchen.", rest: "Zwei typische Fälle." },
booking: {
label: "Buchung",
title: { lead: "Gebucht wird monatsweise.", rest: "Nicht tageweise." },
text: "Ich arbeite an festen Wochentagen an Ihrem Projekt, vereinbart für jeweils einen Monat. Es gibt kein Paket zu kaufen: Sie zahlen die Stunden, die ich tatsächlich gearbeitet habe.",
rate: { label: "Pro Stunde", amount: "100 EUR", note: "netto" },
example: {
label: "Beispiel: 2 Tage pro Woche",
amount: "ca. 6.400 EUR",
note: "64 Stunden in vier Wochen, netto",
},
terms: [
"Feste Wochentage, vereinbart für jeweils einen Monat",
"Eine Rechnung pro Monat, mit den erfassten Stunden",
"Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen",
"Einzelne Tage buche ich nicht",
],
cta: "Verfügbarkeit anfragen",
remote: {
title: "Remote, ohne feste Bürozeiten",
text: "Ich arbeite ortsunabhängig und teile mir die Zeit selbst ein. Für Meetings mit Ihrem Team bin ich nach Absprache da. Eine durchgehende Erreichbarkeit über den ganzen Tag gibt es nicht; dafür arbeite ich in der übrigen Zeit ungestört an Ihrem Projekt.",
},
},
teaser: {
title: "Sie entwickeln selbst oder leiten ein Team?",
text: "Für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, steht auf einer eigenen Seite.",
cta: "Zu den technischen Details",
},
},
technology: {
label: "Technik",
title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." },
@@ -449,7 +604,7 @@ export const de: Content = {
title: "Passt gut",
items: [
"Web-Apps: Dashboards, Portale, interne Werkzeuge",
"Oberflächen, bei denen Design und Bedienbarkeit zählen",
"Interfaces, bei denen Design und Bedienbarkeit zählen",
"Unternehmenswebsites, auch in mehreren Sprachen",
"Web3-Frontends: Wallets, Live-Daten, Dashboards",
"Automatisierung: Systeme und ihre APIs verbinden",
@@ -475,7 +630,7 @@ export const de: Content = {
techLabel: "Womit ich arbeite",
tech: [
{
area: "Oberfläche",
area: "Interface",
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
},
{
@@ -557,7 +712,10 @@ export const de: Content = {
},
working: {
label: "Zusammenarbeit",
title: { lead: "So arbeite ich.", rest: "Nichts Ungewöhnliches." },
title: {
lead: "So läuft die Zusammenarbeit.",
rest: "Direkt und nachvollziehbar.",
},
principles: [
{
title: "Ein Ansprechpartner",
@@ -631,9 +789,9 @@ export const de: Content = {
"Nein. Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen. Code müssen Sie nicht lesen.",
},
{
question: "Wie lange bin ich gebunden?",
question: "Was kostet das?",
answer:
"Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
"Das hängt davon ab, was Sie brauchen. Nach einem kurzen Gespräch bekommen Sie ein schriftliches Angebot. Ich fange erst an, wenn Sie zugestimmt haben.",
},
{
question: "Wem gehört, was Sie bauen?",
@@ -641,19 +799,14 @@ export const de: Content = {
"Ihnen. Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.",
},
{
question: "Können Sie mit unserem bestehenden Team und Code arbeiten?",
question: "Wer kümmert sich danach um die Website?",
answer:
"Ja. Ich habe jahrelang in Agenturen und Produktteams gearbeitet, der Einstieg in ein bestehendes Projekt ist mir vertraut.",
"Ich, wenn Sie das möchten. Für 89 EUR im Monat läuft die Website auf Servern in Deutschland, und kleine Änderungen sind enthalten: Sie schreiben mir eine E-Mail. Ein Redaktionssystem brauchen Sie nicht. E-Mail-Postfächer bleiben bei Ihrem Anbieter.",
},
{
question: "Wie erreiche ich Sie?",
answer:
"Per E-Mail oder Videocall. Ich arbeite remote und bin zu europäischen Geschäftszeiten erreichbar.",
},
{
question: "Was machen Sie nicht?",
answer:
"Mobile Apps für die App-Stores, Windows-Software und den Betrieb Ihrer Server. Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.",
"Per E-Mail oder Videocall. Ich arbeite remote; einen Termin vereinbaren wir so, dass er für Sie passt.",
},
],
},
+211 -55
View File
@@ -25,6 +25,7 @@ export const en: Content = {
},
nav: {
contact: GET_IN_TOUCH,
freelance: "Freelance",
contactLong: GET_IN_TOUCH,
logoLabel: "Marc Mintel, back to top",
imprint: "Imprint",
@@ -42,8 +43,12 @@ export const en: Content = {
leadStrong: "I build web apps, interfaces and websites.",
primaryCta: GET_IN_TOUCH,
secondaryCta: "What I do",
trust: ["15 years in web", "Remote, EU core hours"],
builtAndRunning: "Built and running:",
trust: [
"One contact, who builds it himself",
"An offer before anything costs money",
"Changes by email, no system to learn",
"Servers in Germany",
],
},
hello: {
label: "Hello",
@@ -72,11 +77,60 @@ export const en: Content = {
},
services: {
label: "Services",
title: { lead: "What do you need?", rest: "Four typical cases." },
title: { lead: "What do you need?", rest: "Usually one of the two." },
tablistLabel: "What do you need?",
youGet: "What you get",
howItRuns: "How it runs",
whatItCosts: "What it costs",
care: {
label: "After the launch",
title: "I keep looking after your website, if you want that.",
text: "You do not have to operate a system or maintain anything yourself. When something needs to change, you write me an email.",
price: {
label: "Care",
amount: "89 EUR",
note: "per month, net, cancel monthly",
},
includedLabel: "What is included",
included: [
"The website runs on servers in Germany",
"Address, security and technology are taken care of",
"Small changes: a text, a picture, new opening hours",
"No editing system, no login",
],
termsLabel: "Good to know",
terms: [
"The first three months are included in the project price.",
"Anything bigger, such as a new page, gets a price first.",
"I reply within two working days. There is no on-call service.",
"If you want to leave, you take everything with you. The website is yours.",
],
},
offer: {
title: "You get an offer before anything costs money.",
text: "Write me briefly what you need. We talk for about 20 minutes, then you get a written offer. I only start once you have agreed.",
cta: "Ask for an offer",
},
estimate: {
label: "What it costs",
title: "How you get to a price",
steps: [
{
title: "A call, about 20 minutes",
text: "You tell me what you need.",
},
{
title: "A written estimate",
text: "You see the estimated hours and what they include.",
},
{
title: "You approve a budget",
text: "I bill the hours actually used and tell you early if it will not be enough.",
},
],
orientationLabel: "For orientation",
net: "All prices net.",
cta: "Ask for an estimate",
},
paths: [
{
id: "developer",
@@ -110,16 +164,9 @@ export const en: Content = {
text: "The agreement can be ended with two weeks notice to the end of a month.",
},
],
pricingModel: "By effort, for as long as your project runs",
pricingText:
"I work on your project on agreed days of the week and bill the hours I actually worked. There is no package to buy and no minimum term.",
prices: [
{ label: "Per hour", amount: "100 EUR", note: "net" },
{ label: "Per day", amount: "800 EUR", note: "8 hours, net" },
],
smallPrint:
"One invoice per month, with the tracked hours. Either side can end it with two weeks notice to the end of a month.",
cta: GET_IN_TOUCH,
orientation: "100 EUR per hour, booked by the month",
orientationNote:
"You book fixed days of the week for one month at a time. I bill the hours I actually worked.",
},
{
id: "rescue",
@@ -153,15 +200,9 @@ export const en: Content = {
text: "With me, with your own team, or not at all.",
},
],
pricingModel: "A fixed week at a fixed price",
pricingText:
"The scope is one week of my time. You know the cost before I start.",
prices: [
{ label: "One week, 35 hours", amount: "3,500 EUR", note: "net" },
],
smallPrint:
"A week does not fix everything. If I see in the first call that it will not be enough to be useful, I tell you.",
cta: GET_IN_TOUCH,
orientation: "3,500 EUR for one fixed week",
orientationNote:
"The scope is one week of my time, 35 hours. A week does not fix everything: if I see in the first call that it will not be enough to be useful, I tell you.",
},
{
id: "automation",
@@ -197,13 +238,9 @@ export const en: Content = {
text: "You get a note on what it does and what to check when one of the tools changes.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"Once I have seen the process I estimate the hours. That estimate becomes the budget. I bill the hours I actually needed and tell you early if it will not be enough.",
prices: [{ label: "Per hour", amount: "100 EUR", note: "net" }],
smallPrint:
orientation: "100 EUR per hour, after an estimate",
orientationNote:
"How long it takes depends on the process, so there is no price before I have seen it. Fees for the tools themselves are paid by you.",
cta: GET_IN_TOUCH,
},
{
id: "website",
@@ -237,20 +274,9 @@ export const en: Content = {
text: "A new photo or a changed price: you send me an email.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"After the first call you get an estimate. It becomes your budget: I bill the hours actually used. The estimate usually holds, and if it will not, I tell you before the budget is used up.",
prices: [
{
label: "A typical company website",
amount: "from 3,800 EUR",
note: "one-off, net",
},
{ label: "Anything extra, per hour", amount: "100 EUR", note: "net" },
],
smallPrint:
"Hosting and small changes afterwards cost a monthly fee that we agree on before we start. I do not manage email inboxes; they stay with your provider.",
cta: GET_IN_TOUCH,
orientation: "From 3,800 EUR, one-off",
orientationNote:
"This is for a typical company website. Care afterwards costs 89 EUR per month.",
},
],
scenes: {
@@ -280,7 +306,10 @@ export const en: Content = {
},
automation: {
label: "Process automation",
title: { lead: "Let the systems", rest: "do the copying." },
title: {
lead: "Your tools do the routine work.",
rest: "Nobody retypes anything.",
},
intro:
"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:",
examplesLabel: "Example workflows",
@@ -425,6 +454,135 @@ export const en: Content = {
},
],
},
presentations: {
label: "Web presentations",
title: { lead: "A presentation as a link.", rest: "Not as an attachment." },
intro:
"Most presentations are still slide files: one fixed format, sent around as an attachment. I build presentations as websites. They open in any browser, adapt to the screen they are shown on, and can contain things a slide cannot.",
compare: {
description:
"A slide keeps its fixed format on every screen and becomes too small to read on a phone. A web presentation rearranges its content for each screen.",
slides: "Slide file",
web: "Web presentation",
pickScreen: "Shown on",
screens: ["Projector", "Laptop", "Phone"],
slideNotes: [
"Made for this format",
"Still fits",
"Shrunk: too small to read",
],
webNotes: ["Uses the full width", "Two columns", "One column, readable"],
},
reasons: [
{
title: "Fits every screen",
text: "A slide has one fixed format. A web presentation rearranges itself for the projector, the laptop and the phone.",
},
{
title: "One link, one version",
text: "Nobody has to ask which file is the current one. You change it in one place, and everyone who opens the link sees that state.",
},
{
title: "Shows the real thing",
text: "Current figures, a working demo or a clickable prototype can sit inside the presentation, instead of a screenshot of them.",
},
{
title: "Still useful afterwards",
text: "After the meeting it is a page people can read on their own, pass on and find again.",
},
],
goodFor: {
title: "Typical uses",
items: [
"Company and product presentations",
"Pitches and offers sent as a link",
"Reports with figures that change",
"Screens at trade fairs and events",
],
},
honest: {
title: "When slides are enough",
text: "For a single internal meeting, a slide file is quicker and cheaper. A web presentation is worth it when it is shown often, sent to many people, or stands for your company.",
},
cta: "Ask about a presentation",
},
story: {
hello: "For a project on the web you need someone who builds it.",
services: "First we find out what you really need.",
automation:
"Sometimes it is not new software at all, but less work by hand.",
presentations:
"And sometimes it is a meeting in which you have to convince.",
work: "In the end there is something that runs.",
faq: "Before that, a few questions usually remain.",
contact: "And then it starts with an email.",
},
freelance: {
metaTitle: "Freelance web developer for your team | Marc Mintel",
metaDescription:
"Which projects I am right for, what I work with (React, Next.js, TypeScript) and how I secure changes before they go live.",
label: "Freelance",
title: {
lead: "A developer for your team.",
rest: "The technical details.",
},
intro:
"This page is for people who develop themselves or lead a team: which projects I am right for, what I work with, and how I secure changes before they go live.",
pipelineLabel: "Safeguards",
faq: {
title: { lead: "Good to know", rest: "before you book." },
items: [
{
question: "How long are we committed?",
answer:
"Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
},
{
question: "Can you work with our existing team and code?",
answer:
"Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
},
{
question: "Who owns the code?",
answer:
"You do. Code and design belong to you, and you can hand them to another developer.",
},
{
question: "What do you not do?",
answer:
"Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
},
],
},
cases: { lead: "What teams book me for.", rest: "Two typical cases." },
booking: {
label: "Booking",
title: { lead: "Booked by the month.", rest: "Not by the day." },
text: "I work on your project on fixed days of the week, agreed for one month at a time. There is no package to buy: you pay the hours I actually worked.",
rate: { label: "Per hour", amount: "100 EUR", note: "net" },
example: {
label: "Example: 2 days a week",
amount: "about 6,400 EUR",
note: "64 hours in four weeks, net",
},
terms: [
"Fixed days of the week, agreed for one month at a time",
"One invoice per month, with the tracked hours",
"Either side can end it with two weeks notice to the end of a month",
"I do not book single days",
],
cta: "Ask about availability",
remote: {
title: "Remote, without fixed office hours",
text: "I work from wherever I am and plan my own time. For meetings with your team I am there by arrangement. I am not reachable all day long; in return I work on your project undisturbed the rest of the time.",
},
},
teaser: {
title: "You develop yourself or lead a team?",
text: "Which projects I am right for, what I work with and how I secure changes is on a page of its own.",
cta: "To the technical details",
},
},
technology: {
label: "Technology",
title: { lead: "Which projects", rest: "I am right for." },
@@ -537,7 +695,10 @@ export const en: Content = {
},
working: {
label: "Working with me",
title: { lead: "How I work.", rest: "Nothing unusual." },
title: {
lead: "How working together goes.",
rest: "Direct and easy to follow.",
},
principles: [
{
title: "One contact",
@@ -608,9 +769,9 @@ export const en: Content = {
"No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
},
{
question: "How long am I committed?",
question: "What does it cost?",
answer:
"Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
"That depends on what you need. After a short call you get a written offer. I only start once you have agreed.",
},
{
question: "Who owns what you build?",
@@ -618,19 +779,14 @@ export const en: Content = {
"You do. Code and design belong to you, and you can hand them to another developer.",
},
{
question: "Can you work with our existing team and code?",
question: "Who looks after the website afterwards?",
answer:
"Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
"I do, if you want that. For 89 EUR per month the website runs on servers in Germany, and small changes are included: you write me an email. You do not need an editing system. Email inboxes stay with your provider.",
},
{
question: "How do I reach you?",
answer:
"By email or video call. I work remotely and am available during European business hours.",
},
{
question: "What do you not do?",
answer:
"Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
"By email or video call. I work remotely; we agree on a time that suits you.",
},
],
},
+94 -7
View File
@@ -55,11 +55,10 @@ export type ServicePath = {
readonly youGet: readonly string[];
readonly steps: readonly TitledText[];
/** How this kind of work is priced, in one line, and why. */
readonly pricingModel: string;
readonly pricingText: string;
readonly prices: readonly PathPrice[];
readonly smallPrint: string;
readonly cta: string;
/** The order of magnitude in one line, such as "from 3,800 EUR, one-off". */
readonly orientation: string;
/** What that figure depends on or leaves out. */
readonly orientationNote: string;
};
/** The short labels inside the drawings that explain the steps of a path. */
@@ -121,6 +120,15 @@ export type WorkCopy = {
readonly tech: readonly string[];
};
export type ChapterId =
| "hello"
| "services"
| "automation"
| "presentations"
| "work"
| "faq"
| "contact";
export type FaqItem = {
readonly question: string;
readonly answer: string;
@@ -146,6 +154,7 @@ export type Content = {
};
readonly nav: {
readonly contact: string;
readonly freelance: string;
readonly contactLong: string;
readonly logoLabel: string;
readonly imprint: string;
@@ -162,7 +171,6 @@ export type Content = {
readonly primaryCta: string;
readonly secondaryCta: string;
readonly trust: readonly string[];
readonly builtAndRunning: string;
};
readonly hello: {
readonly label: string;
@@ -179,7 +187,29 @@ export type Content = {
readonly tablistLabel: string;
readonly youGet: string;
readonly howItRuns: string;
readonly whatItCosts: string;
/** What happens to a website after it is live: the care offer, with its price. */
readonly care: {
readonly label: string;
readonly title: string;
readonly text: string;
readonly price: PathPrice;
readonly includedLabel: string;
readonly included: readonly string[];
readonly termsLabel: string;
/** The limits, said plainly. */
readonly terms: readonly string[];
};
/** The homepage's way to a price: ask for an offer. No figures there. */
readonly offer: TitledText & { readonly cta: string };
/** How a visitor gets to a price: the homepage shows this instead of a price list. */
readonly estimate: {
readonly label: string;
readonly title: string;
readonly steps: readonly TitledText[];
readonly orientationLabel: string;
readonly net: string;
readonly cta: string;
};
readonly paths: readonly ServicePath[];
readonly scenes: SceneLabels;
};
@@ -198,6 +228,63 @@ export type Content = {
readonly workflows: Readonly<Record<string, WorkflowCopy>>;
readonly notes: readonly TitledList[];
};
readonly presentations: {
readonly label: string;
readonly title: SplitTitle;
readonly intro: string;
readonly compare: {
/** What the drawing shows, for screen readers. */
readonly description: string;
readonly slides: string;
readonly web: string;
readonly pickScreen: string;
/** Projector, laptop, phone: in this order. */
readonly screens: readonly string[];
/** What happens to the slide on each screen, in the same order. */
readonly slideNotes: readonly string[];
readonly webNotes: readonly string[];
};
/** Every screen, one link, real content, useful afterwards: in this order. */
readonly reasons: readonly TitledText[];
readonly goodFor: TitledList;
readonly honest: TitledText;
readonly cta: string;
};
/**
* The story of the homepage: one sentence per chapter. Read one after the
* other they tell the way of a project from the idea to the first email.
*/
readonly story: Readonly<Record<ChapterId, string>>;
/** The page for people who develop themselves or lead a team. */
readonly freelance: {
readonly metaTitle: string;
readonly metaDescription: string;
readonly label: string;
readonly title: SplitTitle;
readonly intro: string;
readonly pipelineLabel: string;
/** The questions of teams that book by the hour. The homepage has its own. */
readonly faq: {
readonly title: SplitTitle;
readonly items: readonly FaqItem[];
};
/** Headline over the cases teams book the owner for. */
readonly cases: SplitTitle;
/** How the owner is booked as a developer: by the month, not by the day. */
readonly booking: {
readonly label: string;
readonly title: SplitTitle;
readonly text: string;
readonly rate: PathPrice;
readonly example: PathPrice;
readonly terms: readonly string[];
readonly cta: string;
/** Where and when the owner works: remote, no fixed hours, meetings by arrangement. */
readonly remote: TitledText;
};
/** The pointer to this page on the homepage. */
readonly teaser: TitledText & { readonly cta: string };
};
readonly technology: {
readonly label: string;
readonly title: SplitTitle;
+62
View File
@@ -296,3 +296,65 @@ describe("sitemapEntries", () => {
expect(sitemapEntries("https://example.com", [])).toEqual([]);
});
});
describe("the freelance page, which has the same name in both languages", () => {
it("lives at /freelance in German and /en/freelance in English", () => {
expect(pathFor("freelance", "de")).toBe("/freelance");
expect(pathFor("freelance", "en")).toBe("/en/freelance");
expect(languageAlternates("freelance")).toEqual({
de: "/freelance",
en: "/en/freelance",
"x-default": "/freelance",
});
});
it("switches between the languages", () => {
expect(switchPath("/freelance", "en")).toBe("/en/freelance");
expect(switchPath("/en/freelance", "de")).toBe("/freelance");
});
it("shows /freelance in German to a German browser", () => {
expect(routeRequest({ pathname: "/freelance", ...german })).toEqual({
kind: "rewrite",
to: "/de/freelance",
negotiated: true,
});
});
it("sends an English browser from /freelance to /en/freelance, not permanently", () => {
expect(routeRequest({ pathname: "/freelance", ...english })).toEqual({
kind: "redirect",
to: "/en/freelance",
permanent: false,
negotiated: true,
});
});
it("shows /en/freelance to everyone instead of redirecting it onto itself", () => {
expect(routeRequest({ pathname: "/en/freelance", ...german })).toEqual({
kind: "rewrite",
to: "/en/freelance",
negotiated: false,
});
});
it("moves the internal address /de/freelance to /freelance", () => {
expect(routeRequest({ pathname: "/de/freelance", ...german })).toEqual({
kind: "redirect",
to: "/freelance",
permanent: true,
negotiated: false,
});
});
it("is listed in the sitemap in both languages", () => {
expect(
sitemapEntries("https://example.com", ["freelance"]).map(
(entry) => entry.url,
),
).toEqual([
"https://example.com/freelance",
"https://example.com/en/freelance",
]);
});
});
+11 -4
View File
@@ -6,7 +6,7 @@ import { LOCALES, preferredLocale, type Locale } from "./locale";
* Internally every page is rendered under its language (/de/..., /en/...);
* `routeRequest` maps one onto the other.
*/
export type PageId = "home" | "imprint" | "privacy";
export type PageId = "home" | "freelance" | "imprint" | "privacy";
type LanguageAlternates = Readonly<Record<Locale | "x-default", string>>;
@@ -31,18 +31,24 @@ export type SitemapEntry = {
type PagePaths = Readonly<Record<PageId, string>>;
const PAGES: readonly PageId[] = ["home", "imprint", "privacy"];
const PAGES: readonly PageId[] = ["home", "freelance", "imprint", "privacy"];
/** The path of a page below its language, as the app directory names it. */
const INTERNAL: PagePaths = {
home: "",
freelance: "/freelance",
imprint: "/imprint",
privacy: "/privacy",
};
/** The path of a page below its language, as visitors see it. */
const PUBLIC: Readonly<Record<Locale, PagePaths>> = {
de: { home: "", imprint: "/impressum", privacy: "/datenschutz" },
de: {
home: "",
freelance: "/freelance",
imprint: "/impressum",
privacy: "/datenschutz",
},
en: INTERNAL,
};
@@ -111,8 +117,9 @@ export function routeRequest(request: {
const english = restAfter(request.pathname, PREFIX.en);
if (english !== null) {
// A German page name under /en is moved, unless the page has the same name in both languages.
const misnamed = pageAt(english, PUBLIC.de);
return misnamed === null || misnamed === "home"
return misnamed === null || PUBLIC.de[misnamed] === INTERNAL[misnamed]
? { kind: "rewrite", to: `/en${english}`, negotiated: false }
: redirect(pathFor(misnamed, "en"), true);
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { deepestSoFar, lengthAtDepth } from "./thread";
describe("deepestSoFar", () => {
it("keeps the lowest point the line has reached at each sample", () => {
// The line goes down, loops back up, and goes down again.
expect(deepestSoFar([10, 5, 20, 15, 30])).toEqual([10, 10, 20, 20, 30]);
});
it("is empty for an empty line", () => {
expect(deepestSoFar([])).toEqual([]);
});
it("works above the top of the page", () => {
expect(deepestSoFar([-20, -30, -10])).toEqual([-20, -20, -10]);
});
});
describe("lengthAtDepth", () => {
const depths = [10, 10, 20, 20, 30];
it("draws nothing above the start of the line", () => {
expect(lengthAtDepth(depths, 8, 9)).toBe(0);
});
it("draws everything the line needed to reach that depth, loops included", () => {
expect(lengthAtDepth(depths, 8, 10)).toBe(16);
expect(lengthAtDepth(depths, 8, 19)).toBe(16);
expect(lengthAtDepth(depths, 8, 20)).toBe(32);
});
it("draws the whole line below its end", () => {
expect(lengthAtDepth(depths, 8, 1000)).toBe(40);
});
it("draws nothing of an empty line", () => {
expect(lengthAtDepth([], 8, 1000)).toBe(0);
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* The story thread is a line that runs down the homepage and draws itself as
* the visitor scrolls. It is not straight: it loops back up to form figures.
* These two functions decide how much of it is drawn at a scroll depth.
*/
/** For each sample along the line: the lowest point the line has reached so far. */
export function deepestSoFar(ys: readonly number[]): number[] {
return ys.map((_, index) => Math.max(...ys.slice(0, index + 1)));
}
/**
* How much of the line is drawn when the visitor has read down to `y`.
* `depths` comes from `deepestSoFar`, sampled every `step` along the line.
* A figure that loops back up is drawn in full once its lowest point is passed.
*/
export function lengthAtDepth(
depths: readonly number[],
step: number,
y: number,
): number {
return depths.filter((depth) => depth <= y).length * step;
}

Some files were not shown because too many files have changed in this diff Show More