Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76873808ce | ||
|
|
710d3d06e6 | ||
|
|
2cb15608dd | ||
|
|
af66f61c26 | ||
|
|
7a71296e6d | ||
|
|
f3797bfe47 | ||
|
|
bee03398d0 | ||
|
|
1b6a96225e | ||
|
|
f0c25b802b | ||
|
|
affd4b7ee7 | ||
|
|
3e0c9a1605 | ||
|
|
90120b60db | ||
|
|
e6f00cbbb6 | ||
|
|
f50bb553e8 | ||
|
|
c7f8672cd0 | ||
|
|
6fcf893903 | ||
|
|
5cbcb2eac4 | ||
|
|
2dab728129 | ||
|
|
4bfbcbcacd | ||
|
|
83616433e4 | ||
|
|
c70833a725 | ||
|
|
11e991e54a |
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: video-film
|
||||
description: Make or change a social media film or image post in apps/video (Remotion) in the approved mintel.me style - one continuous shot, minimal, with a camera pull-back as the reveal, in German and English. Use when asked for a new video, a new topic, changes to an existing film, new formats, or to re-render the video library.
|
||||
---
|
||||
|
||||
# Making a film for mintel.me
|
||||
|
||||
The videos in `apps/video` are short looping films for LinkedIn and ads. The owner approved this
|
||||
style after rejecting several others. Read `apps/video/CLAUDE.md` first; this skill is the procedure.
|
||||
|
||||
## What the owner rejected, so do not go back to it
|
||||
|
||||
- Slides with a panel, a progress bar, labels and a frame around everything.
|
||||
- Much text, small text, text sizes that change from line to line.
|
||||
- Hectic motion: hops of a third of a second, six statements in 15 seconds.
|
||||
- Busy drawings: icons, labels, counters, stacks, several effects at once.
|
||||
- A tiny logo in a black box.
|
||||
- German that reads translated.
|
||||
|
||||
## The style
|
||||
|
||||
One continuous shot on a dark stage. Only squares, dots, lines and bars. One statement at a time,
|
||||
two short lines, large and centred under the drawing. Calm: nothing moves faster than it has to, every
|
||||
statement stays for about three seconds.
|
||||
|
||||
Every film tells the same kind of story in six beats, on the shared timeline `T` in
|
||||
`src/films/shot.ts` (690 frames at 30 fps, 23 seconds):
|
||||
|
||||
| Beat | Caption id | What happens |
|
||||
| :--- | :--------- | :----------- |
|
||||
| Hook | `hook` | Close on something small and abstract that looks like something else. "Looks like ping-pong." |
|
||||
| Reveal | `reveal` | The camera pulls back and shows what it really is. "It's your office." This is the aha moment. |
|
||||
| Problem | `problem` | The whole picture keeps going, and it is clearly too much. |
|
||||
| Turn | `turn` | Everything slows to a halt, then the form changes: arcs become a line, a pile leaves by itself. |
|
||||
| Calm | `calm` | The result at rest, with one quiet movement. |
|
||||
| Call to action | `cta` | A question that picks up the hook ("Done with ping-pong?") and the website's button. |
|
||||
|
||||
Then the picture folds back into its opening, so the last frame equals the first and the film loops.
|
||||
Frame 0 is the preview image: it must already look good and carry the hook line.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Bring the worktree up to date with `main`** and read the service in
|
||||
`apps/web/src/content/de.ts` and `en.ts`. Claims, prices and terms come from there and nowhere else.
|
||||
2. **Find the picture before writing code.** Answer in one line each:
|
||||
- What does the viewer see close up, and what harmless thing does it look like?
|
||||
- What does the camera reveal when it pulls back?
|
||||
- Which single change of form resolves it?
|
||||
If the reveal does not make you think "oh", look for another picture. Existing ones:
|
||||
ping-pong / office, skyscraper / pile of invoices, star / unanswered enquiries,
|
||||
blinking cursor / empty website, Jenga / fragile app, loading bar / backlog.
|
||||
3. **Write the six statements** in `src/content/videos.ts`, German and English, plus the post text.
|
||||
Two lines each, as short as possible: one font size is used for the whole film, set by the longest
|
||||
line. The hook is "Sieht aus wie ..." / "Looks like ...". A little sarcasm is welcome. German uses
|
||||
"Sie" and has to sound like a native wrote it; write it first, do not translate it from English.
|
||||
The post text opens with the hook. Copy rules of the root `CLAUDE.md` apply.
|
||||
4. **Write the film** as `src/films/<Name>Film.tsx`. Copy the structure of `AppFilm.tsx` (simplest)
|
||||
or `OrderFilm.tsx` (several moving things):
|
||||
- `cameraAt(frame, zoomIn, zoomOut, focus)` gives `transform`, `zoom`, `hair` and `others`.
|
||||
Everything that is not part of the opening picture gets `opacity={others}`.
|
||||
- Draw in stage coordinates, (0, 0) in the centre, 1000 x 700. Use `hair` for stroke widths and
|
||||
divide glow sizes by `zoom`, so lines look the same at every zoom.
|
||||
- All motion is a pure function of the frame: `progress`, `inOut`, `out`, `mix` from
|
||||
`src/illustrations/motion.ts`. No timers, no randomness, no CSS animations.
|
||||
- For the loop: after `T.fold` the opening picture has to be rebuilt exactly. Anything periodic
|
||||
needs a period that divides 690 (23, 30, 46). Compare frame 0 and frame 689.
|
||||
- Logic worth testing goes into `src/lib` with a spec, written first (see `hop.ts`).
|
||||
5. **Register it**: `src/compositions/<VideoId>.tsx` exporting a `VideoEntry` with `Film`, and the
|
||||
list in `src/Root.tsx`. A new id also goes into `VIDEO_IDS` in `videos.ts`.
|
||||
6. **Check**: `pnpm typecheck` and `pnpm test` in `apps/video`.
|
||||
7. **Render and look**: `node scripts/export.ts --only <VideoId>`, then pull frames from the MP4
|
||||
(start, middle of the pull-back, problem, turn, calm, call to action, last frame) in `feed` and
|
||||
`wide`, and read them. Check: nothing overlaps the text, the drawing fades at the stage edges
|
||||
during the camera move, frame 689 equals frame 0, German lines fit.
|
||||
8. **Report** what was rendered, what you looked at, and what you did not. You cannot play video:
|
||||
say so, and leave the judgement of pace and feel to the owner.
|
||||
|
||||
## Traps that have cost time here
|
||||
|
||||
- **A render can silently not happen.** Redirect logs with `>|`, not `>` (the shell refuses to
|
||||
overwrite an existing file and the command never starts). After every export, compare the file times
|
||||
with the clock before sending anything. Run one export at a time.
|
||||
- **Glows**: `drop-shadow` on SVG shapes is fine. Blur filters under a CSS mask, blend modes and dashed
|
||||
strokes came out with hard edges; build soft light from gradients that fade to transparent.
|
||||
- **The stage is not clipped by itself.** While the camera is close, the drawing is larger than the
|
||||
stage; `FilmStage` fades it at the edges. Do not rely on things being "off screen".
|
||||
- **Do not name a helper script `copy.py`, `json.py` or similar**: it shadows the Python module.
|
||||
- **Text from an old worktree is stale.** The website changed language and wording during this work.
|
||||
|
||||
## Image posts
|
||||
|
||||
Every film gets a poster, and the plan pairs it with a card (see `apps/web/plans/content-strategy.md`).
|
||||
|
||||
- Poster: add an entry to `src/content/images.ts` with `poster: { video, frame }`. Pick the frame where the
|
||||
whole picture is in view and the problem is obvious (around frame 250 to 300). Two lines, as short as
|
||||
possible: "Kein Pingpong." / "Ihr Büro."
|
||||
- Card: a question a client asks before the first call (`label`) and the answer in two or three lines.
|
||||
The answer must be on the website already.
|
||||
- Add the new posts to `src/content/plan.ts`, run `pnpm test`, then `node scripts/export.ts --images`
|
||||
and look at the PNGs in `feed` and `square`.
|
||||
- A poster is not a frozen frame inside a one-frame image: `Freeze` did not work there. The poster is a
|
||||
composition as long as the film, rendered at that frame.
|
||||
|
||||
## Formats and output
|
||||
|
||||
Compositions are `<VideoId>-<de|en>-<feed|wide|square|story>`. `node scripts/export.ts` renders
|
||||
`feed` (4:5, LinkedIn) and `wide` (16:9, YouTube and ads) in both languages into
|
||||
`out/library/<lang>/week-<n>/<slot>-<day>-<id>/`, with `post.txt` and an overview page
|
||||
`out/library/index.html`. `out/` is not committed. Keep it tidy: check frames, contact sheets and logs
|
||||
go into `out/tmp`, nothing loose into `out/`, and `out/tmp` is emptied before you report.
|
||||
@@ -477,8 +477,8 @@ jobs:
|
||||
run: |
|
||||
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
# The legal pages must be reachable in both languages.
|
||||
for PAGE in /impressum /datenschutz /en/imprint /en/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)"
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
out
|
||||
.remotion
|
||||
@@ -0,0 +1,102 @@
|
||||
# apps/video
|
||||
|
||||
Social media videos (LinkedIn first, 16:9 for YouTube and Google Ads) in the mintel.me look, made with Remotion.
|
||||
Standalone package: it is **not** in `pnpm-workspace.yaml`, so it never ends up in the web build, the Dockerfile
|
||||
or `pnpm -r`. `apps/web` must never import from here, and this package imports nothing from `apps/web`.
|
||||
|
||||
## Commands (run from this folder)
|
||||
|
||||
- Install: `pnpm install --ignore-workspace`
|
||||
- Studio (the playground, http://localhost:3000): `pnpm studio`
|
||||
- One frame as PNG: `pnpm still WebsiteDone-de-feed out/check.png --frame=40`
|
||||
- One video: `pnpm render WebsiteDone-de-feed out/website.mp4`
|
||||
- The whole library: `pnpm library` (= `node scripts/export.ts`; `--only <VideoId>`, `--formats feed,wide`,
|
||||
`--page` to rewrite only post texts and the overview). Writes `out/library/index.html` (watch, copy the post
|
||||
text, download) and `out/library/<language>/week-<n>/<slot>-<day>-<id>/` with the files and `post.txt`, in plan order.
|
||||
- `pnpm typecheck`, `pnpm test`
|
||||
|
||||
Compositions are named `<VideoId>-<language>-<format>`: languages `en`, `de`; formats `feed` (4:5, LinkedIn),
|
||||
`wide` (16:9), `square`, `story` (9:16). `out/` is gitignored and holds only two things: `out/library`
|
||||
(what gets posted) and `out/tmp` (your check frames, sheets and logs; delete it when you are done).
|
||||
|
||||
## Where things are
|
||||
|
||||
- `src/content/videos.ts`: every word. Headlines per slide, the post text per video, both languages, and the
|
||||
German for labels inside drawings (`LABELS_DE`). Drawing code writes labels in English; they are translated
|
||||
when rendered. German addresses the viewer as "Sie".
|
||||
- `src/compositions/<Video>.tsx`: the slides of one video (ids, durations, illustrations). No text.
|
||||
- `src/slides`: `SlideDeck` (panel, headline, curtain, loop, portrait and 16:9 layout), `ServiceVideo`
|
||||
(frame + language + deck), `ctaSlide` (closing slide with the button).
|
||||
- `src/illustrations`: `parts.tsx` (tile, icon, cursor, data chip, window, page, badges, day box), `scenes.tsx`
|
||||
(scenes several videos share), `motion.ts` (easing helpers).
|
||||
- `src/brand`: tokens, fonts, `BrandFrame` (logo, labels, digit texture), `Panel` (the dark panel with the
|
||||
website's light effects), `BrandButton`.
|
||||
- `src/lib`: pure, tested logic (`bits.ts`, `aura.ts`).
|
||||
|
||||
## How to make a new video
|
||||
|
||||
1. Add the texts to `src/content/videos.ts` (new `VideoId`, both languages, post text).
|
||||
2. Add `src/compositions/<Name>.tsx` with its slides and register it in `src/Root.tsx`.
|
||||
3. Look before reporting: render the MP4, pull one frame per slide and read them, in `feed` and `wide`, in both
|
||||
languages (German lines are longer). Do not hand over a video you have not seen.
|
||||
4. `pnpm library --only <Name>` and tell the owner where the files are.
|
||||
|
||||
## Films (the current format, owner approved 2026-10-04)
|
||||
|
||||
Every video is a film in `src/films`: one continuous shot on a dark stage, not a deck of slides.
|
||||
- Shared: `FilmStage` (backdrop, one centred statement at a time, signature, address) and `shot.ts`
|
||||
(one timeline `T`, the camera move `cameraAt`, the six caption slots hook/reveal/problem/turn/calm/cta).
|
||||
- The pattern: close on something abstract that looks like something else ("Looks like ping-pong."), the
|
||||
camera pulls back and shows what it really is ("It's your office."), it stops, resolves, rests, and
|
||||
returns to the opening picture so the film loops. Frame 0 is the preview image.
|
||||
- Minimal and calm (owner feedback: earlier versions were hectic). Only squares, dots, lines and bars; no
|
||||
icons or labels in the drawing. One statement of two short lines, held for about three seconds.
|
||||
A little sarcasm in the hook is welcome. German has to read like a native wrote it.
|
||||
- One font size per film: the largest at which the longest line fits, so keep lines short.
|
||||
- The logo is the white signature (`public/logo-mark.svg`): bottom left, and very large and faint behind.
|
||||
- The slide deck code (`src/slides/SlideDeck`, `src/illustrations`, `BrandFrame`, `Panel`) is no longer used
|
||||
by any video and can be removed once the owner confirms.
|
||||
|
||||
## Image posts and the posting plan
|
||||
|
||||
- Image posts are made the same way as films: `src/content/images.ts` holds every word and, for a poster, which
|
||||
film and frame it shows; `src/images/ImagePost.tsx` draws them. A poster is a film rendered at one frame with
|
||||
two lines ("Not ping-pong. Your office."); a card is a client's question with the answer in large type.
|
||||
Cards say only what the website says. Compositions: `<ImageId>-<language>-<feed|square>`.
|
||||
- `src/content/plan.ts` is the posting plan (which film or image in which week); the reasoning is in
|
||||
`apps/web/plans/content-strategy.md`. The spec checks that every film and image is planned exactly once
|
||||
and that every video post opens with the hook of its film.
|
||||
- `pnpm library` renders films and images and writes the overview page in plan order;
|
||||
`--images`, `--videos`, `--only <id>`, `--page`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Before any work: bring the worktree up to date with `main` (owner feedback, twice). Take wording, styles and
|
||||
effects from the website as it is **now**: texts in `apps/web/src/content/de.ts` and `en.ts`, styles in
|
||||
`apps/web/app/globals.css`, drawings in `apps/web/src/components/illustrations`. Do not state prices, terms
|
||||
or durations that are not in the texts.
|
||||
- Picture first, few words (owner feedback): 6 to 8 slides of about 2 s, each one large animated illustration
|
||||
plus a headline of two to four words. No paragraphs, no captions.
|
||||
- It has to look stunning (owner feedback, repeated: "not enough yet"). The videos carry every effect of the
|
||||
website: the dark `Panel` with `BitAura` (light along the edge, digits drifting off), `BitGlow` (light over
|
||||
the dot grid) and `bit-aurora` (indigo light drifting behind the drawing); slides change with the `BitCurtain`
|
||||
(ragged front of digits, `src/lib/curtain.ts`); the button has the `bit-cta` light running round its edge.
|
||||
When the website gets a new effect, bring it here as a pure function of the frame in `src/lib`.
|
||||
- Drawings follow `PathScenes.tsx`: fine light lines, tinted glass surfaces (`DRAW.tint`, `DRAW.glass`) instead
|
||||
of solid blocks, amber packets with a halo (`DRAW.mark`), emerald for done, soft glows on what is active.
|
||||
- UI in a video must be the website's real UI, rebuilt faithfully (owner feedback): read the component in
|
||||
`apps/web/src/components` first. The button is `BrandButton` (twin of `Button.tsx`); do not draw your own.
|
||||
- Almost no frame (owner feedback: the drawing matters far more than what surrounds it). No border, no name,
|
||||
no topic label, no progress bar. Only one small line at the bottom: the logo, small and dimmed, and
|
||||
`www.mintel.me` (with `www.` so that it reads as an address). The stage is scaled as large as the frame allows.
|
||||
- Light is drawn with plain gradients that fade to transparent. No `filter: blur`, `mix-blend-mode` or dashed
|
||||
strokes for glows: they came out with hard edges in the rendered video (owner feedback).
|
||||
- Every video loops: the first slide is complete on frame 0 (it is the preview image) and fades back in at the
|
||||
end. Anything that moves outside the slides (`Panel`) must repeat exactly over the length of the video.
|
||||
- Frame-driven only: all motion comes from `useCurrentFrame()` / `interpolate()`. No `setTimeout`,
|
||||
`setInterval`, CSS animations, `Math.random()` or framer-motion: they do not render correctly frame by frame.
|
||||
Pure logic goes in `src/lib` with a `*.spec.ts`.
|
||||
- 30 fps, about 15 seconds. The video must work without sound and make sense in the first two seconds.
|
||||
- Copy rules as on the website (see root `CLAUDE.md`): no AI as the owner's tool, no internal method vocabulary,
|
||||
modest and factual, no promises the owner did not confirm. Never write the full email address.
|
||||
- The hero design of the website (binary-digit headline) is approved: do not recreate it here with changes.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@mintel/video",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Social media videos in the mintel.me look. Standalone: not part of the pnpm workspace, never imported by apps/web.",
|
||||
"scripts": {
|
||||
"studio": "remotion studio src/index.ts",
|
||||
"render": "remotion render src/index.ts",
|
||||
"still": "remotion still src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"library": "node scripts/export.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.532",
|
||||
"@remotion/google-fonts": "4.0.532",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"remotion": "4.0.532"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "~4.1.11"
|
||||
}
|
||||
}
|
||||
Generated
+3632
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 745 744" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Favicon" x="0.855" y="0.277" width="743.633" height="743.633" style="fill:none;"/><path d="M739.657,49.961c-0,-24.755 -20.098,-44.852 -44.853,-44.852l-644.264,-0c-24.755,-0 -44.853,20.097 -44.853,44.852l-0,644.265c-0,24.755 20.098,44.853 44.853,44.853l644.264,-0c24.755,-0 44.853,-20.098 44.853,-44.853l-0,-644.265Z"/><path d="M135.778,286.282c0.157,0.236 0.52,0.761 0.852,1.092c1.2,1.192 2.956,2.358 5.296,3.587l0.005,0.003l2,1.327l1.693,1.752l1.239,2.072l0.713,2.215l0.211,2.192l-0.208,2.068l-1.385,3.646l-2.46,2.971l-1.634,1.187l-1.911,0.914l-2.161,0.551l-2.323,0.093l-2.343,-0.412l-2.211,-0.892c-11.589,-6.087 -16.814,-12.713 -18.39,-18.621c-1.835,-6.879 0.575,-13.751 6.509,-19.965c12.824,-13.43 44.389,-24.257 57.136,-27.454l0.001,-0c30.563,-7.658 62.165,-12.34 93.648,-13.693c25.54,-1.098 54.423,-1.803 78.831,7.271c15.51,5.768 26.031,14.726 32.759,25.559c56.484,-46.707 118.893,-93.116 187.414,-119.225c10.958,-4.178 27.246,-8.604 40.339,-7.415c7.908,0.718 14.744,3.442 19.461,8.573c4.662,5.072 7.541,12.672 6.521,23.945c-3.634,40.091 -31.543,80.109 -52.575,112.911c-25.848,40.321 -53.663,79.557 -82.723,117.821c29.191,-23.305 58.382,-46.61 87.573,-69.913l1.731,-1.145l1.85,-0.796l3.75,-0.585l3.526,0.562l3.17,1.552l2.672,2.595l1.028,1.728l0.705,1.995l0.285,2.178l-0.185,2.22l-0.631,2.115l-0.992,1.91c-10.505,16.533 -21.014,33.063 -31.523,49.592l-0.001,0.003c-1.852,2.909 -11.995,19.195 -18.14,30.842c-0.096,0.182 -0.192,0.366 -0.288,0.553c13.673,-3.721 27.615,-13.517 38.776,-19.936c10.441,-6.004 20.778,-12.208 30.865,-18.787l0.003,-0.002l2.134,-1.1l2.356,-0.626l2.421,-0.072l2.289,0.463l2.022,0.893l1.703,1.201l2.495,3.003l1.373,3.598l0.232,1.999l-0.139,2.106l-0.573,2.146l-1.048,2.067l-1.498,1.843l-1.854,1.496l-0.007,0.005c-15.715,10.242 -31.914,19.975 -48.604,28.526c-6.986,3.579 -18.808,10.744 -29.918,13.789c-9.41,2.579 -18.37,2.143 -24.958,-2.988c-5.648,-4.398 -7.104,-11.077 -5.042,-18.895c3.104,-11.773 15.551,-27.001 19.276,-32.858l2.604,-4.095c-37.274,29.759 -74.551,59.518 -111.826,89.274l-1.82,1.214l-2.004,0.868l-2.111,0.471l-2.12,0.061l-2.039,-0.329l-1.892,-0.676l-3.201,-2.224l-2.259,-3.202l-0.689,-1.912l-0.33,-2.068l0.077,-2.148l0.5,-2.129l0.902,-2.005l1.242,-1.804c59.233,-71.996 118.215,-147.452 163.946,-228.856l0.002,-0.003c3.532,-6.277 19.498,-32.912 25.637,-54.458c1.456,-5.11 2.365,-9.885 2.213,-13.918c-0.128,-3.403 -1.052,-6.169 -4.397,-6.847c-6.883,-1.395 -14.409,-0.182 -21.911,2.075c-12.591,3.787 -25.072,10.598 -34.871,15.502l-0.002,0.001c-37.202,18.606 -72.519,40.846 -106.083,65.396c-19.253,14.077 -38.067,28.773 -56.448,44.009c5.948,31.922 -8.725,71.663 -25.261,97.617c-26.624,41.789 -61.114,78.396 -97.084,112.241c-35.155,33.081 -71.676,66.504 -111.219,94.355l-0.002,0.001c-4.905,3.453 -13.056,9.944 -21.245,13.763c-7.008,3.268 -14.124,4.488 -20.021,2.432c-7.666,-2.674 -10.711,-8.481 -11.105,-15.23c-0.288,-4.928 1.117,-10.472 2.745,-14.802l0.001,-0.003c17.424,-46.26 54.722,-88.018 86.091,-125.21c52.005,-61.657 108.894,-119.681 170.402,-171.929c-5.142,-9.861 -13.608,-17.675 -25.833,-20.957c-27.596,-7.404 -57.826,-6.098 -86.019,-3.428c-30.452,2.883 -61.745,7.625 -90.667,17.984c-6.667,2.388 -17.118,6.215 -22.892,11.134c-0.89,0.758 -1.884,2.115 -2.149,2.485Zm-6.046,295.699c3.853,-1.713 7.757,-4.116 11.623,-6.805c12.067,-8.393 23.538,-19.805 31.614,-26.433c45.641,-37.472 90.707,-76.66 129.793,-121.083c26.644,-30.283 57.988,-66.814 65.641,-107.833c1.02,-5.466 1.414,-11.09 1.137,-16.634c-41.419,35.852 -80.575,74.39 -117.54,114.67c-41.853,45.61 -85.416,93.945 -115.619,148.393c-0.671,1.213 -4.049,9.375 -6.649,15.725Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,7 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setCodec("h264");
|
||||
Config.setPixelFormat("yuv420p");
|
||||
Config.setCrf(16);
|
||||
Config.setOverwriteOutput(true);
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Renders the content library: every film and every image post in every language, with the text
|
||||
* to post next to it, and an overview page in the order of the posting plan.
|
||||
*
|
||||
* node scripts/export.ts everything
|
||||
* node scripts/export.ts --images only the image posts
|
||||
* node scripts/export.ts --videos only the films
|
||||
* node scripts/export.ts --only WebsiteDone one film or one image
|
||||
* node scripts/export.ts --formats feed one video format (feed, wide, square, story)
|
||||
* node scripts/export.ts --page only rewrite post texts and the overview page
|
||||
*
|
||||
* Output, in the order of the posting plan:
|
||||
* out/library/index.html
|
||||
* out/library/<language>/week-<n>/<slot>-<day>-<id>/ the files to upload and post.txt
|
||||
* Anything else in out/ is scratch: put checks and logs into out/tmp, which can be deleted at any time.
|
||||
* Run one export at a time, and redirect its log with `>|`, not `>`.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { IMAGES, IMAGE_IDS, type ImageId } from "../src/content/images.ts";
|
||||
import { PLAN, POSTING_DAYS, type PlannedPost } from "../src/content/plan.ts";
|
||||
import { COPY, LANGS, VIDEO_IDS, type Lang, type VideoId } from "../src/content/videos.ts";
|
||||
|
||||
const LIBRARY = join("out", "library");
|
||||
const DEFAULT_VIDEO_FORMATS = ["feed", "wide"];
|
||||
const IMAGE_FORMATS = ["feed", "square"];
|
||||
const FORMAT_NOTE: Readonly<Record<string, string>> = {
|
||||
feed: "4:5, LinkedIn feed",
|
||||
wide: "16:9, YouTube and Google Ads",
|
||||
square: "1:1",
|
||||
story: "9:16, vertical",
|
||||
};
|
||||
|
||||
const option = (name: string): string | undefined => {
|
||||
const index = process.argv.indexOf(`--${name}`);
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
};
|
||||
const flag = (name: string): boolean => process.argv.includes(`--${name}`);
|
||||
|
||||
const only = option("only");
|
||||
const videoFormats = option("formats")?.split(",") ?? DEFAULT_VIDEO_FORMATS;
|
||||
const pageOnly = flag("page");
|
||||
const wanted = (id: string): boolean => only === undefined || id === only;
|
||||
const videoIds = flag("images") ? [] : VIDEO_IDS.filter(wanted);
|
||||
const imageIds = flag("videos") ? [] : IMAGE_IDS.filter(wanted);
|
||||
if (videoIds.length + imageIds.length === 0) {
|
||||
throw new Error(`Nothing to export for "${only}". Known: ${[...VIDEO_IDS, ...IMAGE_IDS].join(", ")}`);
|
||||
}
|
||||
|
||||
/** Where a post lives inside the library: by language, then week, then day, as it will be posted. */
|
||||
const place = (id: string, lang: Lang): string => {
|
||||
for (const [weekIndex, week] of PLAN.entries()) {
|
||||
const slot = week.posts.findIndex((post) => post.id === id);
|
||||
if (slot !== -1) {
|
||||
return `${lang}/week-${weekIndex + 1}/${slot + 1}-${(POSTING_DAYS[slot] ?? "").toLowerCase()}-${id}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`${id} is not in the posting plan (src/content/plan.ts)`);
|
||||
};
|
||||
const folder = (id: string, lang: Lang): string => join(LIBRARY, place(id, lang));
|
||||
const fileName = (id: string, lang: Lang, format: string, extension: string): string =>
|
||||
`${id}-${lang}-${format}.${extension}`;
|
||||
|
||||
/** Runs a Remotion command and makes sure it really wrote the file. */
|
||||
const render = (command: "render" | "still", composition: string, target: string, frame = 0): void => {
|
||||
console.log(`Rendering ${target}`);
|
||||
const startedAt = Date.now();
|
||||
const extra = command === "still" ? [`--frame=${frame}`] : [];
|
||||
execFileSync("pnpm", ["exec", "remotion", command, "src/index.ts", composition, target, "--log=error", ...extra], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
// A render can end without an error and without a new file (seen with two renders started at once).
|
||||
if (!existsSync(target) || statSync(target).mtimeMs < startedAt) {
|
||||
throw new Error(`${target} was not written. Run one export at a time.`);
|
||||
}
|
||||
};
|
||||
|
||||
const postText = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
|
||||
kind === "video" ? COPY[id as VideoId][lang].post : IMAGES[id as ImageId].copy[lang].post;
|
||||
const title = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
|
||||
kind === "video" ? COPY[id as VideoId][lang].title : IMAGES[id as ImageId].copy[lang].title;
|
||||
|
||||
for (const lang of LANGS) {
|
||||
for (const id of videoIds) {
|
||||
mkdirSync(folder(id, lang), { recursive: true });
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("video", id, lang)}\n`);
|
||||
if (pageOnly) continue;
|
||||
for (const format of videoFormats) {
|
||||
render("render", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "mp4")));
|
||||
}
|
||||
}
|
||||
for (const id of imageIds) {
|
||||
mkdirSync(folder(id, lang), { recursive: true });
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("image", id, lang)}\n`);
|
||||
if (pageOnly) continue;
|
||||
for (const format of IMAGE_FORMATS) {
|
||||
render("still", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "png")), IMAGES[id].poster?.frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
const media = (post: PlannedPost, lang: Lang): string => {
|
||||
const extension = post.kind === "video" ? "mp4" : "png";
|
||||
const shown = Object.keys(FORMAT_NOTE)
|
||||
.filter((format) => existsSync(join(folder(post.id, lang), fileName(post.id, lang, format, extension))))
|
||||
.map((format) => {
|
||||
const src = `${place(post.id, lang)}/${fileName(post.id, lang, format, extension)}`;
|
||||
const element =
|
||||
post.kind === "video"
|
||||
? `<video src="${src}" controls loop muted playsinline preload="metadata"></video>`
|
||||
: `<img src="${src}" alt="" loading="lazy">`;
|
||||
return `<figure class="${format}">${element}<figcaption>${FORMAT_NOTE[format]} · <a href="${src}" download>Download</a></figcaption></figure>`;
|
||||
})
|
||||
.join("\n");
|
||||
return shown || "<p>Not rendered yet.</p>";
|
||||
};
|
||||
|
||||
const card = (post: PlannedPost, day: string, lang: Lang): string => `<article lang="${lang}" data-lang="${lang}">
|
||||
<header><span class="tag">${day} · ${post.kind}</span><h3>${escapeHtml(title(post.kind, post.id, lang))}</h3></header>
|
||||
<div class="media">${media(post, lang)}</div>
|
||||
<div class="post">
|
||||
<div class="post-head"><span class="tag">Post text</span><button type="button" class="copy">Copy</button></div>
|
||||
<textarea readonly rows="12">${escapeHtml(postText(post.kind, post.id, lang))}</textarea>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
const weeks = PLAN.map(
|
||||
(week, index) => `<section>
|
||||
<h2><span class="tag">Week ${index + 1}</span>${escapeHtml(week.topic)}</h2>
|
||||
${week.posts.flatMap((post, slot) => LANGS.map((lang) => card(post, POSTING_DAYS[slot] ?? "", lang))).join("\n")}
|
||||
</section>`,
|
||||
).join("\n");
|
||||
|
||||
const page = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Content library</title>
|
||||
<style>
|
||||
:root { --ink: #0f172a; --muted: #64748b; --line: #e2e8f0; --wash: #f8fafc; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #fff; color: var(--ink); font-family: Inter, system-ui, sans-serif; }
|
||||
main { max-width: 1400px; margin: 0 auto; padding: 48px 20px 96px; }
|
||||
h1 { font-size: clamp(40px, 7vw, 88px); font-weight: 900; letter-spacing: -0.05em; line-height: 0.95; margin: 0 0 12px; }
|
||||
.intro { color: var(--muted); font-size: 18px; max-width: 62ch; margin: 0 0 24px; }
|
||||
.switch { display: flex; gap: 8px; margin-bottom: 40px; }
|
||||
section { border-top: 2px solid var(--ink); padding-top: 20px; margin-top: 56px; }
|
||||
h2 { font-size: 34px; letter-spacing: -0.03em; margin: 0 0 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
h3 { font-size: 22px; letter-spacing: -0.02em; margin: 0; }
|
||||
article { border-top: 1px solid var(--line); padding: 28px 0; display: grid; gap: 20px; }
|
||||
@media (min-width: 900px) { article { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); } article header { grid-column: 1 / -1; } }
|
||||
body[data-show="de"] article[data-lang="en"], body[data-show="en"] article[data-lang="de"] { display: none; }
|
||||
header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.tag, figcaption { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
|
||||
.media { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
|
||||
figure { margin: 0; }
|
||||
video, img { display: block; width: 100%; border-radius: 16px; background: var(--ink); }
|
||||
figure.feed, figure.square { width: min(100%, 300px); }
|
||||
figure.story { width: min(100%, 230px); }
|
||||
figure.wide { width: min(100%, 460px); }
|
||||
figcaption { margin-top: 10px; }
|
||||
a { color: var(--ink); }
|
||||
.post-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
textarea { width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 16px; font: 15px/1.5 Inter, system-ui, sans-serif; color: var(--ink); background: var(--wash); resize: vertical; }
|
||||
button { border: 1px solid var(--ink); border-radius: 999px; background: var(--ink); color: #fff; padding: 10px 20px; font: 700 11px Inter, system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
|
||||
button[aria-pressed="false"] { background: #fff; color: var(--ink); }
|
||||
</style>
|
||||
</head>
|
||||
<body data-show="de">
|
||||
<main>
|
||||
<h1>Content library</h1>
|
||||
<p class="intro">Six weeks, three posts each: the film on Tuesday, a card on Thursday, the poster on Saturday. The plan is in <code>src/content/plan.ts</code>, the reasoning in <code>apps/web/plans/content-strategy.md</code>.</p>
|
||||
<div class="switch">
|
||||
<button type="button" data-set="de" aria-pressed="true">Deutsch</button>
|
||||
<button type="button" data-set="en" aria-pressed="false">English</button>
|
||||
</div>
|
||||
${weeks}
|
||||
</main>
|
||||
<script>
|
||||
for (const button of document.querySelectorAll("[data-set]")) {
|
||||
button.addEventListener("click", () => {
|
||||
document.body.dataset.show = button.dataset.set;
|
||||
for (const other of document.querySelectorAll("[data-set]")) other.setAttribute("aria-pressed", String(other === button));
|
||||
});
|
||||
}
|
||||
for (const button of document.querySelectorAll(".copy")) {
|
||||
button.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(button.closest(".post").querySelector("textarea").value);
|
||||
button.textContent = "Copied";
|
||||
setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
mkdirSync(LIBRARY, { recursive: true });
|
||||
writeFileSync(join(LIBRARY, "index.html"), page);
|
||||
console.log(`Overview: ${join(LIBRARY, "index.html")}`);
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FC } from "react";
|
||||
import { Composition } from "remotion";
|
||||
import { FORMATS, FPS, type FormatName } from "./brand/tokens";
|
||||
import { IMAGES, IMAGE_IDS } from "./content/images";
|
||||
import { LANGS } from "./content/videos";
|
||||
import { ImagePost } from "./images/ImagePost";
|
||||
import { VIDEOS } from "./registry";
|
||||
import { SHOT_FRAMES } from "./films/shot";
|
||||
import { ServiceVideo, videoDuration } from "./slides/ServiceVideo";
|
||||
|
||||
const FORMAT_NAMES = Object.keys(FORMATS) as FormatName[];
|
||||
/** Images are posted in the feed: 4:5, and square for places that crop. */
|
||||
const IMAGE_FORMATS = ["feed", "square"] as const;
|
||||
|
||||
/** Videos are `<VideoId>-<language>-<format>`, images `<ImageId>-<language>-<feed|square>`. */
|
||||
export const Root: FC = () => (
|
||||
<>
|
||||
{VIDEOS.flatMap((video) =>
|
||||
LANGS.flatMap((lang) =>
|
||||
FORMAT_NAMES.map((format) => (
|
||||
<Composition
|
||||
key={`${video.id}-${lang}-${format}`}
|
||||
id={`${video.id}-${lang}-${format}`}
|
||||
component={() => <ServiceVideo video={video} lang={lang} />}
|
||||
durationInFrames={videoDuration(video)}
|
||||
fps={FPS}
|
||||
width={FORMATS[format].width}
|
||||
height={FORMATS[format].height}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
{IMAGE_IDS.flatMap((id) =>
|
||||
LANGS.flatMap((lang) =>
|
||||
IMAGE_FORMATS.map((format) => (
|
||||
// A poster is a film at one of its frames, so it needs the film's length; a card is a single frame.
|
||||
<Composition
|
||||
key={`${id}-${lang}-${format}`}
|
||||
id={`${id}-${lang}-${format}`}
|
||||
component={() => <ImagePost id={id} lang={lang} />}
|
||||
durationInFrames={IMAGES[id].poster ? SHOT_FRAMES : 1}
|
||||
fps={FPS}
|
||||
width={FORMATS[format].width}
|
||||
height={FORMATS[format].height}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Easing, interpolate, useCurrentFrame } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { COLOR } from "../brand/tokens";
|
||||
import { bitString } from "../lib/bits";
|
||||
|
||||
const COLUMN_DIGITS = 48;
|
||||
const SCRAMBLE_EVERY_FRAMES = 2;
|
||||
const EDGE_PCT = 2;
|
||||
|
||||
/**
|
||||
* Content revealed left to right behind a narrow column of binary digits (video twin of
|
||||
* BitWipe on the site). The content keeps its place, so nothing reflows.
|
||||
*/
|
||||
export const BitWipe: FC<{
|
||||
children: ReactNode;
|
||||
/** Frame (inside the current sequence) where the wipe starts. */
|
||||
from?: number;
|
||||
durationInFrames?: number;
|
||||
/** Size of the digit column in px. */
|
||||
digitSize?: number;
|
||||
tone?: "dark" | "light";
|
||||
}> = ({ children, from = 0, durationInFrames = 24, digitSize = 22, tone = "dark" }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const t = interpolate(frame, [from, from + durationInFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.bezier(0.65, 0, 0.35, 1),
|
||||
});
|
||||
const done = t >= 1;
|
||||
const rightInset = 100 - t * (100 + EDGE_PCT);
|
||||
const columnOpacity = interpolate(t, [0, 0.08, 0.86, 1], [0, 1, 1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block", whiteSpace: "nowrap" }}>
|
||||
<div
|
||||
style={{
|
||||
clipPath: done
|
||||
? "none"
|
||||
: `inset(-12% ${Math.max(0, rightInset)}% -12% -2%)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{t > 0 && !done && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `${t * 100}%`,
|
||||
width: digitSize * 1.1,
|
||||
overflow: "hidden",
|
||||
opacity: columnOpacity,
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 700,
|
||||
fontSize: digitSize,
|
||||
lineHeight: 1.05,
|
||||
writingMode: "vertical-rl",
|
||||
wordBreak: "break-all",
|
||||
color: tone === "light" ? COLOR.paper : COLOR.ink,
|
||||
}}
|
||||
>
|
||||
{bitString(COLUMN_DIGITS, Math.floor(frame / SCRAMBLE_EVERY_FRAMES))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { CSSProperties, FC } from "react";
|
||||
import { Easing, interpolate, useCurrentFrame } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { COLOR } from "../brand/tokens";
|
||||
import { decodeText } from "../lib/bits";
|
||||
|
||||
const SCRAMBLE_EVERY_FRAMES = 2;
|
||||
|
||||
/** Mono label resolving from digits (video twin of DecodeText on the site). Monospaced only. */
|
||||
export const DecodeLabel: FC<{
|
||||
text: string;
|
||||
/** Frame (inside the current sequence) where decoding starts. */
|
||||
from?: number;
|
||||
durationInFrames?: number;
|
||||
style?: CSSProperties;
|
||||
}> = ({ text, from = 0, durationInFrames = 18, style }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const progress = interpolate(frame, [from, from + durationInFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
});
|
||||
const tick = Math.floor(frame / SCRAMBLE_EVERY_FRAMES);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: COLOR.muted,
|
||||
whiteSpace: "pre",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{decodeText(text, progress, tick)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties, FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { useT } from "../content/CopyContext";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR, DRAW } from "./tokens";
|
||||
|
||||
/** One lap of the light round the edge, as on the website (6 s at 30 fps). */
|
||||
const LAP_FRAMES = 180;
|
||||
const SWEEP_FRAMES = 60;
|
||||
|
||||
/** Shows only a ring of the given thickness along the edge of its box. */
|
||||
const ring = (thickness: number): CSSProperties => ({
|
||||
padding: thickness,
|
||||
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
|
||||
});
|
||||
|
||||
/**
|
||||
* The primary button of the website (apps/web Button.tsx with `bit-cta`): dark pill, bold uppercase
|
||||
* label, the arrow in a circle, a light travelling round the edge with a soft glow behind it, and
|
||||
* the spot of light that follows the pointer there. Here that spot sweeps across on its own.
|
||||
*/
|
||||
export const BrandButton: FC<{
|
||||
label: string;
|
||||
/** Font size in px; padding, arrow and light scale with it. */
|
||||
size?: number;
|
||||
/** `light` is the white pill the website uses on dark surfaces. */
|
||||
tone?: "dark" | "light";
|
||||
}> = ({ label, size = 28, tone = "dark" }) => {
|
||||
const light = tone === "light";
|
||||
const frame = useCurrentFrame();
|
||||
const t = useT();
|
||||
const circle = size * 2.7;
|
||||
const angle = (frame / LAP_FRAMES) * 360;
|
||||
const travelling = `conic-gradient(from ${angle}deg, transparent 0 58%, rgba(${DRAW.light}, 0.95) 76%, #ffffff 86%, rgba(${DRAW.light}, 0.95) 93%, transparent 100%)`;
|
||||
const sweep = ((frame % SWEEP_FRAMES) / SWEEP_FRAMES) * 140 - 20;
|
||||
const edge: CSSProperties = { position: "absolute", borderRadius: 9999, background: travelling };
|
||||
|
||||
return (
|
||||
<span style={{ position: "relative", display: "inline-flex", borderRadius: 9999 }}>
|
||||
<span style={{ position: "absolute", inset: -14, filter: "blur(12px)", opacity: 0.8 }}>
|
||||
<span style={{ ...edge, inset: 8, ...ring(12) }} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: size * 1.1,
|
||||
overflow: "hidden",
|
||||
borderRadius: 9999,
|
||||
backgroundColor: light ? COLOR.paper : COLOR.ink,
|
||||
color: light ? COLOR.ink : COLOR.paper,
|
||||
padding: `${size * 0.8}px ${size * 0.8}px ${size * 0.8}px ${size * 2.3}px`,
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 700,
|
||||
fontSize: size,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
boxShadow: "0 20px 30px -10px rgba(15, 23, 42, 0.35)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `radial-gradient(${size * 5}px circle at ${sweep}% 50%, rgba(${DRAW.light}, 0.5), transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<span style={{ position: "relative" }}>{t(label)}</span>
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: circle,
|
||||
height: circle,
|
||||
borderRadius: 9999,
|
||||
backgroundColor: light ? COLOR.ink : "rgba(255, 255, 255, 0.1)",
|
||||
color: COLOR.paper,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={size * 1.2}
|
||||
height={size * 1.2}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ ...edge, inset: -2, ...ring(4) }} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { bitString } from "../lib/bits";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR } from "./tokens";
|
||||
|
||||
const LOGO_SIZE = 36;
|
||||
/** Width of one digit of the texture including its letter spacing, in px. */
|
||||
const RAIN_COLUMN_WIDTH = 28.6;
|
||||
const RAIN_ROW_HEIGHT = 40;
|
||||
const RAIN_EVERY_FRAMES = 6;
|
||||
const RAIN_OPACITY = 0.03;
|
||||
|
||||
/** Slowly changing field of digits behind everything, as quiet texture. Fills its box at any format. */
|
||||
const BitRain: FC<{ inset: number }> = ({ inset }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const columns = Math.ceil((width - inset * 2) / RAIN_COLUMN_WIDTH);
|
||||
const tick = Math.floor(frame / RAIN_EVERY_FRAMES);
|
||||
const rowCount = Math.ceil((height - inset * 2) / RAIN_ROW_HEIGHT);
|
||||
const rows = Array.from({ length: rowCount }, (_, row) =>
|
||||
bitString(columns, tick + row * 7),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: inset,
|
||||
left: inset,
|
||||
right: inset,
|
||||
bottom: inset,
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 26,
|
||||
lineHeight: `${RAIN_ROW_HEIGHT}px`,
|
||||
letterSpacing: "0.5em",
|
||||
color: COLOR.ink,
|
||||
opacity: RAIN_OPACITY,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre",
|
||||
padding: "0 24px",
|
||||
}}
|
||||
>
|
||||
{rows.join("\n")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Space kept free at the sides, the top and (for the footer) the bottom of the frame, in px. */
|
||||
export const FRAME = { side: 48, top: 48, bottom: 104 } as const;
|
||||
|
||||
/**
|
||||
* What surrounds the drawing: paper, a quiet digit texture, and one small line at the bottom with
|
||||
* the logo and the address. Deliberately little (owner feedback): the drawing is what matters.
|
||||
*/
|
||||
export const BrandFrame: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<AbsoluteFill style={{ backgroundColor: COLOR.paper }}>
|
||||
<BitRain inset={0} />
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: FRAME.side,
|
||||
right: FRAME.side,
|
||||
bottom: 36,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile("logo.svg")} style={{ width: LOGO_SIZE, height: LOGO_SIZE, opacity: 0.55 }} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: COLOR.muted,
|
||||
}}
|
||||
>
|
||||
www.mintel.me
|
||||
</span>
|
||||
</div>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
padding: `${FRAME.top}px ${FRAME.side}px ${FRAME.bottom}px`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { CSSProperties, FC, ReactNode } from "react";
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { cometAt, outlinePoint, sparkAt } from "../lib/aura";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR, DRAW } from "./tokens";
|
||||
|
||||
const RADIUS = 40;
|
||||
const SPARKS = 150;
|
||||
const DOT_GRID = 28;
|
||||
const LIGHT_REACH = 360;
|
||||
/** How far the light on the edge reaches into the panel and along the edge, in px. */
|
||||
const EDGE_HAZE = 300;
|
||||
const EDGE_LINE = 380;
|
||||
const EDGE_WIDTH = 3;
|
||||
/** Two lights travel the edge, opposite each other; the second is fainter. */
|
||||
const COMETS = [
|
||||
{ offset: 0, power: 1 },
|
||||
{ offset: 0.5, power: 0.6 },
|
||||
] as const;
|
||||
|
||||
/** Shows only a ring of the given thickness along the edge of its box. */
|
||||
const ring = (thickness: number): CSSProperties => ({
|
||||
padding: thickness,
|
||||
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
|
||||
});
|
||||
|
||||
const fill: CSSProperties = { position: "absolute", inset: 0 };
|
||||
|
||||
/**
|
||||
* The dark panel the drawings sit on, with the website's effects (BitAura, BitGlow, bit-aurora):
|
||||
* light travelling round its edge, binary digits drifting off it, a soft light wandering over
|
||||
* the dot grid, and slow indigo light drifting behind the drawing.
|
||||
*
|
||||
* All light is drawn with plain gradients that fade to transparent: no blur filters, blend modes
|
||||
* or dashed strokes, which render with hard edges frame by frame. Everything repeats exactly when
|
||||
* the video loops. Must be rendered outside any Sequence, so that it sees the frame of the whole video.
|
||||
*/
|
||||
export const Panel: FC<{
|
||||
width: number;
|
||||
height: number;
|
||||
/** Corner radius in px; 0 for a panel that fills the whole frame. */
|
||||
radius?: number;
|
||||
children: ReactNode;
|
||||
}> = ({ width, height, radius = RADIUS, children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
const box = { width, height };
|
||||
const turn = (frame / durationInFrames) * Math.PI * 2;
|
||||
const lightX = width * (0.5 + 0.34 * Math.sin(turn * 2));
|
||||
const lightY = height * (0.5 + 0.3 * Math.cos(turn * 3));
|
||||
const comets = COMETS.map((comet) => ({
|
||||
...outlinePoint(cometAt(frame, durationInFrames) + comet.offset, box),
|
||||
power: comet.power,
|
||||
}));
|
||||
const sparks = Array.from({ length: SPARKS }, (_, index) => sparkAt(index, frame, durationInFrames, box));
|
||||
const auroraX = 38 + Math.sin(turn) * 8;
|
||||
const auroraY = 42 + Math.cos(turn) * 7;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width, height }}>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
borderRadius: radius,
|
||||
overflow: "hidden",
|
||||
backgroundColor: DRAW.bg,
|
||||
backgroundImage: `radial-gradient(${DRAW.raised} 2px, transparent 2px)`,
|
||||
backgroundSize: `${DOT_GRID}px ${DOT_GRID}px`,
|
||||
boxShadow: "0 40px 80px -30px rgba(15, 23, 42, 0.55)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
background: `radial-gradient(55% 65% at ${auroraX}% ${auroraY}%, rgba(${DRAW.glow}, 0.34), transparent 72%), radial-gradient(45% 55% at ${102 - auroraX}% ${112 - auroraY}%, rgba(${DRAW.light}, 0.2), transparent 72%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
backgroundImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, rgba(148, 163, 184, 0.2), transparent 70%), radial-gradient(#e2e8f0 1.75px, transparent 1.75px)`,
|
||||
backgroundSize: `auto, ${DOT_GRID}px ${DOT_GRID}px`,
|
||||
maskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
|
||||
WebkitMaskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
{comets.map((comet) => (
|
||||
<div
|
||||
key={comet.power}
|
||||
style={{
|
||||
...fill,
|
||||
background: `radial-gradient(${EDGE_HAZE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.light}, ${0.5 * comet.power}), rgba(${DRAW.glow}, ${0.22 * comet.power}) 40%, transparent 72%)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div style={fill}>{children}</div>
|
||||
{comets.map((comet) => (
|
||||
<div
|
||||
key={comet.power}
|
||||
style={{
|
||||
...fill,
|
||||
borderRadius: radius,
|
||||
...ring(EDGE_WIDTH),
|
||||
background: `radial-gradient(${EDGE_LINE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.core}, ${comet.power}), rgba(${DRAW.light}, ${0.7 * comet.power}) 35%, transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg width={width} height={height} style={{ ...fill, overflow: "visible" }}>
|
||||
{sparks.map((spark, index) =>
|
||||
spark === null ? null : (
|
||||
<text
|
||||
key={index}
|
||||
x={spark.x}
|
||||
y={spark.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={spark.large ? 700 : 500}
|
||||
fontSize={spark.large ? 24 : 18}
|
||||
fill={COLOR.ink}
|
||||
opacity={spark.opacity * 0.8}
|
||||
>
|
||||
{spark.digit}
|
||||
</text>
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
|
||||
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";
|
||||
import { loadFont as loadSerif } from "@remotion/google-fonts/Newsreader";
|
||||
|
||||
const inter = loadInter("normal", { weights: ["400", "600", "700", "900"], subsets: ["latin"] });
|
||||
const mono = loadMono("normal", { weights: ["500", "700"], subsets: ["latin"] });
|
||||
const serif = loadSerif("normal", { weights: ["400"], subsets: ["latin"] });
|
||||
|
||||
/** Same three families as the website: Inter for headlines, JetBrains Mono for labels, Newsreader for reading text. */
|
||||
export const FONT = {
|
||||
sans: inter.fontFamily,
|
||||
mono: mono.fontFamily,
|
||||
serif: serif.fontFamily,
|
||||
} as const;
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Brand tokens, mirrored from apps/web (Tailwind slate scale, white paper, mono labels). */
|
||||
export const COLOR = {
|
||||
paper: "#ffffff",
|
||||
ink: "#0f172a", // slate-900
|
||||
inkSoft: "#1e293b", // slate-800
|
||||
body: "#475569", // slate-600
|
||||
muted: "#64748b", // slate-500
|
||||
faint: "#94a3b8", // slate-400
|
||||
line: "#e2e8f0", // slate-200
|
||||
wash: "#f8fafc", // slate-50
|
||||
active: "#fbbf24", // amber-400: work in progress
|
||||
done: "#10b981", // emerald-500: finished
|
||||
} as const;
|
||||
|
||||
/** Colours for drawings, which sit on a dark panel like the animated panels of the website. */
|
||||
export const DRAW = {
|
||||
bg: "#0f172a", // slate-900: the panel
|
||||
raised: "#1e293b", // slate-800: quiet surfaces on the panel
|
||||
soft: "#334155", // slate-700: placeholder lines and blocks
|
||||
label: "#94a3b8", // slate-400: mono labels
|
||||
fg: "#f8fafc", // slate-50: lines, tiles, text
|
||||
rim: "#64748b", // slate-500: fine outlines of quiet things
|
||||
tint: "rgba(248, 250, 252, 0.14)", // a lit surface: a light tint instead of a solid block
|
||||
glass: "rgba(15, 23, 42, 0.55)", // windows and cards: the dot grid shows through
|
||||
mark: "#fde68a", // amber-200: work that is travelling
|
||||
glow: "99, 102, 241", // indigo-500 as r, g, b: the light along the edge
|
||||
light: "129, 140, 248", // indigo-400 as r, g, b: the light on buttons and nodes
|
||||
core: "224, 231, 255", // indigo-100 as r, g, b: its bright centre
|
||||
} as const;
|
||||
|
||||
/** Output formats. LinkedIn feed default is 4:5; the others are variants of the same video. */
|
||||
export const FORMATS = {
|
||||
feed: { width: 1080, height: 1350 },
|
||||
square: { width: 1080, height: 1080 },
|
||||
story: { width: 1080, height: 1920 },
|
||||
/** 16:9 for YouTube and Google Ads video campaigns. */
|
||||
wide: { width: 1920, height: 1080 },
|
||||
} as const;
|
||||
|
||||
export type FormatName = keyof typeof FORMATS;
|
||||
|
||||
export const FPS = 30;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AppDrawing, AppFilm } from "../films/AppFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const appRescue: VideoEntry = {
|
||||
id: "AppRescue",
|
||||
Film: AppFilm,
|
||||
Drawing: AppDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DocumentsDrawing, DocumentsFilm } from "../films/DocumentsFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationDocuments: VideoEntry = {
|
||||
id: "AutomationDocuments",
|
||||
Film: DocumentsFilm,
|
||||
Drawing: DocumentsDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EnquiryDrawing, EnquiryFilm } from "../films/EnquiryFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationEnquiry: VideoEntry = {
|
||||
id: "AutomationEnquiry",
|
||||
Film: EnquiryFilm,
|
||||
Drawing: EnquiryDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ORDER_FILM_FRAMES, OrderDrawing, OrderFilm } from "../films/OrderFilm";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationOrder: VideoEntry = {
|
||||
id: "AutomationOrder",
|
||||
Film: OrderFilm,
|
||||
Drawing: OrderDrawing,
|
||||
durationInFrames: ORDER_FILM_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DeveloperDrawing, DeveloperFilm } from "../films/DeveloperFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const productDeveloper: VideoEntry = {
|
||||
id: "ProductDeveloper",
|
||||
Film: DeveloperFilm,
|
||||
Drawing: DeveloperDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebsiteDrawing, WebsiteFilm } from "../films/WebsiteFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const websiteDone: VideoEntry = {
|
||||
id: "WebsiteDone",
|
||||
Film: WebsiteFilm,
|
||||
Drawing: WebsiteDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { translate, type Lang, type VideoCopy } from "./videos";
|
||||
|
||||
type CopyValue = { readonly lang: Lang; readonly copy: VideoCopy };
|
||||
|
||||
const CopyContext = createContext<CopyValue | null>(null);
|
||||
|
||||
export const CopyProvider = CopyContext.Provider;
|
||||
|
||||
const useCopyValue = (): CopyValue => {
|
||||
const value = useContext(CopyContext);
|
||||
if (!value) throw new Error("This component must be rendered inside a ServiceVideo");
|
||||
return value;
|
||||
};
|
||||
|
||||
/** The texts of the video that is being rendered, in its language. */
|
||||
export const useCopy = (): VideoCopy => useCopyValue().copy;
|
||||
|
||||
/** Translates a label that is written in English in the drawing code. */
|
||||
export const useT = (): ((text: string) => string) => {
|
||||
const { lang } = useCopyValue();
|
||||
return (text) => translate(lang, text);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Image posts: every word, in English and German, and for posters which film and which frame they
|
||||
* show. Two kinds:
|
||||
*
|
||||
* - a poster is one picture of a film (the composition is rendered at that frame) with the film's idea in two lines
|
||||
* - a card answers one question a client asks before the first call, in large type
|
||||
*
|
||||
* Everything said here is on the website (`apps/web/src/content/de.ts`, questions and principles).
|
||||
* This file imports only types, so the export script can read it with plain Node.
|
||||
*/
|
||||
import type { Lang, VideoId } from "./videos.ts";
|
||||
|
||||
export type ImageCopy = {
|
||||
/** What the image is about, for the overview page. */
|
||||
readonly title: string;
|
||||
/** Cards only: the question, set small above the answer. */
|
||||
readonly label?: string;
|
||||
/** The large lines: at most three, as short as possible. */
|
||||
readonly lines: readonly string[];
|
||||
/** Text to post with the image. */
|
||||
readonly post: string;
|
||||
};
|
||||
|
||||
export type ImagePost = {
|
||||
/** Posters only: the film whose picture is shown, and at which frame. */
|
||||
readonly poster?: { readonly video: VideoId; readonly frame: number };
|
||||
readonly copy: Readonly<Record<Lang, ImageCopy>>;
|
||||
};
|
||||
|
||||
export const IMAGE_IDS = [
|
||||
"PosterOrder",
|
||||
"PosterDocuments",
|
||||
"PosterEnquiry",
|
||||
"PosterWebsite",
|
||||
"PosterApp",
|
||||
"PosterDeveloper",
|
||||
"CardNotTechnical",
|
||||
"CardOneContact",
|
||||
"CardOwnership",
|
||||
"CardHours",
|
||||
"CardNotice",
|
||||
"CardNotMyField",
|
||||
] as const;
|
||||
export type ImageId = (typeof IMAGE_IDS)[number];
|
||||
|
||||
const CALL_EN = "A first call takes about 20 minutes.\n\nwww.mintel.me";
|
||||
const CALL_DE = "Ein erstes Gespräch dauert etwa 20 Minuten.\n\nwww.mintel.me";
|
||||
|
||||
export const IMAGES: Readonly<Record<ImageId, ImagePost>> = {
|
||||
PosterOrder: {
|
||||
poster: { video: "AutomationOrder", frame: 285 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: ping-pong",
|
||||
lines: ["Not ping-pong.", "Your office."],
|
||||
post: `Every dot is someone carrying an order from one tool to the next. By hand.\n\nThe tools could pass it on themselves. I connect them.\n\n${CALL_EN}\n\n#automation #processautomation`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Pingpong",
|
||||
lines: ["Kein Pingpong.", "Ihr Büro."],
|
||||
post: `Jeder Punkt ist jemand, der eine Bestellung von einem Tool ins nächste trägt. Von Hand.\n\nDie Tools könnten das unter sich ausmachen. Ich verbinde sie.\n\n${CALL_DE}\n\n#automatisierung #prozessautomatisierung`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterDocuments: {
|
||||
poster: { video: "AutomationDocuments", frame: 300 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: skyscraper",
|
||||
lines: ["Not a skyscraper.", "Your inbox."],
|
||||
post: `Every floor is an invoice waiting to be typed in.\n\nIt can be read automatically. Clear cases are booked directly, unclear ones go to a person.\n\n${CALL_EN}\n\n#automation #accounting`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Hochhaus",
|
||||
lines: ["Kein Hochhaus.", "Ihr Posteingang."],
|
||||
post: `Jedes Stockwerk ist eine Rechnung, die noch jemand abtippen muss.\n\nDas lässt sich automatisch auslesen. Klare Fälle werden direkt gebucht, unklare gehen an einen Menschen.\n\n${CALL_DE}\n\n#automatisierung #buchhaltung`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterEnquiry: {
|
||||
poster: { video: "AutomationEnquiry", frame: 250 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: stars",
|
||||
lines: ["Not a night sky.", "Your enquiries."],
|
||||
post: `The faint ones are the enquiries nobody followed up on.\n\nThe steps can be connected: the contact is created, the right person is told, and a reminder appears when nobody has replied.\n\n${CALL_EN}\n\n#automation #sales`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Sterne",
|
||||
lines: ["Kein Sternenhimmel.", "Ihre Anfragen."],
|
||||
post: `Die blassen sind die Anfragen, bei denen niemand nachgefasst hat.\n\nDie Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn niemand geantwortet hat, erscheint eine Erinnerung.\n\n${CALL_DE}\n\n#automatisierung #vertrieb`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterWebsite: {
|
||||
poster: { video: "WebsiteDone", frame: 260 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: empty page",
|
||||
lines: ["Not a beginning.", "Your website."],
|
||||
post: `The website that has been on the list since last year.\n\nYou tell me what your business does. I design it, build it and put it online. Changes afterwards: you send me an email.\n\n${CALL_EN}\n\n#website #webdesign`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: leere Seite",
|
||||
lines: ["Kein Anfang.", "Ihre Website."],
|
||||
post: `Die Website, die seit letztem Jahr auf der Liste steht.\n\nSie erzählen mir, was Ihr Unternehmen macht. Ich gestalte sie, baue sie und bringe sie online. Änderungen danach: Sie schreiben mir eine E-Mail.\n\n${CALL_DE}\n\n#website #webdesign`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterApp: {
|
||||
poster: { video: "AppRescue", frame: 285 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: Jenga",
|
||||
lines: ["Not Jenga.", "Your app."],
|
||||
post: `It works. But bugs come back, and small changes take longer than they should.\n\nI take a week to go through the code, fix what is most urgent and write down what I would do next. In plain language.\n\n${CALL_EN}\n\n#webdevelopment #softwarequality`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Jenga",
|
||||
lines: ["Kein Jenga.", "Ihre App."],
|
||||
post: `Sie läuft. Aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten.\n\nIch nehme mir eine Woche, gehe den Code durch, behebe das Dringendste und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache.\n\n${CALL_DE}\n\n#webentwicklung #softwarequalität`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterDeveloper: {
|
||||
poster: { video: "ProductDeveloper", frame: 300 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: loading bars",
|
||||
lines: ["Not loading bars.", "Your backlog."],
|
||||
post: `More work than your team can handle.\n\nI join on agreed days of the week and work on what is next on your list. No package to buy, no minimum term.\n\n${CALL_EN}\n\n#webdevelopment #freelance`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Ladebalken",
|
||||
lines: ["Keine Ladebalken.", "Ihr Backlog."],
|
||||
post: `Mehr Arbeit, als Ihr Team schafft.\n\nIch komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes auf Ihrer Liste steht. Kein Paket, keine Mindestlaufzeit.\n\n${CALL_DE}\n\n#webentwicklung #freelancer`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotTechnical: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: not technical",
|
||||
label: "Not a technical person?",
|
||||
lines: ["Doesn't matter.", "You never have", "to read code."],
|
||||
post: `I explain what I am doing in plain language, and you can ask at any time.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: kein Technikmensch",
|
||||
label: "Kein Technikmensch?",
|
||||
lines: ["Macht nichts.", "Code müssen Sie", "nicht lesen."],
|
||||
post: `Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardOneContact: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: one contact",
|
||||
label: "Who does the work?",
|
||||
lines: ["The person", "you talk to."],
|
||||
post: `Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: ein Ansprechpartner",
|
||||
label: "Wer macht die Arbeit?",
|
||||
lines: ["Der, mit dem", "Sie sprechen."],
|
||||
post: `Oft geht eine Anfrage durch mehrere Hände, bevor sie beim Entwickler ankommt. Bei mir gibt es eine direkte Leitung zwischen Ihnen und dem, der die Arbeit macht.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardOwnership: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: ownership",
|
||||
label: "Who owns the code?",
|
||||
lines: ["You do.", "The design too."],
|
||||
post: `Code and design belong to you, and you can hand both to another developer.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Eigentum",
|
||||
label: "Wem gehört der Code?",
|
||||
lines: ["Ihnen.", "Das Design auch."],
|
||||
post: `Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardHours: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: hours",
|
||||
label: "What do you pay for?",
|
||||
lines: ["Hours you", "can check."],
|
||||
post: `I track my time. The monthly invoice shows what the hours were spent on.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Stunden",
|
||||
label: "Wofür zahlen Sie?",
|
||||
lines: ["Für Stunden, die Sie", "nachprüfen können."],
|
||||
post: `Ich erfasse meine Zeit. Die Monatsrechnung zeigt, wofür die Stunden angefallen sind.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotice: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: notice",
|
||||
label: "How long are you committed?",
|
||||
lines: ["Two weeks", "notice."],
|
||||
post: `Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Kündigungsfrist",
|
||||
label: "Wie lange sind Sie gebunden?",
|
||||
lines: ["Zwei Wochen", "Kündigungsfrist."],
|
||||
post: `Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotMyField: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: not my field",
|
||||
label: "What I do not do",
|
||||
lines: ["Mobile apps.", "Windows software.", "Running servers."],
|
||||
post: `If your project needs those, I say so in the first call.\n\nWhat I do: web apps, interfaces, websites, and connecting systems.\n\nwww.mintel.me`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: nicht mein Feld",
|
||||
label: "Was ich nicht mache",
|
||||
lines: ["Mobile Apps.", "Windows-Software.", "Serverbetrieb."],
|
||||
post: `Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.\n\nWas ich mache: Web-Apps, Oberflächen, Websites und das Verbinden von Systemen.\n\nwww.mintel.me`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IMAGES, IMAGE_IDS } from "./images";
|
||||
import { PLAN } from "./plan";
|
||||
import { COPY, LANGS, VIDEO_IDS } from "./videos";
|
||||
|
||||
/** Longer lines get too small to read on a phone. */
|
||||
const MAX_IMAGE_LINE = 28;
|
||||
const MAX_IMAGE_LINES = 3;
|
||||
|
||||
describe("image posts", () => {
|
||||
it.each(IMAGE_IDS)("%s has at most three short lines in every language", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { lines } = IMAGES[id].copy[lang];
|
||||
expect(lines.length, `${id} ${lang}`).toBeGreaterThan(0);
|
||||
expect(lines.length, `${id} ${lang}`).toBeLessThanOrEqual(MAX_IMAGE_LINES);
|
||||
for (const line of lines) expect(line.length, `${id} ${lang}: "${line}"`).toBeLessThanOrEqual(MAX_IMAGE_LINE);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(IMAGE_IDS)("%s has a post that names the website and never an email address", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post } = IMAGES[id].copy[lang];
|
||||
expect(post).toContain("www.mintel.me");
|
||||
expect(post).not.toContain("@");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(IMAGE_IDS)("%s is a poster of an existing film, or a card with a question", (id) => {
|
||||
const image = IMAGES[id];
|
||||
if (image.poster) {
|
||||
expect(VIDEO_IDS).toContain(image.poster.video);
|
||||
expect(image.poster.frame).toBeGreaterThanOrEqual(0);
|
||||
} else {
|
||||
for (const lang of LANGS) expect(image.copy[lang].label, `${id} ${lang}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("video posts", () => {
|
||||
it.each(VIDEO_IDS)("%s opens its post with the hook of the film", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post, slides } = COPY[id][lang];
|
||||
const lastHookWord = (slides.hook ?? []).join(" ").split(" ").pop()?.replace(/[.?!]/g, "") ?? "";
|
||||
expect(post.split("\n")[0], `${id} ${lang}`).toContain(lastHookWord);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("posting plan", () => {
|
||||
const planned = PLAN.flatMap((week) => week.posts);
|
||||
|
||||
it("uses every film and every image exactly once", () => {
|
||||
const videos = planned.filter((post) => post.kind === "video").map((post) => post.id);
|
||||
const images = planned.filter((post) => post.kind === "image").map((post) => post.id);
|
||||
expect([...videos].sort()).toEqual([...VIDEO_IDS].sort());
|
||||
expect([...images].sort()).toEqual([...IMAGE_IDS].sort());
|
||||
});
|
||||
|
||||
it("opens every week with its film", () => {
|
||||
for (const week of PLAN) expect(week.posts[0].kind, week.topic).toBe("video");
|
||||
});
|
||||
|
||||
it("pairs the film of a week with the poster of the same film", () => {
|
||||
for (const week of PLAN) {
|
||||
const film = week.posts[0].id;
|
||||
const posters = week.posts.filter((post) => post.kind === "image" && IMAGES[post.id].poster?.video === film);
|
||||
expect(posters, week.topic).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The posting plan: which film or image goes out in which week, in which order.
|
||||
* The reasoning is in `apps/web/plans/content-strategy.md`. Each week has one topic and three
|
||||
* posts: the film, the poster of the same idea, and a card that answers a question clients ask.
|
||||
*
|
||||
* This file imports only types, so the export script can read it with plain Node.
|
||||
*/
|
||||
import type { ImageId } from "./images.ts";
|
||||
import type { VideoId } from "./videos.ts";
|
||||
|
||||
export type PlannedPost =
|
||||
| { readonly kind: "video"; readonly id: VideoId }
|
||||
| { readonly kind: "image"; readonly id: ImageId };
|
||||
|
||||
export type PlannedWeek = {
|
||||
readonly topic: string;
|
||||
/** In posting order: Tuesday, Thursday, Saturday. */
|
||||
readonly posts: readonly [PlannedPost, PlannedPost, PlannedPost];
|
||||
};
|
||||
|
||||
export const POSTING_DAYS = ["Tuesday", "Thursday", "Saturday"] as const;
|
||||
|
||||
export const PLAN: readonly PlannedWeek[] = [
|
||||
{
|
||||
topic: "Automation: orders",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationOrder" },
|
||||
{ kind: "image", id: "CardNotTechnical" },
|
||||
{ kind: "image", id: "PosterOrder" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Websites",
|
||||
posts: [
|
||||
{ kind: "video", id: "WebsiteDone" },
|
||||
{ kind: "image", id: "CardOneContact" },
|
||||
{ kind: "image", id: "PosterWebsite" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Automation: invoices",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationDocuments" },
|
||||
{ kind: "image", id: "CardHours" },
|
||||
{ kind: "image", id: "PosterDocuments" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Reliable apps",
|
||||
posts: [
|
||||
{ kind: "video", id: "AppRescue" },
|
||||
{ kind: "image", id: "CardOwnership" },
|
||||
{ kind: "image", id: "PosterApp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Automation: enquiries",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationEnquiry" },
|
||||
{ kind: "image", id: "CardNotMyField" },
|
||||
{ kind: "image", id: "PosterEnquiry" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "A developer for your product",
|
||||
posts: [
|
||||
{ kind: "video", id: "ProductDeveloper" },
|
||||
{ kind: "image", id: "CardNotice" },
|
||||
{ kind: "image", id: "PosterDeveloper" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COPY, LANGS, VIDEO_IDS, translate } from "./videos";
|
||||
|
||||
const MAX_LINES = 2;
|
||||
/** Longer lines get too small to read on a phone. */
|
||||
const MAX_LINE_LENGTH = 24;
|
||||
|
||||
describe("video copy", () => {
|
||||
it.each(VIDEO_IDS)("%s has the same slides in every language", (id) => {
|
||||
const [first, ...others] = LANGS.map((lang) => Object.keys(COPY[id][lang].slides).sort());
|
||||
for (const slides of others) expect(slides).toEqual(first);
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s keeps every headline to two short lines", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
for (const [slide, lines] of Object.entries(COPY[id][lang].slides)) {
|
||||
expect(lines.length, `${id} ${lang} ${slide}`).toBeGreaterThan(0);
|
||||
expect(lines.length, `${id} ${lang} ${slide}`).toBeLessThanOrEqual(MAX_LINES);
|
||||
for (const line of lines) {
|
||||
expect(line.length, `${id} ${lang} ${slide}: "${line}"`).toBeLessThanOrEqual(MAX_LINE_LENGTH);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s ends with a call to action slide", (id) => {
|
||||
for (const lang of LANGS) expect(COPY[id][lang].slides).toHaveProperty("cta");
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s has a post that names the website and never an email address", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post } = COPY[id][lang];
|
||||
expect(post).toContain("www.mintel.me");
|
||||
expect(post).not.toContain("@");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("translate", () => {
|
||||
it("leaves English labels as they are", () => {
|
||||
expect(translate("en", "Invoice")).toBe("Invoice");
|
||||
});
|
||||
|
||||
it("returns the German label when there is one", () => {
|
||||
expect(translate("de", "Invoice")).toBe("Rechnung");
|
||||
});
|
||||
|
||||
it("falls back to the given text when there is no German label", () => {
|
||||
expect(translate("de", "Shop")).toBe("Shop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Everything that is said in and about the videos: headlines per slide, labels inside the drawings,
|
||||
* and the text to post with each video. English and German.
|
||||
*
|
||||
* This file imports nothing, so the export script can read it with plain Node.
|
||||
* German addresses the viewer formally ("Sie"), as is usual towards business owners.
|
||||
*/
|
||||
|
||||
export const LANGS = ["en", "de"] as const;
|
||||
export type Lang = (typeof LANGS)[number];
|
||||
|
||||
export const VIDEO_IDS = [
|
||||
"AutomationOrder",
|
||||
"AutomationDocuments",
|
||||
"AutomationEnquiry",
|
||||
"WebsiteDone",
|
||||
"AppRescue",
|
||||
"ProductDeveloper",
|
||||
] as const;
|
||||
export type VideoId = (typeof VIDEO_IDS)[number];
|
||||
|
||||
export type VideoCopy = {
|
||||
/** What the video is about, for the overview page. */
|
||||
readonly title: string;
|
||||
/** Headline per slide id: at most two lines of a few words. */
|
||||
readonly slides: Readonly<Record<string, readonly string[]>>;
|
||||
/** Text to post with the video on LinkedIn. */
|
||||
readonly post: string;
|
||||
};
|
||||
|
||||
/** German for every label that appears inside a drawing or on the frame. Missing entries stay English. */
|
||||
export const LABELS_DE: Readonly<Record<string, string>> = {
|
||||
"Freelance developer": "Freelance-Entwickler",
|
||||
"Get in touch": "Kontakt aufnehmen",
|
||||
Invoice: "Rechnung",
|
||||
Customers: "Kunden",
|
||||
Email: "E-Mail",
|
||||
"One person": "Eine Person",
|
||||
Inbox: "Posteingang",
|
||||
Read: "Lesen",
|
||||
Check: "Prüfen",
|
||||
Books: "Buchhaltung",
|
||||
"Invoice.pdf": "Rechnung.pdf",
|
||||
Accounting: "Buchhaltung",
|
||||
Supplier: "Lieferant",
|
||||
Amount: "Betrag",
|
||||
Date: "Datum",
|
||||
"A person": "Ein Mensch",
|
||||
Contacts: "Kontakte",
|
||||
Reminder: "Erinnerung",
|
||||
"Day 1": "Tag 1",
|
||||
"Day 2": "Tag 2",
|
||||
"Day 3": "Tag 3",
|
||||
"Day 4": "Tag 4",
|
||||
"Website builder": "Baukasten",
|
||||
"Your website": "Ihre Website",
|
||||
"Your email": "Ihre E-Mail",
|
||||
"www.your-name.com": "www.ihr-name.de",
|
||||
"Your app": "Ihre App",
|
||||
Mon: "Mo",
|
||||
Tue: "Di",
|
||||
Wed: "Mi",
|
||||
Thu: "Do",
|
||||
Fri: "Fr",
|
||||
"To do": "Offen",
|
||||
Done: "Erledigt",
|
||||
Product: "Produkt",
|
||||
Developer: "Entwicklung",
|
||||
};
|
||||
|
||||
export const translate = (lang: Lang, text: string): string =>
|
||||
lang === "de" ? (LABELS_DE[text] ?? text) : text;
|
||||
|
||||
export const COPY: Readonly<Record<VideoId, Readonly<Record<Lang, VideoCopy>>>> = {
|
||||
AutomationOrder: {
|
||||
en: {
|
||||
title: "Automation: from order to invoice",
|
||||
slides: {
|
||||
hook: ["Looks like", "ping-pong."],
|
||||
reveal: ["It's your", "office."],
|
||||
problem: ["Every order.", "By hand."],
|
||||
turn: ["Or your tools", "talk to each other."],
|
||||
calm: ["And your team", "has time."],
|
||||
cta: ["Done with", "ping-pong?"],
|
||||
},
|
||||
post: `Looks like ping-pong. It's your office.
|
||||
|
||||
An order comes in. Someone opens it in the shop, writes the invoice, updates the customer record, sends the confirmation. Then the next one. And the next.
|
||||
|
||||
Works beautifully, as long as hardly anyone orders.
|
||||
|
||||
The four tools can sort this out among themselves: the order triggers invoice, customer record and email on its own. Your team sees the finished result and looks after the cases that really need a person.
|
||||
|
||||
I connect the tools you already use. A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #processautomation #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: von der Bestellung zur Rechnung",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "Pingpong."],
|
||||
reveal: ["Ist aber", "Ihr Büro."],
|
||||
problem: ["Jede Bestellung.", "Von Hand."],
|
||||
turn: ["Oder Ihre Tools", "reden miteinander."],
|
||||
calm: ["Und Ihr Team", "hat Zeit."],
|
||||
cta: ["Schluss mit", "Pingpong?"],
|
||||
},
|
||||
post: `Sieht aus wie Pingpong. Ist aber Ihr Büro.
|
||||
|
||||
Eine Bestellung kommt rein. Jemand öffnet sie im Shop, schreibt die Rechnung, pflegt den Kunden nach, verschickt die Bestätigung. Dann die nächste. Und die nächste.
|
||||
|
||||
Klappt wunderbar, solange kaum jemand bestellt.
|
||||
|
||||
Dabei können die vier Tools das längst unter sich ausmachen: Die Bestellung löst Rechnung, Kundendatensatz und E-Mail selbst aus. Ihr Team sieht das fertige Ergebnis und kümmert sich um die Fälle, die wirklich einen Menschen brauchen.
|
||||
|
||||
Ich verbinde die Tools, die Sie schon haben. Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #prozessautomatisierung #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AutomationDocuments: {
|
||||
en: {
|
||||
title: "Automation: incoming invoices",
|
||||
slides: {
|
||||
hook: ["Looks like", "a skyscraper."],
|
||||
reveal: ["It's your", "inbox."],
|
||||
problem: ["Every invoice.", "Typed in."],
|
||||
turn: ["Or it simply", "gets read."],
|
||||
calm: ["Unclear ones go", "to a person."],
|
||||
cta: ["Done typing", "things in?"],
|
||||
},
|
||||
post: `Looks like a skyscraper. It's your inbox.
|
||||
|
||||
Invoices arrive as PDF. Someone opens each one, reads supplier, amount and date, and types them into the accounting system. Floor by floor.
|
||||
|
||||
Nobody has to do that by hand. The document can be read automatically, a fixed rule checks whether the values make sense, and clear cases are booked directly. Anything unclear goes to a person, with the document attached.
|
||||
|
||||
So people decide the exceptions instead of typing the routine.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #accounting #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: Eingangsrechnungen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Hochhaus."],
|
||||
reveal: ["Ist aber Ihr", "Posteingang."],
|
||||
problem: ["Jede Rechnung.", "Abgetippt."],
|
||||
turn: ["Oder sie wird", "einfach gelesen."],
|
||||
calm: ["Unklares geht", "an einen Menschen."],
|
||||
cta: ["Schluss mit", "Abtippen?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Hochhaus. Ist aber Ihr Posteingang.
|
||||
|
||||
Rechnungen kommen als PDF. Jemand öffnet jede einzelne, liest Lieferant, Betrag und Datum ab und tippt alles in die Buchhaltung. Stockwerk für Stockwerk.
|
||||
|
||||
Das muss niemand von Hand machen. Das Dokument lässt sich automatisch auslesen, eine feste Regel prüft, ob die Werte plausibel sind, und klare Fälle werden direkt gebucht. Alles Unklare geht an einen Menschen, mit dem Dokument im Anhang.
|
||||
|
||||
So entscheiden Menschen die Ausnahmen, statt Routine abzutippen.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #buchhaltung #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AutomationEnquiry: {
|
||||
en: {
|
||||
title: "Automation: from enquiry to follow-up",
|
||||
slides: {
|
||||
hook: ["Looks like", "a star."],
|
||||
reveal: ["These are your", "enquiries."],
|
||||
problem: ["They burn out.", "Unanswered."],
|
||||
turn: ["Or each one reaches", "the right person."],
|
||||
calm: ["And follow-ups", "happen by themselves."],
|
||||
cta: ["Stop losing", "enquiries?"],
|
||||
},
|
||||
post: `Looks like a star. It's an enquiry, burning out.
|
||||
|
||||
Someone fills in your contact form. The enquiry is copied into the customer list by hand, forwarded by email, and everyone hopes the follow-up is not forgotten.
|
||||
|
||||
Hope is not a process.
|
||||
|
||||
These steps can be connected: the contact is created, the right person is told, and if nobody has replied after three days, a reminder appears.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #sales #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: von der Anfrage zum Nachfassen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Stern."],
|
||||
reveal: ["Sind aber Ihre", "Anfragen."],
|
||||
problem: ["Sie verglühen.", "Unbeantwortet."],
|
||||
turn: ["Oder jede landet", "beim Richtigen."],
|
||||
calm: ["Und Nachfassen", "passiert von selbst."],
|
||||
cta: ["Keine Anfrage", "mehr verlieren?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Stern. Ist aber eine Anfrage, die gerade verglüht.
|
||||
|
||||
Jemand füllt Ihr Kontaktformular aus. Die Anfrage wird von Hand in die Kundenliste kopiert, per E-Mail weitergeleitet, und alle hoffen, dass das Nachfassen nicht vergessen wird.
|
||||
|
||||
Hoffen ist kein Prozess.
|
||||
|
||||
Diese Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn nach drei Tagen niemand geantwortet hat, erscheint eine Erinnerung.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #vertrieb #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
WebsiteDone: {
|
||||
en: {
|
||||
title: "A website, built and looked after",
|
||||
slides: {
|
||||
hook: ["Looks like", "a beginning."],
|
||||
reveal: ["It's been your", "website for months."],
|
||||
problem: ["Empty.", "Still."],
|
||||
turn: ["You talk.", "I build."],
|
||||
calm: ["Fits every", "screen."],
|
||||
cta: ["Website", "overdue?"],
|
||||
},
|
||||
post: `Looks like a beginning. It's been your website for months.
|
||||
|
||||
You run a business. Learning a website builder on top of that was never going to happen.
|
||||
|
||||
This is how I do it instead: you tell me what your business does and what the website is for. I design it, build it and put it online. It works on phone, tablet and desktop, and you approve it before it goes live.
|
||||
|
||||
And afterwards? A new photo or a changed price: you send me an email.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#website #webdesign #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Eine Website, gebaut und betreut",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Anfang."],
|
||||
reveal: ["Ist seit Monaten", "Ihre Website."],
|
||||
problem: ["Leer.", "Immer noch."],
|
||||
turn: ["Sie erzählen.", "Ich baue."],
|
||||
calm: ["Passt auf", "jeden Bildschirm."],
|
||||
cta: ["Website", "fällig?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Anfang. Ist seit Monaten Ihre Website.
|
||||
|
||||
Sie führen ein Unternehmen. Dass Sie nebenbei noch einen Website-Baukasten lernen, war nie realistisch.
|
||||
|
||||
So mache ich es stattdessen: Sie erzählen mir, was Ihr Unternehmen macht und wofür die Website da ist. Ich gestalte sie, baue sie und bringe sie online. Sie funktioniert auf Handy, Tablet und Desktop, und Sie geben sie frei, bevor sie live geht.
|
||||
|
||||
Und danach? Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#website #webdesign #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AppRescue: {
|
||||
en: {
|
||||
title: "Making an app reliable",
|
||||
slides: {
|
||||
hook: ["Looks like", "Jenga."],
|
||||
reveal: ["It's your", "app."],
|
||||
problem: ["Every change", "wobbles."],
|
||||
turn: ["One week.", "I look inside."],
|
||||
calm: ["After that", "it stands."],
|
||||
cta: ["App", "wobbling?"],
|
||||
},
|
||||
post: `Looks like Jenga. It's your app.
|
||||
|
||||
It works. But bugs come back, and every small change feels like pulling out a block.
|
||||
|
||||
That usually happens when an app was built quickly and then kept growing.
|
||||
|
||||
I take a week to go through it: I look at what is solid and what is fragile, fix the most urgent problems as far as a week allows, and write down what I would do next. In plain language, so you can follow it without being a developer.
|
||||
|
||||
After that you decide how to continue: with me, with your own team, or not at all.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webdevelopment #softwarequality #startups`,
|
||||
},
|
||||
de: {
|
||||
title: "Eine App zuverlässig machen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "Jenga."],
|
||||
reveal: ["Ist aber", "Ihre App."],
|
||||
problem: ["Jede Änderung", "wackelt."],
|
||||
turn: ["Eine Woche.", "Ich sehe hinein."],
|
||||
calm: ["Danach", "steht sie."],
|
||||
cta: ["App", "wackelt?"],
|
||||
},
|
||||
post: `Sieht aus wie Jenga. Ist aber Ihre App.
|
||||
|
||||
Sie läuft. Aber Fehler kommen wieder, und jede kleine Änderung fühlt sich an, als zöge man einen Stein heraus.
|
||||
|
||||
Das passiert meist, wenn eine App schnell gebaut wurde und dann immer weiter gewachsen ist.
|
||||
|
||||
Ich nehme mir eine Woche dafür: Ich sehe mir an, was solide ist und was wackelt, behebe die dringendsten Probleme, soweit eine Woche reicht, und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache, die Sie auch ohne Entwicklerwissen verstehen.
|
||||
|
||||
Danach entscheiden Sie, wie es weitergeht: mit mir, mit Ihrem eigenen Team oder gar nicht.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webentwicklung #softwarequalität #startups`,
|
||||
},
|
||||
},
|
||||
|
||||
ProductDeveloper: {
|
||||
en: {
|
||||
title: "A developer for your product",
|
||||
slides: {
|
||||
hook: ["Looks like", "a loading bar."],
|
||||
reveal: ["It's your", "backlog."],
|
||||
problem: ["Growing faster", "than your team."],
|
||||
turn: ["One more", "developer."],
|
||||
calm: ["Fixed days.", "No minimum term."],
|
||||
cta: ["Backlog", "too long?"],
|
||||
},
|
||||
post: `Looks like a loading bar. It's your backlog.
|
||||
|
||||
You have a product, and the list grows faster than your team gets through it.
|
||||
|
||||
I join on agreed days of the week and work on what is next: new features, the interface, or the parts nobody gets around to.
|
||||
|
||||
Finished parts go to you for review as they are done. I track my hours, and you can see what they were spent on. No package to buy, no minimum term: either side can end it with two weeks notice to the end of a month.
|
||||
|
||||
15 years in web development, in agencies and product teams.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webdevelopment #freelance #react`,
|
||||
},
|
||||
de: {
|
||||
title: "Ein Entwickler für Ihr Produkt",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Ladebalken."],
|
||||
reveal: ["Ist aber", "Ihr Backlog."],
|
||||
problem: ["Wächst schneller", "als Ihr Team."],
|
||||
turn: ["Ein Entwickler", "mehr."],
|
||||
calm: ["Feste Tage.", "Keine Mindestlaufzeit."],
|
||||
cta: ["Backlog", "zu lang?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Ladebalken. Ist aber Ihr Backlog.
|
||||
|
||||
Sie haben ein Produkt, und die Liste wächst schneller, als Ihr Team sie abarbeitet.
|
||||
|
||||
Ich komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes ansteht: neue Funktionen, die Oberfläche oder die Teile, zu denen niemand kommt.
|
||||
|
||||
Fertige Teile gehen zur Durchsicht an Sie, sobald sie fertig sind. Ich erfasse meine Stunden, und Sie sehen, wofür sie verwendet wurden. Kein Paket, keine Mindestlaufzeit: Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.
|
||||
|
||||
15 Jahre Webentwicklung, in Agenturen und Produktteams.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webentwicklung #freelancer #react`,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a block wobbles on another: Jenga. The camera pulls back: it is a whole leaning tower,
|
||||
* the app, swaying with every change. It stops, a light rises through it, the blocks line up one
|
||||
* by one, and two rails keep it straight.
|
||||
*/
|
||||
const BLOCK = { width: 230, height: 44, pitch: 52, base: 290 } as const;
|
||||
/** How far each block sits off centre while the tower is crooked. */
|
||||
const OFFSETS = [0, 14, -10, 22, -18, 26, -8, 30, -22] as const;
|
||||
const TOP = OFFSETS.length - 1;
|
||||
const SWAY_FRAMES = 46;
|
||||
const SWAY_DEGREES = 2.2;
|
||||
const ALIGN_EVERY = 8;
|
||||
const RAIL_GAP = 42;
|
||||
|
||||
const blockY = (index: number): number => BLOCK.base - BLOCK.height / 2 - index * BLOCK.pitch;
|
||||
const alignStart = (index: number): number => T.resolve + index * ALIGN_EVERY;
|
||||
const LAST_ALIGN = alignStart(TOP) + 22;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const AppDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 2.4, 1, { x: 0, y: blockY(TOP) + 30 });
|
||||
const { zoom, hair, others } = camera;
|
||||
const folded = frame >= T.fold;
|
||||
/** How much the tower moves: it stops at the freeze and starts again for the loop. */
|
||||
const restless = folded ? progress(frame, T.fold + 16, SHOT_FRAMES) : 1 - progress(frame, T.freeze, T.turn);
|
||||
const wave = Math.sin((frame / SWAY_FRAMES) * Math.PI * 2);
|
||||
const crooked = (index: number): number =>
|
||||
folded ? inOut(progress(frame, T.fold, T.fold + 40)) : 1 - inOut(progress(frame, alignStart(index), alignStart(index) + 22));
|
||||
const scan = progress(frame, T.turn, LAST_ALIGN);
|
||||
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_ALIGN - 10, LAST_ALIGN));
|
||||
const rails = out(progress(frame, T.calm, T.calm + 40)) * untilFold(frame);
|
||||
const top = blockY(TOP) - BLOCK.height / 2 - 20;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
<path d={`M-280 ${BLOCK.base}h560`} stroke={DRAW.fg} strokeOpacity={0.5 * others} strokeWidth={hair} strokeLinecap="round" />
|
||||
|
||||
{[-1, 1].map((side) => (
|
||||
<path
|
||||
key={side}
|
||||
d={`M${side * (BLOCK.width / 2 + RAIL_GAP)} ${BLOCK.base}V${mix(BLOCK.base, top, rails)}`}
|
||||
stroke={COLOR.done}
|
||||
strokeWidth={hair * 1.6}
|
||||
strokeLinecap="round"
|
||||
opacity={rails > 0 ? 1 : 0}
|
||||
style={glow(COLOR.done, 10, zoom)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<g transform={`rotate(${wave * SWAY_DEGREES * restless} 0 ${BLOCK.base})`}>
|
||||
{OFFSETS.map((offset, index) => {
|
||||
const off = crooked(index);
|
||||
// The top block wobbles on its own: that is the opening picture.
|
||||
const wobble = index === TOP ? Math.sin((frame / (SWAY_FRAMES / 2)) * Math.PI * 2) * 3.5 * restless : 0;
|
||||
const justAligned = folded ? 0 : Math.sin(Math.PI * progress(frame, alignStart(index), alignStart(index) + 30));
|
||||
const y = blockY(index);
|
||||
return (
|
||||
<g
|
||||
key={index}
|
||||
opacity={index >= TOP - 1 ? 1 : others}
|
||||
transform={`translate(${offset * off} 0) rotate(${wobble} ${-BLOCK.width / 2} ${y + BLOCK.height / 2})`}
|
||||
>
|
||||
<rect x={-BLOCK.width / 2} y={y - BLOCK.height / 2} width={BLOCK.width} height={BLOCK.height} rx={13} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={-BLOCK.width / 2}
|
||||
y={y - BLOCK.height / 2}
|
||||
width={BLOCK.width}
|
||||
height={BLOCK.height}
|
||||
rx={13}
|
||||
fill={`rgba(248, 250, 252, ${0.1 + justAligned * 0.25})`}
|
||||
stroke={justAligned > 0.1 ? DRAW.mark : DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity + justAligned * 0.25}
|
||||
strokeWidth={hair * (1 + justAligned * 0.6)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{scanShown > 0 && (
|
||||
<path
|
||||
d={`M-190 ${mix(BLOCK.base, top, scan)}h380`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.4}
|
||||
strokeLinecap="round"
|
||||
opacity={scanShown}
|
||||
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Making an app reliable: from a leaning tower that sways with every change to one that stands. */
|
||||
export const AppFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<AppDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, glow } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a bar fills slowly: a loading bar. The camera pulls back: it is the first of a long
|
||||
* list, the backlog, and new entries arrive faster than the team gets through them. Then one more
|
||||
* developer joins, and the list is worked off, entry after entry.
|
||||
*/
|
||||
const ROW = { x: -215, width: 470, height: 26, pitch: 44, top: -270 } as const;
|
||||
const MAX_ROWS = 13;
|
||||
const OPENING_ROWS = 4;
|
||||
const ROW_EVERY = 26;
|
||||
const TEAM_SIZE = 3;
|
||||
const WORKER_X = ROW.x - 42;
|
||||
const DONE_EVERY = 13;
|
||||
const OPENING_FILL = 0.15;
|
||||
|
||||
const rowY = (index: number): number => ROW.top + index * ROW.pitch;
|
||||
const doneStart = (index: number): number => T.resolve + index * DONE_EVERY;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const DeveloperDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 1.7, 1, { x: 0, y: rowY(0) });
|
||||
const { zoom, hair, others } = camera;
|
||||
const folded = frame >= T.fold;
|
||||
const rows = folded ? 1 : Math.min(MAX_ROWS, OPENING_ROWS + Math.max(0, frame - T.reveal) / ROW_EVERY);
|
||||
const dot = 12 / Math.sqrt(zoom);
|
||||
const joined = out(progress(frame, T.turn, T.turn + 28));
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{Array.from({ length: MAX_ROWS }, (_, index) => {
|
||||
if (index >= rows) return null;
|
||||
const y = rowY(index);
|
||||
const arrived = Math.min(1, rows - index);
|
||||
const worked = index === 0 ? mix(OPENING_FILL, 0.6, progress(frame, 0, T.turn)) : index < TEAM_SIZE ? mix(0, 0.4, progress(frame, T.reveal, T.turn)) : 0;
|
||||
const finishing = folded ? 0 : out(progress(frame, doneStart(index), doneStart(index) + 16));
|
||||
const leaving = folded ? 0 : progress(frame, doneStart(index) + 18, doneStart(index) + 32);
|
||||
const fill = folded ? OPENING_FILL : mix(worked, 1, finishing);
|
||||
const shown = folded ? progress(frame, T.fold + 16, T.fold + 44) : arrived * (1 - leaving) * (index === 0 ? 1 : others);
|
||||
if (shown === 0) return null;
|
||||
return (
|
||||
<g key={index} opacity={shown} transform={`translate(${-36 * leaving} ${(1 - out(arrived)) * 26})`}>
|
||||
<rect
|
||||
x={ROW.x}
|
||||
y={y - ROW.height / 2}
|
||||
width={ROW.width}
|
||||
height={ROW.height}
|
||||
rx={ROW.height / 2}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={0.5}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
{fill > 0 && (
|
||||
<rect
|
||||
x={ROW.x}
|
||||
y={y - ROW.height / 2}
|
||||
width={Math.max(ROW.height, ROW.width * fill)}
|
||||
height={ROW.height}
|
||||
rx={ROW.height / 2}
|
||||
fill={finishing === 1 ? COLOR.done : DRAW.fg}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{Array.from({ length: TEAM_SIZE }, (_, index) => (
|
||||
<circle key={index} cx={WORKER_X} cy={rowY(index)} r={dot} fill={DRAW.fg} opacity={index === 0 ? 1 : others} />
|
||||
))}
|
||||
<circle
|
||||
cx={mix(WORKER_X - 150, WORKER_X, joined)}
|
||||
cy={rowY(TEAM_SIZE)}
|
||||
r={dot}
|
||||
fill={DRAW.mark}
|
||||
opacity={joined * others}
|
||||
style={glow(DRAW.mark, 14, zoom)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** A developer for a product: from a backlog that grows faster than the team to a list worked off. */
|
||||
export const DeveloperFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<DeveloperDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, bars drop onto a stack: a skyscraper going up. The camera pulls back: it is the pile of
|
||||
* invoices waiting to be typed in, and it keeps growing. Then a light runs down the pile and the
|
||||
* sheets leave by themselves: most into the books, the few unclear ones to a person.
|
||||
*/
|
||||
const BAR = { x: -170, width: 210, height: 16, pitch: 25, base: 290 } as const;
|
||||
const MAX_BARS = 24;
|
||||
const BOOKS = { x: 250, y: -70, size: 130 } as const;
|
||||
const PERSON = { x: 250, y: 170, r: 44 } as const;
|
||||
const OPENING_BARS = 3;
|
||||
const CLOSE_EVERY = 25;
|
||||
/** The film starts with a bar in mid-fall: the first frame is the preview image. */
|
||||
const CLOSE_PHASE = 12;
|
||||
const WIDE_EVERY = 9;
|
||||
const LEAVE_EVERY = 5;
|
||||
const LEAVE_FRAMES = 24;
|
||||
/** Every sixth sheet is unclear and goes to a person. */
|
||||
const isUnclear = (index: number): boolean => index % 6 === 2;
|
||||
|
||||
const barY = (index: number): number => BAR.base - BAR.height / 2 - index * BAR.pitch;
|
||||
|
||||
/** How many bars have arrived, as a number with a fraction: the fraction is the one still falling. */
|
||||
const barsAt = (frame: number): number => {
|
||||
if (frame >= T.fold) return OPENING_BARS + (frame - SHOT_FRAMES + CLOSE_PHASE) / CLOSE_EVERY;
|
||||
const close = OPENING_BARS + (Math.min(frame, T.reveal) + CLOSE_PHASE) / CLOSE_EVERY;
|
||||
return Math.min(MAX_BARS, close + Math.max(0, frame - T.reveal) / WIDE_EVERY);
|
||||
};
|
||||
|
||||
const leaveStart = (index: number): number => T.resolve + (MAX_BARS - 1 - index) * LEAVE_EVERY;
|
||||
const LAST_LEAVE = T.resolve + MAX_BARS * LEAVE_EVERY;
|
||||
const UNCLEAR = Array.from({ length: MAX_BARS }, (_, index) => index).filter(isUnclear);
|
||||
|
||||
/** Where an unclear sheet waits next to the person. */
|
||||
const waitingSpot = (index: number): { x: number; y: number } => {
|
||||
const angle = UNCLEAR.indexOf(index) * 0.62 + 2.2;
|
||||
return { x: PERSON.x + Math.cos(angle) * 78, y: PERSON.y + Math.sin(angle) * 78 };
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const DocumentsDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 2.6, 0.95, { x: BAR.x, y: barY(OPENING_BARS) - 20 });
|
||||
const { zoom, hair, others } = camera;
|
||||
const bars = barsAt(frame);
|
||||
const leaving = frame < T.fold;
|
||||
const scan = progress(frame, T.turn, LAST_LEAVE);
|
||||
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_LEAVE - 10, LAST_LEAVE));
|
||||
const dot = 11 / Math.sqrt(zoom);
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
<path d={`M${BAR.x - 170} ${BAR.base}h340`} stroke={DRAW.fg} strokeOpacity={0.5} strokeWidth={hair} strokeLinecap="round" />
|
||||
|
||||
<g opacity={others}>
|
||||
<rect
|
||||
x={BOOKS.x - BOOKS.size / 2}
|
||||
y={BOOKS.y - BOOKS.size / 2}
|
||||
width={BOOKS.size}
|
||||
height={BOOKS.size}
|
||||
rx={BOOKS.size * 0.24}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
<circle cx={PERSON.x} cy={PERSON.y} r={PERSON.r} fill={SURFACE.fill} stroke={DRAW.fg} strokeOpacity={SURFACE.strokeOpacity} strokeWidth={hair} />
|
||||
</g>
|
||||
|
||||
{Array.from({ length: MAX_BARS }, (_, index) => {
|
||||
if (index >= bars) return null;
|
||||
const arrived = Math.min(1, bars - index);
|
||||
const fadeIn = leaving ? 1 : progress(frame, T.fold + 8, T.fold + 30);
|
||||
const gone = leaving ? inOut(progress(frame, leaveStart(index), leaveStart(index) + LEAVE_FRAMES)) : 0;
|
||||
const unclear = isUnclear(index);
|
||||
const target = unclear ? waitingSpot(index) : BOOKS;
|
||||
const width = mix(BAR.width, dot * 2, gone);
|
||||
const height = mix(BAR.height, dot * 2, gone);
|
||||
const x = mix(BAR.x, target.x, gone);
|
||||
const y = mix(mix(barY(index) - 150 / zoom, barY(index), out(arrived)), target.y, gone) - Math.sin(Math.PI * gone) * 70;
|
||||
const opacity = arrived * fadeIn * (unclear ? 1 : 1 - progress(gone, 0.82, 1));
|
||||
if (opacity === 0) return null;
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={x - width / 2}
|
||||
y={y - height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={height / 2}
|
||||
fill={unclear && gone > 0 ? DRAW.mark : DRAW.fg}
|
||||
opacity={opacity * (gone > 0 ? 1 : 0.9)}
|
||||
style={unclear && gone > 0.5 ? glow(DRAW.mark, 10, zoom) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!leaving &&
|
||||
UNCLEAR.map((index) => {
|
||||
const spot = waitingSpot(index);
|
||||
return <circle key={index} cx={spot.x} cy={spot.y} r={dot} fill={DRAW.mark} opacity={untilFold(frame) * others} />;
|
||||
})}
|
||||
|
||||
{scanShown > 0 && (
|
||||
<path
|
||||
d={`M${BAR.x - 150} ${mix(barY(MAX_BARS - 1), BAR.base, scan)}h300`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.4}
|
||||
strokeLinecap="round"
|
||||
opacity={scanShown}
|
||||
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Incoming invoices: from a growing pile to documents that are read and booked by themselves. */
|
||||
export const DocumentsFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<DocumentsDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { mix, out, progress } from "../illustrations/motion";
|
||||
import type { Point } from "../lib/hop";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, one point of light: a star. The camera pulls back: a whole sky of them, each one an
|
||||
* enquiry, and one after the other they burn out unanswered. Then a line reaches every one of them
|
||||
* from the team in the middle, they light up again, and a quiet ring keeps reminding.
|
||||
*/
|
||||
const TEAM = { x: 0, y: 10, size: 120 } as const;
|
||||
/** The first one is the star of the opening picture. */
|
||||
const STARS: readonly Point[] = [
|
||||
{ x: -190, y: -90 },
|
||||
{ x: -380, y: -210 },
|
||||
{ x: -60, y: -260 },
|
||||
{ x: 210, y: -220 },
|
||||
{ x: 400, y: -130 },
|
||||
{ x: 320, y: 50 },
|
||||
{ x: 420, y: 230 },
|
||||
{ x: 170, y: 250 },
|
||||
{ x: -90, y: 230 },
|
||||
{ x: -300, y: 250 },
|
||||
{ x: -430, y: 50 },
|
||||
{ x: -250, y: 110 },
|
||||
{ x: 150, y: -90 },
|
||||
];
|
||||
const HERO: Point = STARS[0] ?? { x: 0, y: 0 };
|
||||
const TWINKLE_FRAMES = 23;
|
||||
const RING_FRAMES = 60;
|
||||
|
||||
const lineStart = (index: number): number => T.resolve + index * 6;
|
||||
|
||||
/** How brightly an enquiry still shines: it fades while nobody answers and returns once it is reached. */
|
||||
const brightness = (index: number, frame: number): number => {
|
||||
if (frame >= T.fold) return 1;
|
||||
const fadeFrom = index === 0 ? 0 : 60 + index * 17;
|
||||
const faded = progress(frame, fadeFrom, fadeFrom + (index === 0 ? 170 : 90));
|
||||
const reached = out(progress(frame, lineStart(index) + 18, lineStart(index) + 40));
|
||||
return mix(1 - 0.88 * faded, 1, reached);
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const EnquiryDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 3, 0.95, HERO);
|
||||
const { zoom, hair, others } = camera;
|
||||
const radius = 11 / Math.sqrt(zoom);
|
||||
const sinceCalm = frame - T.calm;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{STARS.map((star, index) => {
|
||||
const drawn = out(progress(frame, lineStart(index), lineStart(index) + 30)) * untilFold(frame);
|
||||
return drawn === 0 ? null : (
|
||||
<path
|
||||
key={index}
|
||||
d={`M${TEAM.x} ${TEAM.y}L${mix(TEAM.x, star.x, drawn)} ${mix(TEAM.y, star.y, drawn)}`}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={0.6}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{sinceCalm > 0 &&
|
||||
[0, RING_FRAMES / 2].map((delay) => {
|
||||
const t = ((sinceCalm + delay) % RING_FRAMES) / RING_FRAMES;
|
||||
return (
|
||||
<circle
|
||||
key={delay}
|
||||
cx={TEAM.x}
|
||||
cy={TEAM.y}
|
||||
r={mix(TEAM.size * 0.6, TEAM.size * 1.5, t)}
|
||||
fill="none"
|
||||
stroke={`rgba(${DRAW.light}, 1)`}
|
||||
strokeWidth={hair}
|
||||
opacity={(1 - t) * 0.7 * untilFold(frame) * progress(sinceCalm, 0, 20)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<g opacity={others}>
|
||||
<rect x={TEAM.x - TEAM.size / 2} y={TEAM.y - TEAM.size / 2} width={TEAM.size} height={TEAM.size} rx={TEAM.size * 0.24} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={TEAM.x - TEAM.size / 2}
|
||||
y={TEAM.y - TEAM.size / 2}
|
||||
width={TEAM.size}
|
||||
height={TEAM.size}
|
||||
rx={TEAM.size * 0.24}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
</g>
|
||||
|
||||
{STARS.map((star, index) => {
|
||||
const twinkle = 0.92 + 0.08 * Math.sin((frame / TWINKLE_FRAMES) * Math.PI * 2 + index);
|
||||
const shine = brightness(index, frame) * twinkle;
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={star.x}
|
||||
cy={star.y}
|
||||
r={radius}
|
||||
fill={DRAW.mark}
|
||||
opacity={shine * (index === 0 ? 1 : others)}
|
||||
style={glow(DRAW.mark, 16 * shine, zoom)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Enquiries: from stars that burn out unanswered to every one reaching the right person. */
|
||||
export const EnquiryFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<EnquiryDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Easing, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { BrandButton } from "../brand/BrandButton";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { useCopy } from "../content/CopyContext";
|
||||
import { headlineSize } from "../slides/SlideDeck";
|
||||
|
||||
/** Films are drawn in this coordinate system, with (0, 0) in the centre; the stage is scaled to fit. */
|
||||
export const STAGE = { width: 1000, height: 700 } as const;
|
||||
|
||||
/** One statement: which text (key in the copy) and while which frames. */
|
||||
export type Caption = {
|
||||
readonly id: string;
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
/** Show the website's button below this statement. */
|
||||
readonly button?: boolean;
|
||||
};
|
||||
|
||||
export const SIDE = 60;
|
||||
const FOOTER_HEIGHT = 130;
|
||||
const MARK_HEIGHT = 76;
|
||||
const GAP = 44;
|
||||
/** Room for two lines and the button below the stage, in px. */
|
||||
const STATEMENT_HEIGHT = 330;
|
||||
const MAX_WIDE_FONT = 88;
|
||||
const IN_FRAMES = 18;
|
||||
const OUT_FRAMES = 10;
|
||||
const RISE_PX = 18;
|
||||
const EDGE_FADE =
|
||||
"linear-gradient(to bottom, transparent, #000 9%, #000 91%, transparent), linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)";
|
||||
|
||||
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
/** Fast start, long soft landing: the easing of a calm presentation. */
|
||||
const settle = Easing.bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/** Two or three lines in the headline type: the first ones light, the last one quieter. */
|
||||
export const Lines: FC<{ lines: readonly string[]; fontSize: number }> = ({ lines, fontSize }) => (
|
||||
<>
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={line}
|
||||
style={{
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 900,
|
||||
fontSize,
|
||||
lineHeight: 1.06,
|
||||
letterSpacing: "-0.04em",
|
||||
wordSpacing: "0.08em",
|
||||
color: index > 0 && index === lines.length - 1 ? DRAW.label : DRAW.fg,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const Statement: FC<{ caption: Caption; first: boolean; fontSize: number; total: number }> = ({
|
||||
caption,
|
||||
first,
|
||||
fontSize,
|
||||
total,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const lines = useCopy().slides[caption.id] ?? [];
|
||||
// The first statement stands on frame 0 (the preview image) and returns at the very end.
|
||||
const back = first ? interpolate(frame, [total - OUT_FRAMES, total - 1], [0, 1], clampOpts) : 0;
|
||||
const arrive = first ? 1 : settle(interpolate(frame, [caption.from, caption.from + IN_FRAMES], [0, 1], clampOpts));
|
||||
const leave = interpolate(frame, [caption.to - OUT_FRAMES, caption.to - 1], [1, 0], clampOpts);
|
||||
const opacity = Math.max(frame < caption.to ? Math.min(arrive, leave) : 0, back);
|
||||
if (opacity === 0) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
textAlign: "center",
|
||||
opacity,
|
||||
transform: `translateY(${(1 - arrive) * RISE_PX}px)`,
|
||||
}}
|
||||
>
|
||||
<Lines lines={lines} fontSize={fontSize} />
|
||||
{caption.button && (
|
||||
<div style={{ marginTop: fontSize * 0.55 }}>
|
||||
<BrandButton label="Get in touch" size={Math.round(fontSize * 0.3)} tone="light" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The ground every film and image stands on: a quiet dark surface with one soft light, the
|
||||
* signature very large and faint behind everything, and a small line with signature and address.
|
||||
*/
|
||||
export const Backdrop: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height, durationInFrames } = useVideoConfig();
|
||||
const wide = width > height;
|
||||
const turn = (frame / durationInFrames) * Math.PI * 2;
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", backgroundColor: DRAW.bg }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `radial-gradient(60% 50% at ${50 + Math.sin(turn) * 10}% ${38 + Math.cos(turn) * 6}%, rgba(${DRAW.glow}, 0.3), transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
<Img
|
||||
src={staticFile("logo-mark.svg")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -width * 0.12,
|
||||
bottom: -width * 0.06,
|
||||
width: width * (wide ? 0.5 : 0.95),
|
||||
opacity: 0.04,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: SIDE,
|
||||
right: SIDE,
|
||||
bottom: 44,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile("logo-mark.svg")} style={{ height: MARK_HEIGHT, opacity: 0.92 }} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 22,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: DRAW.label,
|
||||
}}
|
||||
>
|
||||
www.mintel.me
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What every film shares: the backdrop, the drawing in the middle, and one statement at a time
|
||||
* centred below it. Deliberately little (owner feedback: minimal, calm, one idea at a time).
|
||||
* The film itself is drawn by `children` as SVG content in STAGE coordinates.
|
||||
*/
|
||||
export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }> = ({ captions, children }) => {
|
||||
const { width, height, durationInFrames } = useVideoConfig();
|
||||
const copy = useCopy();
|
||||
const wide = width > height;
|
||||
const scale = Math.min(
|
||||
(width - SIDE * 2) / STAGE.width,
|
||||
(height - FOOTER_HEIGHT - GAP - STATEMENT_HEIGHT - 50) / STAGE.height,
|
||||
);
|
||||
const stageHeight = STAGE.height * scale;
|
||||
const stageTop = (height - FOOTER_HEIGHT - stageHeight - GAP - STATEMENT_HEIGHT) / 2 + 20;
|
||||
const textWidth = width - SIDE * 2;
|
||||
// One size for the whole film: the largest at which its longest line still fits.
|
||||
const fontSize = Math.min(
|
||||
wide ? MAX_WIDE_FONT : Infinity,
|
||||
headlineSize(captions.flatMap((caption) => copy.slides[caption.id] ?? []), textWidth),
|
||||
);
|
||||
|
||||
return (
|
||||
<Backdrop>
|
||||
<svg
|
||||
width={STAGE.width * scale}
|
||||
height={stageHeight}
|
||||
viewBox={`${-STAGE.width / 2} ${-STAGE.height / 2} ${STAGE.width} ${STAGE.height}`}
|
||||
// While the camera moves, the drawing is larger than the stage: it fades out at the stage's edges.
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: (width - STAGE.width * scale) / 2,
|
||||
top: stageTop,
|
||||
maskImage: EDGE_FADE,
|
||||
WebkitMaskImage: EDGE_FADE,
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
<div style={{ position: "absolute", left: SIDE, width: textWidth, top: stageTop + stageHeight + GAP, height: STATEMENT_HEIGHT }}>
|
||||
{captions.map((caption, index) => (
|
||||
<Statement key={caption.id} caption={caption} first={index === 0} fontSize={fontSize} total={durationInFrames} />
|
||||
))}
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { hopAt, type Point } from "../lib/hop";
|
||||
import { FilmStage, type Caption } from "./FilmStage";
|
||||
|
||||
export const ORDER_FILM_FRAMES = 690;
|
||||
|
||||
/**
|
||||
* One continuous shot, told by the camera. Close up, a dot drifts between two squares: ping-pong.
|
||||
* The camera pulls back: it is one of many, a whole office carrying things from tool to tool.
|
||||
* Everything stops, the dots sink onto a line, and the lines carry the work by themselves.
|
||||
* The camera returns to the two squares, so the film loops.
|
||||
*/
|
||||
const T = {
|
||||
reveal: 75,
|
||||
wide: 195,
|
||||
freeze: 310,
|
||||
sink: 345,
|
||||
lines: 365,
|
||||
flow: 440,
|
||||
cta: 560,
|
||||
fold: 630,
|
||||
} as const;
|
||||
|
||||
const CAPTIONS: readonly Caption[] = [
|
||||
{ id: "hook", from: 0, to: T.reveal + 20 },
|
||||
{ id: "reveal", from: T.reveal + 30, to: T.wide + 30 },
|
||||
{ id: "problem", from: T.wide + 30, to: T.freeze + 20 },
|
||||
{ id: "turn", from: T.sink, to: T.flow + 20 },
|
||||
{ id: "calm", from: T.flow + 20, to: T.cta },
|
||||
{ id: "cta", from: T.cta, to: ORDER_FILM_FRAMES, button: true },
|
||||
];
|
||||
|
||||
const COLS = 4;
|
||||
const HERO_ROW = 2;
|
||||
const PITCH = { x: 220, y: 190 } as const;
|
||||
const TILE = 96;
|
||||
const ZOOM_IN = 2.6;
|
||||
const ZOOM_OUT = 0.8;
|
||||
/** Height of a hop as a share of its length. */
|
||||
const ARC = 0.22;
|
||||
const CLOSE_HOP_FRAMES = 30;
|
||||
/** The film starts in mid-flight: the first frame is the preview image. */
|
||||
const CLOSE_PHASE = 15;
|
||||
|
||||
const colX = (col: number): number => (col - (COLS - 1) / 2) * PITCH.x;
|
||||
const rowY = (row: number): number => (row - HERO_ROW) * PITCH.y;
|
||||
const stop = (row: number, col: number): Point => ({ x: colX(col), y: rowY(row) });
|
||||
|
||||
/** Each row has someone carrying things round its four tools, in their own order and pace. */
|
||||
const WALKERS = [
|
||||
{ route: [0, 1, 2, 3], hopFrames: 27, offset: 7 },
|
||||
{ route: [2, 0, 3, 1], hopFrames: 32, offset: 40 },
|
||||
{ route: [2, 3, 0, 1], hopFrames: 29, offset: 0 },
|
||||
{ route: [3, 2, 1, 0], hopFrames: 25, offset: 19 },
|
||||
{ route: [1, 3, 0, 2], hopFrames: 30, offset: 55 },
|
||||
] as const;
|
||||
|
||||
/** 0 while the camera is close, 1 when it has pulled back. */
|
||||
const pulledBack = (frame: number): number =>
|
||||
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, ORDER_FILM_FRAMES)));
|
||||
|
||||
/** Time as the walkers feel it: it runs, then slows to a halt at the freeze. */
|
||||
const walkTime = (frame: number): number => {
|
||||
if (frame < T.freeze) return frame;
|
||||
const u = progress(frame, T.freeze, T.freeze + 30);
|
||||
return T.freeze + 15 * (1 - (1 - u) ** 2);
|
||||
};
|
||||
|
||||
const walkerAt = (row: number, frame: number): Point => {
|
||||
const walker = WALKERS[row];
|
||||
if (!walker) return stop(row, 0);
|
||||
const time = walkTime(frame);
|
||||
// The one in the middle row plays ping-pong between the two inner squares while the camera is close.
|
||||
if (row === HERO_ROW && (time < T.reveal || frame >= T.fold)) {
|
||||
const loopTime = frame >= T.fold ? frame - ORDER_FILM_FRAMES : time;
|
||||
return hopAt(loopTime + CLOSE_PHASE, [stop(row, 1), stop(row, 2)], CLOSE_HOP_FRAMES, ARC * 1.4);
|
||||
}
|
||||
const start = row === HERO_ROW ? T.reveal : -walker.offset;
|
||||
return hopAt(time - start, walker.route.map((col) => stop(row, col)), walker.hopFrames, ARC);
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const OrderDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const back = pulledBack(frame);
|
||||
const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back;
|
||||
/** Everything outside the opening picture fades in as the camera pulls back. */
|
||||
const others = progress(back, 0.05, 0.5);
|
||||
const hair = 2.2 / zoom;
|
||||
// Back in the air for the return to the opening picture.
|
||||
const sink = frame >= T.fold ? 0 : inOut(progress(frame, T.sink, T.sink + 30));
|
||||
const folding = 1 - progress(frame, T.fold, T.fold + 24);
|
||||
|
||||
return (
|
||||
<g transform={`scale(${zoom})`}>
|
||||
{WALKERS.map((_, row) => {
|
||||
const hero = row === HERO_ROW;
|
||||
const y = rowY(row);
|
||||
const away = Math.abs(row - HERO_ROW);
|
||||
|
||||
// The line that replaces the walking.
|
||||
const lineStart = T.lines + away * 12;
|
||||
const drawn = out(progress(frame, lineStart, lineStart + 45)) * folding;
|
||||
const lineEnd = mix(colX(0), colX(COLS - 1), drawn);
|
||||
|
||||
// Work travelling along the line by itself.
|
||||
const sincePulse = frame - (T.flow + away * 10);
|
||||
const pulseT = sincePulse < 0 ? -1 : (sincePulse % 80) / 55;
|
||||
const pulseX = pulseT >= 0 && pulseT <= 1 ? mix(colX(0), colX(COLS - 1), inOut(pulseT)) : null;
|
||||
|
||||
// The walker, and the light it trails.
|
||||
const walkerShown =
|
||||
(hero ? 1 : others) * (1 - progress(frame, lineStart, lineStart + 18)) +
|
||||
(hero ? progress(frame, T.fold + 28, ORDER_FILM_FRAMES - 6) : 0);
|
||||
const place = (at: number): Point => {
|
||||
const point = walkerAt(row, at);
|
||||
return { x: point.x, y: mix(point.y, y, sink) };
|
||||
};
|
||||
const walker = place(frame);
|
||||
const radius = 12 / Math.sqrt(zoom);
|
||||
|
||||
return (
|
||||
<g key={row}>
|
||||
{drawn > 0 && (
|
||||
<path
|
||||
d={`M${colX(0)} ${y}H${lineEnd}`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.3}
|
||||
strokeLinecap="round"
|
||||
opacity={hero ? 1 : others}
|
||||
style={{ filter: `drop-shadow(0 0 ${6 / zoom}px rgba(${DRAW.light}, 0.9))` }}
|
||||
/>
|
||||
)}
|
||||
{Array.from({ length: COLS }, (_unused, col) => {
|
||||
const x = colX(col);
|
||||
const opening = hero && (col === 1 || col === 2);
|
||||
const touched = walkerShown > 0.5 && Math.hypot(walker.x - x, walker.y - y) < TILE * 0.42;
|
||||
const lit = pulseX === null ? 0 : Math.max(0, 1 - Math.abs(pulseX - x) / 110) * folding;
|
||||
return (
|
||||
<g key={col} opacity={opening ? 1 : others}>
|
||||
<rect x={x - TILE / 2} y={y - TILE / 2} width={TILE} height={TILE} rx={TILE * 0.24} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={x - TILE / 2}
|
||||
y={y - TILE / 2}
|
||||
width={TILE}
|
||||
height={TILE}
|
||||
rx={TILE * 0.24}
|
||||
fill={`rgba(248, 250, 252, ${0.1 + lit * 0.22})`}
|
||||
stroke={touched ? DRAW.mark : DRAW.fg}
|
||||
strokeOpacity={touched ? 1 : 0.75 + lit * 0.25}
|
||||
strokeWidth={touched ? hair * 1.6 : hair}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{pulseX !== null && folding > 0 && (
|
||||
<circle
|
||||
cx={pulseX}
|
||||
cy={y}
|
||||
r={radius * 0.8}
|
||||
fill={DRAW.mark}
|
||||
opacity={(hero ? 1 : others) * folding}
|
||||
style={{ filter: `drop-shadow(0 0 ${10 / zoom}px ${DRAW.mark})` }}
|
||||
/>
|
||||
)}
|
||||
{walkerShown > 0 && (
|
||||
<g opacity={Math.min(1, walkerShown)}>
|
||||
{[1, 2, 3, 4, 5].map((k) => {
|
||||
const ghost = place(frame - k * 2.2);
|
||||
return <circle key={k} cx={ghost.x} cy={ghost.y} r={radius * (1 - k * 0.13)} fill={DRAW.mark} opacity={0.3 - k * 0.05} />;
|
||||
})}
|
||||
<circle
|
||||
cx={walker.x}
|
||||
cy={walker.y}
|
||||
r={radius}
|
||||
fill={DRAW.mark}
|
||||
style={{ filter: `drop-shadow(0 0 ${12 / zoom}px ${DRAW.mark})` }}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Automation, as one continuous shot: from ping-pong to tools that talk to each other. */
|
||||
export const OrderFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<OrderDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { FC } from "react";
|
||||
import { Easing, useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a blinking cursor: a beginning. The camera pulls back: it is an empty page, and it has
|
||||
* been empty for months. Then the page builds itself, block by block, and becomes three screens.
|
||||
*/
|
||||
const WINDOW = { width: 640, height: 440 } as const;
|
||||
const BAR_HEIGHT = 50;
|
||||
const CURSOR = { x: -250, y: -110, width: 9, height: 62 } as const;
|
||||
const BLINK_FRAMES = 30;
|
||||
const settle = Easing.bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/** The blocks of a simple page, in the coordinates of a screen of the given size. */
|
||||
const Page: FC<{ width: number; height: number; columns: number; shown: (block: number) => number }> = ({
|
||||
width,
|
||||
height,
|
||||
columns,
|
||||
shown,
|
||||
}) => {
|
||||
const pad = width * 0.07;
|
||||
const inner = width - pad * 2;
|
||||
const top = -height / 2 + pad;
|
||||
const heroHeight = height * 0.42;
|
||||
const cardsTop = top + heroHeight + pad * 0.6;
|
||||
const cardHeight = height / 2 - pad - cardsTop;
|
||||
const gap = pad * 0.5;
|
||||
const cardWidth = (inner - gap * (columns - 1)) / columns;
|
||||
const block = (index: number): { opacity: number; transform: string } => ({
|
||||
opacity: shown(index),
|
||||
transform: `translate(0 ${(1 - shown(index)) * 22})`,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<g {...block(0)}>
|
||||
<rect x={-inner / 2} y={top} width={inner} height={heroHeight} rx={pad * 0.45} fill={DRAW.fg} />
|
||||
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.34} width={inner * 0.5} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} />
|
||||
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.58} width={inner * 0.3} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} opacity={0.45} />
|
||||
</g>
|
||||
{Array.from({ length: columns }, (_, column) => (
|
||||
<rect
|
||||
key={column}
|
||||
{...block(1 + column)}
|
||||
x={-inner / 2 + column * (cardWidth + gap)}
|
||||
y={cardsTop}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
rx={pad * 0.45}
|
||||
fill="rgba(248, 250, 252, 0.22)"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Screen: FC<{ width: number; height: number; hair: number; radius: number }> = ({ width, height, hair, radius }) => (
|
||||
<>
|
||||
<rect x={-width / 2} y={-height / 2} width={width} height={height} rx={radius} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={-width / 2}
|
||||
y={-height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={radius}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const DEVICES = [
|
||||
{ x: 195, y: 20, width: 190, height: 270, columns: 2 },
|
||||
{ x: 380, y: 50, width: 112, height: 210, columns: 1 },
|
||||
] as const;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const WebsiteDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 3, 1, CURSOR);
|
||||
const { hair, others } = camera;
|
||||
const blink = Math.cos((frame / BLINK_FRAMES) * Math.PI * 2) > -0.2 ? 1 : 0.12;
|
||||
const cursorShown = 1 - progress(frame, T.turn, T.turn + 12) + progress(frame, T.fold + 24, T.fold + 44);
|
||||
const built = (block: number): number =>
|
||||
settle(progress(frame, T.resolve - 12 + block * 12, T.resolve + 22 + block * 12)) * untilFold(frame);
|
||||
const split = inOut(progress(frame, T.calm, T.calm + 50)) * (1 - inOut(progress(frame, T.fold, T.fold + 30)));
|
||||
const contentHeight = WINDOW.height - BAR_HEIGHT;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{DEVICES.map((device) => (
|
||||
<g key={device.x} opacity={split} transform={`translate(${mix(-120, device.x, split)} ${device.y})`}>
|
||||
<Screen width={device.width} height={device.height} hair={hair} radius={22} />
|
||||
<Page width={device.width} height={device.height} columns={device.columns} shown={() => 1} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
<g transform={`translate(${mix(0, -170, split)} 0) scale(${mix(1, 0.72, split)})`}>
|
||||
<g opacity={others}>
|
||||
<Screen width={WINDOW.width} height={WINDOW.height} hair={hair} radius={30} />
|
||||
<path d={`M${-WINDOW.width / 2} ${-WINDOW.height / 2 + BAR_HEIGHT}h${WINDOW.width}`} stroke={DRAW.fg} strokeOpacity={0.4} strokeWidth={hair} />
|
||||
{[0, 1, 2].map((n) => (
|
||||
<circle key={n} cx={-WINDOW.width / 2 + 30 + n * 24} cy={-WINDOW.height / 2 + BAR_HEIGHT / 2} r={7} fill={DRAW.fg} opacity={0.3} />
|
||||
))}
|
||||
</g>
|
||||
<g transform={`translate(0 ${BAR_HEIGHT / 2})`}>
|
||||
<Page width={WINDOW.width} height={contentHeight} columns={3} shown={built} />
|
||||
</g>
|
||||
<rect
|
||||
x={CURSOR.x - CURSOR.width / 2}
|
||||
y={CURSOR.y - CURSOR.height / 2}
|
||||
width={CURSOR.width}
|
||||
height={CURSOR.height}
|
||||
rx={CURSOR.width / 2}
|
||||
fill={DRAW.mark}
|
||||
opacity={Math.min(1, cursorShown) * blink}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** A website: from a page that has been empty for months to one that is built and fits every screen. */
|
||||
export const WebsiteFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<WebsiteDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { inOut, progress } from "../illustrations/motion";
|
||||
import type { Point } from "../lib/hop";
|
||||
import type { Caption } from "./FilmStage";
|
||||
|
||||
/**
|
||||
* What the films share: one timeline and one camera move. Every film is a single shot:
|
||||
* close on something small, pull back to see what it really is, stop, resolve, rest,
|
||||
* and return to the opening picture so that it loops.
|
||||
*/
|
||||
export const SHOT_FRAMES = 690;
|
||||
|
||||
export const T = {
|
||||
/** The camera starts to pull back. */
|
||||
reveal: 75,
|
||||
/** The whole picture is in view. */
|
||||
wide: 195,
|
||||
/** Everything slows to a halt. */
|
||||
freeze: 310,
|
||||
/** The turn begins. */
|
||||
turn: 345,
|
||||
/** The problem resolves. */
|
||||
resolve: 365,
|
||||
/** The calm result. */
|
||||
calm: 440,
|
||||
cta: 560,
|
||||
/** The picture folds back into its opening. */
|
||||
fold: 630,
|
||||
} as const;
|
||||
|
||||
/** The six statements of a film, in order: hook, reveal, problem, turn, calm, call to action. */
|
||||
export const CAPTIONS: readonly Caption[] = [
|
||||
{ id: "hook", from: 0, to: T.reveal + 20 },
|
||||
{ id: "reveal", from: T.reveal + 30, to: T.wide + 30 },
|
||||
{ id: "problem", from: T.wide + 30, to: T.freeze + 20 },
|
||||
{ id: "turn", from: T.turn, to: T.calm + 20 },
|
||||
{ id: "calm", from: T.calm + 20, to: T.cta },
|
||||
{ id: "cta", from: T.cta, to: SHOT_FRAMES, button: true },
|
||||
];
|
||||
|
||||
/** 0 while the camera is close, 1 when it has pulled back. */
|
||||
export const pulledBack = (frame: number): number =>
|
||||
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, SHOT_FRAMES)));
|
||||
|
||||
/** 1 until the fold, then quickly 0: for everything that has to be gone before the loop closes. */
|
||||
export const untilFold = (frame: number): number => 1 - progress(frame, T.fold, T.fold + 24);
|
||||
|
||||
export type Camera = {
|
||||
readonly zoom: number;
|
||||
/** Opacity of everything outside the opening picture: it fades in as the camera pulls back. */
|
||||
readonly others: number;
|
||||
/** Width of a hairline that looks the same at every zoom. */
|
||||
readonly hair: number;
|
||||
/** SVG transform for the group that holds the drawing. */
|
||||
readonly transform: string;
|
||||
};
|
||||
|
||||
/** The camera at a frame: close on `focus` at `zoomIn`, then the whole stage at `zoomOut`. */
|
||||
export const cameraAt = (frame: number, zoomIn: number, zoomOut: number, focus: Point): Camera => {
|
||||
const back = pulledBack(frame);
|
||||
const zoom = zoomIn * (zoomOut / zoomIn) ** back;
|
||||
return {
|
||||
zoom,
|
||||
others: progress(back, 0.05, 0.5),
|
||||
hair: 2.2 / zoom,
|
||||
transform: `scale(${zoom}) translate(${-focus.x * (1 - back)} ${-focus.y * (1 - back)})`,
|
||||
};
|
||||
};
|
||||
|
||||
/** A soft light around a shape, the same size on screen at every zoom. */
|
||||
export const glow = (color: string, px: number, zoom: number): { filter: string } => ({
|
||||
filter: `drop-shadow(0 0 ${px / zoom}px ${color})`,
|
||||
});
|
||||
|
||||
/** The quiet look of every surface: a light tint and a fine light outline. */
|
||||
export const SURFACE = { fill: "rgba(248, 250, 252, 0.1)", strokeOpacity: 0.75 } as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Easing, interpolate } from "remotion";
|
||||
|
||||
/** Small motion helpers shared by all illustrations. All of them are pure functions of the frame. */
|
||||
|
||||
const CLAMP = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
|
||||
/** 0 before `from`, 1 after `to`, linear in between. */
|
||||
export const progress = (frame: number, from: number, to: number): number =>
|
||||
interpolate(frame, [from, to], [0, 1], CLAMP);
|
||||
|
||||
export const mix = (from: number, to: number, t: number): number => from + (to - from) * t;
|
||||
|
||||
export const inOut = Easing.inOut(Easing.cubic);
|
||||
export const out = Easing.out(Easing.cubic);
|
||||
/** Overshoots slightly, for things that pop in. */
|
||||
export const pop = Easing.out(Easing.back(2.2));
|
||||
|
||||
/** `pop` between two frames: 0 before, 1 after. */
|
||||
export const popAt = (frame: number, from: number, length = 8): number =>
|
||||
pop(progress(frame, from, from + length));
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { useT } from "../content/CopyContext";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
|
||||
/** Shared drawing parts for illustrations. Everything is SVG, drawn in stage coordinates. */
|
||||
|
||||
export type IconName =
|
||||
| "cart"
|
||||
| "invoice"
|
||||
| "people"
|
||||
| "mail"
|
||||
| "check"
|
||||
| "book"
|
||||
| "form"
|
||||
| "chat"
|
||||
| "bell"
|
||||
| "tasks";
|
||||
|
||||
const ICON_PATHS: Readonly<Record<IconName, ReactNode>> = {
|
||||
cart: (
|
||||
<>
|
||||
<path d="M3 4h2l2.4 10.2a1 1 0 0 0 1 .8h8.9a1 1 0 0 0 1-.8L20 7H6.2" />
|
||||
<circle cx="9.5" cy="19" r="1.3" />
|
||||
<circle cx="17" cy="19" r="1.3" />
|
||||
</>
|
||||
),
|
||||
invoice: (
|
||||
<>
|
||||
<path d="M7 3h7l4 4v14H7z" />
|
||||
<path d="M14 3v4h4" />
|
||||
<path d="M10 12h5" />
|
||||
<path d="M10 16h5" />
|
||||
</>
|
||||
),
|
||||
people: (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="3.5" />
|
||||
<path d="M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
|
||||
</>
|
||||
),
|
||||
mail: (
|
||||
<>
|
||||
<rect x="3" y="6" width="18" height="13" rx="2" />
|
||||
<path d="m3.5 7.5 8.5 6 8.5-6" />
|
||||
</>
|
||||
),
|
||||
check: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8 12.5 3 3 5-6" />
|
||||
</>
|
||||
),
|
||||
book: (
|
||||
<>
|
||||
<path d="M5 4h11a2 2 0 0 1 2 2v14H7a2 2 0 0 1-2-2z" />
|
||||
<path d="M5 18a2 2 0 0 1 2-2h11" />
|
||||
<path d="M9 8h6" />
|
||||
</>
|
||||
),
|
||||
form: (
|
||||
<>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M8 9h8" />
|
||||
<path d="M8 13h8" />
|
||||
<path d="M8 17h4" />
|
||||
</>
|
||||
),
|
||||
chat: <path d="M4 5h16v11H10l-4 4v-4H4z" />,
|
||||
bell: (
|
||||
<>
|
||||
<path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z" />
|
||||
<path d="M10 20a2 2 0 0 0 4 0" />
|
||||
</>
|
||||
),
|
||||
tasks: (
|
||||
<>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="m8 12 3 3 5-6" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/** A line icon from a 24 x 24 grid, centred on (cx, cy). */
|
||||
export const Icon: FC<{ name: IconName; cx: number; cy: number; size: number; color: string }> = ({
|
||||
name,
|
||||
cx,
|
||||
cy,
|
||||
size,
|
||||
color,
|
||||
}) => (
|
||||
<g
|
||||
transform={`translate(${cx - size / 2} ${cy - size / 2}) scale(${size / 24})`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{ICON_PATHS[name]}
|
||||
</g>
|
||||
);
|
||||
|
||||
export const TILE_SIZE = 150;
|
||||
|
||||
/** One tool: a dark rounded tile with an icon and a mono label below. */
|
||||
export const Tile: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
/** Coloured outline, e.g. while someone works in this tool. */
|
||||
ring?: string;
|
||||
scale?: number;
|
||||
opacity?: number;
|
||||
}> = ({ x, y, icon, label, ring, scale = 1, opacity = 1 }) => {
|
||||
const centre = TILE_SIZE / 2;
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`} opacity={opacity}>
|
||||
<g transform={`translate(${centre} ${centre}) scale(${scale}) translate(${-centre} ${-centre})`}>
|
||||
{ring && (
|
||||
<rect
|
||||
x={-10}
|
||||
y={-10}
|
||||
width={TILE_SIZE + 20}
|
||||
height={TILE_SIZE + 20}
|
||||
rx={42}
|
||||
fill="none"
|
||||
stroke={ring}
|
||||
strokeWidth={4}
|
||||
style={{ filter: `drop-shadow(0 0 12px ${ring})` }}
|
||||
/>
|
||||
)}
|
||||
<rect width={TILE_SIZE} height={TILE_SIZE} rx={34} fill={DRAW.tint} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<Icon name={icon} cx={centre} cy={centre} size={78} color={DRAW.fg} />
|
||||
</g>
|
||||
<text
|
||||
x={centre}
|
||||
y={TILE_SIZE + 44}
|
||||
textAnchor="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={19}
|
||||
letterSpacing="0.16em"
|
||||
fill={DRAW.label}
|
||||
>
|
||||
{useT()(label).toUpperCase()}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Round badge with a tick, centred on (cx, cy). */
|
||||
export const DoneBadge: FC<{ cx: number; cy: number; r: number; scale?: number }> = ({ cx, cy, r, scale = 1 }) => (
|
||||
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
|
||||
<circle r={r} fill={COLOR.done} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${COLOR.done})` }} />
|
||||
<path
|
||||
d={`M${-r * 0.42} ${r * 0.04} ${-r * 0.1} ${r * 0.36} ${r * 0.44} ${-r * 0.3}`}
|
||||
fill="none"
|
||||
stroke={DRAW.bg}
|
||||
strokeWidth={r * 0.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Round amber badge with one character: something needs attention. */
|
||||
export const WarnBadge: FC<{ cx: number; cy: number; r: number; scale?: number; char?: string }> = ({
|
||||
cx,
|
||||
cy,
|
||||
r,
|
||||
scale = 1,
|
||||
char = "!",
|
||||
}) => (
|
||||
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
|
||||
<circle r={r} fill={DRAW.mark} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${DRAW.mark})` }} />
|
||||
<text y={r * 0.42} textAnchor="middle" fontFamily={FONT.sans} fontWeight={900} fontSize={r * 1.2} fill={DRAW.bg}>
|
||||
{char}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Mono caption inside a drawing. */
|
||||
export const Label: FC<{ x: number; y: number; text: string; anchor?: "start" | "middle" | "end"; color?: string; size?: number }> = ({
|
||||
x,
|
||||
y,
|
||||
text,
|
||||
anchor = "middle",
|
||||
color = DRAW.label,
|
||||
size = 19,
|
||||
}) => (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor={anchor}
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={size}
|
||||
letterSpacing="0.16em"
|
||||
fill={color}
|
||||
>
|
||||
{useT()(text).toUpperCase()}
|
||||
</text>
|
||||
);
|
||||
|
||||
/** Mouse pointer with its tip at (x, y): stands for a person doing the step by hand. */
|
||||
export const Cursor: FC<{ x: number; y: number; scale?: number }> = ({ x, y, scale = 1.5 }) => (
|
||||
<path
|
||||
transform={`translate(${x} ${y}) scale(${scale})`}
|
||||
d="M0 0 0 30 8 23 14 36 19 34 13 21 24 21Z"
|
||||
fill={DRAW.fg}
|
||||
stroke={DRAW.bg}
|
||||
strokeWidth={2.5}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
|
||||
/** A piece of data: dark pill with binary digits. */
|
||||
export const DataChip: FC<{ x: number; y: number; width?: number; digits?: string; color?: string }> = ({
|
||||
x,
|
||||
y,
|
||||
width = 150,
|
||||
digits = "0110",
|
||||
color = DRAW.fg,
|
||||
}) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={44} rx={22} fill={color} />
|
||||
<text
|
||||
x={width / 2}
|
||||
y={30}
|
||||
textAnchor="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={700}
|
||||
fontSize={22}
|
||||
letterSpacing="0.2em"
|
||||
fill={DRAW.bg}
|
||||
>
|
||||
{digits}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** An app window: white card with a title bar. Children are drawn in its coordinates. */
|
||||
export const AppWindow: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}> = ({ x, y, width, height, title, children }) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={height} rx={30} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<path d={`M0 64H${width}`} stroke={DRAW.rim} strokeWidth={2} />
|
||||
{[30, 56, 82].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={33} r={8} fill={DRAW.soft} />
|
||||
))}
|
||||
<text
|
||||
x={width - 28}
|
||||
y={41}
|
||||
textAnchor="end"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={19}
|
||||
letterSpacing="0.16em"
|
||||
fill={DRAW.label}
|
||||
>
|
||||
{useT()(title).toUpperCase()}
|
||||
</text>
|
||||
{children}
|
||||
</g>
|
||||
);
|
||||
|
||||
/** A box for one day of a week or a countdown, centred on cx. */
|
||||
export const DayBox: FC<{
|
||||
cx: number;
|
||||
y: number;
|
||||
width: number;
|
||||
label: string;
|
||||
filled: boolean;
|
||||
scale?: number;
|
||||
}> = ({ cx, y, width, label, filled, scale = 1 }) => (
|
||||
<g transform={`translate(${cx} ${y + 55}) scale(${scale})`}>
|
||||
<rect
|
||||
x={-width / 2}
|
||||
y={-55}
|
||||
width={width}
|
||||
height={110}
|
||||
rx={26}
|
||||
fill={filled ? DRAW.fg : DRAW.glass}
|
||||
stroke={filled ? DRAW.fg : DRAW.rim}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<Label x={0} y={8} text={label} color={filled ? DRAW.bg : DRAW.fg} size={24} />
|
||||
</g>
|
||||
);
|
||||
|
||||
/** A sheet of paper. Children are drawn in its coordinates. */
|
||||
export const Page: FC<{ x: number; y: number; width: number; height: number; children?: ReactNode }> = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
}) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={height} rx={24} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
{children}
|
||||
</g>
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { STAGE_WIDTH } from "../slides/SlideDeck";
|
||||
import { inOut, mix, out, popAt, progress } from "./motion";
|
||||
import { AppWindow, Cursor, DataChip, DoneBadge, Icon, TILE_SIZE, Tile, type IconName } from "./parts";
|
||||
|
||||
/** Scenes that several videos share. Each is one slide's illustration, drawn from its frame 0. */
|
||||
|
||||
export const SCENE_HEIGHT = 520;
|
||||
export const CTA_SCENE_HEIGHT = 300;
|
||||
|
||||
export type Tool = { readonly icon: IconName; readonly label: string };
|
||||
/** Scenes with a row of tools expect exactly four of them. */
|
||||
export type Tools = readonly [Tool, Tool, Tool, Tool];
|
||||
|
||||
const TOOL_COUNT = 4;
|
||||
const TOOL_GAP = (STAGE_WIDTH - TOOL_COUNT * TILE_SIZE) / (TOOL_COUNT - 1);
|
||||
export const toolX = (index: number): number => index * (TILE_SIZE + TOOL_GAP);
|
||||
export const ROW_Y = 185;
|
||||
|
||||
export const Canvas: FC<{ height?: number; children: ReactNode }> = ({ height = SCENE_HEIGHT, children }) => (
|
||||
<svg width={STAGE_WIDTH} height={height} viewBox={`0 0 ${STAGE_WIDTH} ${height}`} style={{ overflow: "visible" }}>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CHIP_DIGITS = ["0110", "1001", "1101"] as const;
|
||||
|
||||
/** Values carried by hand from one window into another, again and again. */
|
||||
export const CarryScene: FC<{ fromTitle: string; toTitle: string }> = ({ fromTitle, toTitle }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const perChip = 25;
|
||||
const rowY = (row: number): number => 166 + row * 92;
|
||||
const current = Math.min(CHIP_DIGITS.length - 1, Math.floor(frame / perChip));
|
||||
const chipAt = (row: number): { x: number; y: number } => {
|
||||
const t = row < current ? 1 : row > current ? 0 : inOut(progress(frame, row * perChip + 4, row * perChip + 20));
|
||||
return { x: mix(34, 472 + 38, t), y: rowY(row) - Math.sin(Math.PI * t) * 46 };
|
||||
};
|
||||
const held = chipAt(current);
|
||||
return (
|
||||
<Canvas>
|
||||
<AppWindow x={0} y={60} width={400} height={400} title={fromTitle}>
|
||||
{CHIP_DIGITS.map((digits, row) => (
|
||||
<rect key={digits} x={200} y={rowY(row) - 46} width={160} height={16} rx={8} fill={DRAW.soft} />
|
||||
))}
|
||||
</AppWindow>
|
||||
<AppWindow x={472} y={60} width={400} height={400} title={toTitle}>
|
||||
{CHIP_DIGITS.map((digits, row) => (
|
||||
<rect
|
||||
key={digits}
|
||||
x={30}
|
||||
y={rowY(row) - 66}
|
||||
width={340}
|
||||
height={56}
|
||||
rx={28}
|
||||
fill="none"
|
||||
stroke={DRAW.rim}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="10 10"
|
||||
/>
|
||||
))}
|
||||
</AppWindow>
|
||||
{CHIP_DIGITS.map((digits, row) => {
|
||||
const chip = chipAt(row);
|
||||
return <DataChip key={digits} x={chip.x} y={chip.y} digits={digits} />;
|
||||
})}
|
||||
<Cursor x={held.x + 112} y={held.y + 26} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** Four separate tools, and a person hopping between them with the data. */
|
||||
export const HopScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const perTool = 15;
|
||||
const active = Math.min(TOOL_COUNT - 1, Math.floor(frame / perTool));
|
||||
const hop = active === 0 ? 1 : inOut(progress(frame, active * perTool, active * perTool + 9));
|
||||
const x = mix(toolX(Math.max(0, active - 1)), toolX(active), hop) + 78;
|
||||
const y = ROW_Y + TILE_SIZE + 70 - Math.sin(Math.PI * hop) * 40;
|
||||
return (
|
||||
<Canvas>
|
||||
{tools.map((tool, index) => (
|
||||
<Tile key={tool.label} x={toolX(index)} y={ROW_Y} {...tool} ring={index === active ? COLOR.active : undefined} />
|
||||
))}
|
||||
<DataChip x={x + 34} y={y + 26} width={110} />
|
||||
<Cursor x={x} y={y} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const CARD = { width: 300, height: 66, step: 46, count: 9, x: 60, base: 440 } as const;
|
||||
|
||||
/** One item of work waiting to be handled. */
|
||||
export const WorkCard: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
icon: IconName;
|
||||
tilt?: number;
|
||||
opacity?: number;
|
||||
done?: boolean;
|
||||
width?: number;
|
||||
}> = ({ x, y, icon, tilt = 0, opacity = 1, done = false, width = CARD.width }) => (
|
||||
<g transform={`translate(${x} ${y}) rotate(${tilt} ${width / 2} ${CARD.height / 2})`} opacity={opacity}>
|
||||
<rect width={width} height={CARD.height} rx={20} fill={DRAW.glass} stroke={done ? COLOR.done : DRAW.fg} strokeWidth={2.5} />
|
||||
<Icon name={icon} cx={40} cy={CARD.height / 2} size={34} color={done ? COLOR.done : DRAW.fg} />
|
||||
<rect x={76} y={CARD.height / 2 - 8} width={width / 2} height={16} rx={8} fill={DRAW.soft} />
|
||||
</g>
|
||||
);
|
||||
|
||||
const cardTilt = (index: number): number => (index % 2 === 0 ? -2.5 : 2);
|
||||
const cardY = (index: number): number => CARD.base - index * CARD.step;
|
||||
|
||||
/** Work arrives faster than one person can handle it. */
|
||||
export const PileScene: FC<{ icon: IconName }> = ({ icon }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const every = 5;
|
||||
return (
|
||||
<Canvas>
|
||||
{Array.from({ length: CARD.count }, (_, index) => {
|
||||
const fall = out(progress(frame, index * every, index * every + 8));
|
||||
return fall === 0 ? null : (
|
||||
<WorkCard key={index} x={CARD.x} y={mix(-120, cardY(index), fall)} icon={icon} tilt={cardTilt(index)} opacity={fall} />
|
||||
);
|
||||
})}
|
||||
<Tile x={600} y={ROW_Y} icon="people" label="One person" ring={COLOR.active} />
|
||||
<Cursor x={690 + Math.cos(frame / 5) * 14} y={ROW_Y + TILE_SIZE / 2 + 40 + Math.sin(frame / 5) * 14} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** The pile from PileScene clears itself. */
|
||||
export const ClearedScene: FC<{ icon: IconName }> = ({ icon }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const every = 4;
|
||||
return (
|
||||
<Canvas>
|
||||
<DoneBadge cx={STAGE_WIDTH / 2} cy={250} r={120} scale={popAt(frame, 36, 14)} />
|
||||
{Array.from({ length: CARD.count }, (_, index) => {
|
||||
const order = CARD.count - 1 - index;
|
||||
const leave = inOut(progress(frame, 4 + order * every, 14 + order * every));
|
||||
return leave === 1 ? null : (
|
||||
<WorkCard
|
||||
key={index}
|
||||
x={CARD.x + leave * 620}
|
||||
y={cardY(index)}
|
||||
icon={icon}
|
||||
tilt={cardTilt(index)}
|
||||
opacity={1 - leave}
|
||||
done={leave > 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const PACKET_TRAVEL_FRAMES = 27;
|
||||
const PACKET_EVERY_FRAMES = 9;
|
||||
|
||||
/** Four tools in a row, connected, with work travelling through on its own. */
|
||||
export const FlowRow: FC<{
|
||||
tools: Tools;
|
||||
y: number;
|
||||
/** 0..1: how far the link after tool `index` is drawn. */
|
||||
connected: (index: number) => number;
|
||||
/** Frame at which work starts to flow, or null while the tools are only being connected. */
|
||||
flowFrom: number | null;
|
||||
}> = ({ tools, y, connected, flowFrom }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const centreY = y + TILE_SIZE / 2;
|
||||
const firstCentre = toolX(0) + TILE_SIZE / 2;
|
||||
const lastCentre = toolX(TOOL_COUNT - 1) + TILE_SIZE / 2;
|
||||
const flowing = flowFrom === null ? -1 : frame - flowFrom;
|
||||
const packets =
|
||||
flowing < 0 ? [] : Array.from({ length: Math.floor(flowing / PACKET_EVERY_FRAMES) + 1 }, (_, n) => n);
|
||||
const passFrame = (index: number): number => (PACKET_TRAVEL_FRAMES * index) / (TOOL_COUNT - 1);
|
||||
return (
|
||||
<>
|
||||
{tools.slice(1).map((tool, index) => {
|
||||
const from = toolX(index) + TILE_SIZE;
|
||||
const drawn = connected(index);
|
||||
const tip = mix(from, toolX(index + 1), drawn);
|
||||
return drawn === 0 ? null : (
|
||||
<g key={tool.label}>
|
||||
<path d={`M${from} ${centreY}H${tip}`} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<circle cx={tip} cy={centreY} r={6} fill={DRAW.fg} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{packets.map((n) => {
|
||||
const t = (flowing - n * PACKET_EVERY_FRAMES) / PACKET_TRAVEL_FRAMES;
|
||||
return t > 1 ? null : (
|
||||
<circle key={n} cx={mix(firstCentre, lastCentre, t)} cy={centreY} r={11}
|
||||
fill={DRAW.mark}
|
||||
stroke={DRAW.mark}
|
||||
strokeOpacity={0.28}
|
||||
strokeWidth={16}
|
||||
style={{ filter: `drop-shadow(0 0 10px ${DRAW.mark})` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tools.map((tool, index) => {
|
||||
const since = flowing - passFrame(index);
|
||||
const pulse = since < 0 ? 0 : 1 - (since % PACKET_EVERY_FRAMES) / PACKET_EVERY_FRAMES;
|
||||
return (
|
||||
<g key={tool.label}>
|
||||
<Tile x={toolX(index)} y={y} {...tool} scale={1 + pulse * 0.05} />
|
||||
{since >= 0 && (
|
||||
<DoneBadge cx={toolX(index) + TILE_SIZE - 8} cy={y + 8} r={26} scale={popAt(since, 0) * (1 + pulse * 0.12)} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** The tools get connected, one link after the other. */
|
||||
export const ConnectScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<Canvas>
|
||||
<FlowRow
|
||||
tools={tools}
|
||||
y={ROW_Y}
|
||||
connected={(index) => inOut(progress(frame, 8 + index * 12, 20 + index * 12))}
|
||||
flowFrom={null}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const RUNS_FLOW_FROM = 4;
|
||||
const FINISHED_SLOTS = 9;
|
||||
|
||||
/** Work runs through all four tools by itself; finished items collect below. */
|
||||
export const RunsScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const firstArrival = RUNS_FLOW_FROM + PACKET_TRAVEL_FRAMES;
|
||||
return (
|
||||
<Canvas>
|
||||
<FlowRow tools={tools} y={110} connected={() => 1} flowFrom={RUNS_FLOW_FROM} />
|
||||
{Array.from({ length: FINISHED_SLOTS }, (_, n) => {
|
||||
const arrival = firstArrival + n * PACKET_EVERY_FRAMES;
|
||||
return frame < arrival ? null : (
|
||||
<DoneBadge key={n} cx={36 + n * 100} cy={420} r={30} scale={popAt(frame, arrival)} />
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** The connected tools, still running: sits above the call to action. */
|
||||
export const RunningScene: FC<{ tools: Tools }> = ({ tools }) => (
|
||||
<Canvas height={CTA_SCENE_HEIGHT}>
|
||||
<FlowRow tools={tools} y={64} connected={() => 1} flowFrom={-60} />
|
||||
</Canvas>
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FC } from "react";
|
||||
import { useVideoConfig } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { CopyProvider } from "../content/CopyContext";
|
||||
import { IMAGES, type ImageCopy, type ImageId } from "../content/images";
|
||||
import type { Lang } from "../content/videos";
|
||||
import { Backdrop, FilmStage, Lines, SIDE, type Caption } from "../films/FilmStage";
|
||||
import { VIDEOS } from "../registry";
|
||||
import { headlineSize } from "../slides/SlideDeck";
|
||||
|
||||
const MAX_CARD_FONT = 132;
|
||||
/** The statement of a poster stands for good: an image has one frame. */
|
||||
const ALWAYS: readonly Caption[] = [{ id: "poster", from: 0, to: 1_000_000 }];
|
||||
|
||||
/**
|
||||
* One picture of a film, with the film's idea in two lines below it. Which moment is shown is
|
||||
* decided by the frame the composition is rendered at (see Root and the export script).
|
||||
*/
|
||||
const Poster: FC<{ video: string; copy: ImageCopy; lang: Lang }> = ({ video, copy, lang }) => {
|
||||
const entry = VIDEOS.find((candidate) => candidate.id === video);
|
||||
if (!entry || !("Drawing" in entry)) throw new Error(`No film with a drawing is registered as "${video}"`);
|
||||
return (
|
||||
<CopyProvider value={{ lang, copy: { title: copy.title, slides: { poster: copy.lines }, post: copy.post } }}>
|
||||
<FilmStage captions={ALWAYS}>
|
||||
<entry.Drawing />
|
||||
</FilmStage>
|
||||
</CopyProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/** A question clients ask, small, and its answer in very large type. Nothing else. */
|
||||
const Card: FC<{ copy: ImageCopy }> = ({ copy }) => {
|
||||
const { width } = useVideoConfig();
|
||||
const fontSize = Math.min(MAX_CARD_FONT, headlineSize(copy.lines, width - SIDE * 2));
|
||||
return (
|
||||
<Backdrop>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: `0 ${SIDE}px 150px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 22,
|
||||
backgroundColor: DRAW.mark,
|
||||
boxShadow: `0 0 28px 6px ${DRAW.mark}`,
|
||||
marginBottom: fontSize * 0.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: Math.round(fontSize * 0.28),
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: DRAW.mark,
|
||||
marginBottom: fontSize * 0.45,
|
||||
}}
|
||||
>
|
||||
{copy.label}
|
||||
</div>
|
||||
<Lines lines={copy.lines} fontSize={fontSize} />
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
|
||||
/** One image post in one language: a poster of a film, or a card. */
|
||||
export const ImagePost: FC<{ id: ImageId; lang: Lang }> = ({ id, lang }) => {
|
||||
const image = IMAGES[id];
|
||||
const copy = image.copy[lang];
|
||||
return image.poster ? (
|
||||
<Poster video={image.poster.video} copy={copy} lang={lang} />
|
||||
) : (
|
||||
<Card copy={copy} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { Root } from "./Root";
|
||||
|
||||
registerRoot(Root);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { outlinePoint, sparkAt, type Box } from "./aura";
|
||||
|
||||
const BOX: Box = { width: 400, height: 200 };
|
||||
const LOOP = 300;
|
||||
|
||||
describe("outlinePoint", () => {
|
||||
it("starts at the top left corner and walks clockwise", () => {
|
||||
expect(outlinePoint(0, BOX)).toEqual({ x: 0, y: 0, nx: 0, ny: -1 });
|
||||
// Perimeter is 1200: a quarter of it is 300 px along the top edge.
|
||||
expect(outlinePoint(0.25, BOX)).toEqual({ x: 300, y: 0, nx: 0, ny: -1 });
|
||||
});
|
||||
|
||||
it("gives the right edge an outward normal pointing right", () => {
|
||||
// 450 px: 400 along the top, 50 down the right edge.
|
||||
expect(outlinePoint(450 / 1200, BOX)).toEqual({ x: 400, y: expect.closeTo(50, 6), nx: 1, ny: 0 });
|
||||
});
|
||||
|
||||
it("walks the bottom edge from right to left and the left edge upwards", () => {
|
||||
expect(outlinePoint(700 / 1200, BOX)).toEqual({ x: expect.closeTo(300, 6), y: 200, nx: 0, ny: 1 });
|
||||
expect(outlinePoint(1100 / 1200, BOX)).toEqual({ x: 0, y: expect.closeTo(100, 6), nx: -1, ny: 0 });
|
||||
});
|
||||
|
||||
it("wraps positions outside 0..1", () => {
|
||||
expect(outlinePoint(1.25, BOX).x).toBeCloseTo(300, 6);
|
||||
expect(outlinePoint(-0.75, BOX).x).toBeCloseTo(300, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sparkAt", () => {
|
||||
it("is deterministic", () => {
|
||||
expect(sparkAt(7, 120, LOOP, BOX)).toEqual(sparkAt(7, 120, LOOP, BOX));
|
||||
});
|
||||
|
||||
it("is the same on the frame after the loop ends as on frame 0", () => {
|
||||
for (const index of [0, 1, 2, 17, 40]) {
|
||||
expect(sparkAt(index, LOOP, LOOP, BOX)).toEqual(sparkAt(index, 0, LOOP, BOX));
|
||||
}
|
||||
});
|
||||
|
||||
it("hides a spark outside its life and shows it inside", () => {
|
||||
const visibleFrames = Array.from({ length: LOOP }, (_, frame) => sparkAt(3, frame, LOOP, BOX)).filter(
|
||||
(spark) => spark !== null,
|
||||
);
|
||||
expect(visibleFrames.length).toBeGreaterThan(0);
|
||||
expect(visibleFrames.length).toBeLessThan(LOOP);
|
||||
});
|
||||
|
||||
it("fades in and out: opacity stays within 0..1 and digits are 0 or 1", () => {
|
||||
for (let frame = 0; frame < LOOP; frame++) {
|
||||
const spark = sparkAt(5, frame, LOOP, BOX);
|
||||
if (spark === null) continue;
|
||||
expect(spark.opacity).toBeGreaterThanOrEqual(0);
|
||||
expect(spark.opacity).toBeLessThanOrEqual(1);
|
||||
expect(["0", "1"]).toContain(spark.digit);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves away from the outline as it ages", () => {
|
||||
const frames = Array.from({ length: LOOP }, (_, frame) => sparkAt(9, frame, LOOP, BOX));
|
||||
const first = frames.findIndex((spark) => spark !== null);
|
||||
const distance = (frame: number): number => {
|
||||
const spark = frames[frame];
|
||||
if (!spark) throw new Error("spark expected");
|
||||
const outsideX = Math.max(0, -spark.x, spark.x - BOX.width);
|
||||
const outsideY = Math.max(0, -spark.y, spark.y - BOX.height);
|
||||
return Math.hypot(outsideX, outsideY);
|
||||
};
|
||||
// Frame `first` can be mid-life if the spark was born before the loop wrapped; pick a run of two frames.
|
||||
const young = frames.findIndex((spark, frame) => spark !== null && frames[frame + 6] !== null && frame >= first);
|
||||
expect(distance(young + 6)).toBeGreaterThan(distance(young));
|
||||
});
|
||||
|
||||
it("gives different sparks different places", () => {
|
||||
const places = new Set(
|
||||
Array.from({ length: 60 }, (_, index) => sparkAt(index, 150, LOOP, BOX))
|
||||
.filter((spark) => spark !== null)
|
||||
.map((spark) => `${Math.round(spark.x)}:${Math.round(spark.y)}`),
|
||||
);
|
||||
expect(places.size).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The website's BitAura (energy along the outline of an element, digits drifting off it), as pure
|
||||
* functions of the frame number. The website simulates it with random particles; here every spark's
|
||||
* whole life is computed from its index, so each frame can be rendered on its own and the motion
|
||||
* repeats exactly when a video loops.
|
||||
*/
|
||||
|
||||
export type Box = { readonly width: number; readonly height: number };
|
||||
|
||||
export type OutlinePoint = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
/** Outward normal of the edge the point lies on. */
|
||||
readonly nx: number;
|
||||
readonly ny: number;
|
||||
};
|
||||
|
||||
export type Spark = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly opacity: number;
|
||||
readonly digit: "0" | "1";
|
||||
readonly large: boolean;
|
||||
};
|
||||
|
||||
/** How often the light travels round the element during one loop of the video. */
|
||||
export const AURA_LAPS = 2;
|
||||
|
||||
const MIN_LIFE_FRAMES = 50;
|
||||
const EXTRA_LIFE_FRAMES = 60;
|
||||
const MIN_SPEED = 0.35;
|
||||
const EXTRA_SPEED = 0.75;
|
||||
const MAX_SLIDE = 0.3;
|
||||
const SCATTER = 0.14;
|
||||
const LARGE_SHARE = 0.22;
|
||||
const FLIP_EVERY_FRAMES = 33;
|
||||
|
||||
const fraction = (value: number): number => value - Math.floor(value);
|
||||
|
||||
/** Point on the outline of a box, walking clockwise from the top left corner. `at` wraps at 1. */
|
||||
export const outlinePoint = (at: number, { width, height }: Box): OutlinePoint => {
|
||||
const distance = fraction(at) * 2 * (width + height);
|
||||
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
|
||||
if (distance < width + height) return { x: width, y: distance - width, nx: 1, ny: 0 };
|
||||
if (distance < 2 * width + height) return { x: 2 * width + height - distance, y: height, nx: 0, ny: 1 };
|
||||
return { x: 0, y: 2 * (width + height) - distance, nx: -1, ny: 0 };
|
||||
};
|
||||
|
||||
/** A stable pseudo-random number in 0..1 for a spark and one of its properties. */
|
||||
const random = (index: number, property: number): number => {
|
||||
const hash = Math.imul(index + 1, 2654435761) ^ Math.imul(property + 1, 2246822519);
|
||||
return ((Math.imul(hash ^ (hash >>> 15), 3266489917) >>> 0) % 100000) / 100000;
|
||||
};
|
||||
|
||||
/** Where the light is on the outline at a frame: it circles AURA_LAPS times per loop. */
|
||||
export const cometAt = (frame: number, loopFrames: number): number =>
|
||||
fraction((frame / loopFrames) * AURA_LAPS);
|
||||
|
||||
/**
|
||||
* Spark number `index` at `frame`, or null while it is not alive. It is born where the light is at
|
||||
* that moment, drifts away from the edge and fades. `loopFrames` is the length of the video.
|
||||
*/
|
||||
export const sparkAt = (index: number, frame: number, loopFrames: number, box: Box): Spark | null => {
|
||||
const birth = Math.floor(random(index, 0) * loopFrames);
|
||||
const life = Math.min(loopFrames, MIN_LIFE_FRAMES + random(index, 1) * EXTRA_LIFE_FRAMES);
|
||||
const age = (((frame - birth) % loopFrames) + loopFrames) % loopFrames;
|
||||
if (age >= life) return null;
|
||||
|
||||
const second = index % 2 === 1 ? 0.5 : 0;
|
||||
const origin = outlinePoint(cometAt(birth, loopFrames) + second + (random(index, 2) - 0.5) * SCATTER, box);
|
||||
const travelled = (MIN_SPEED + random(index, 3) * EXTRA_SPEED) * age;
|
||||
const slid = (random(index, 4) - 0.5) * 2 * MAX_SLIDE * age;
|
||||
const seed = random(index, 5);
|
||||
|
||||
return {
|
||||
x: origin.x + origin.nx * travelled - origin.ny * slid,
|
||||
y: origin.y + origin.ny * travelled + origin.nx * slid,
|
||||
opacity: Math.sin((Math.PI * age) / life),
|
||||
digit: Math.floor(age / FLIP_EVERY_FRAMES + seed * 10) % 2 === 0 ? "1" : "0",
|
||||
large: random(index, 6) < LARGE_SHARE,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bitString, decodeText } from "./bits";
|
||||
|
||||
const isBinary = (s: string): boolean => /^[01]*$/.test(s);
|
||||
|
||||
describe("decodeText", () => {
|
||||
it("shows only digits for letters and numbers at progress 0, keeps punctuation and spaces", () => {
|
||||
const out = decodeText("Hi, 2 you!", 0, 0);
|
||||
expect(out).toHaveLength("Hi, 2 you!".length);
|
||||
expect(out.replace(/[^A-Za-z2-9]/g, "")).toBe("");
|
||||
expect(out[2]).toBe(",");
|
||||
expect(out[3]).toBe(" ");
|
||||
expect(out[9]).toBe("!");
|
||||
});
|
||||
|
||||
it("returns the original text at progress 1", () => {
|
||||
expect(decodeText("Concept to code", 1, 7)).toBe("Concept to code");
|
||||
});
|
||||
|
||||
it("resolves left to right: at 0.5 the first half is readable, the rest still digits", () => {
|
||||
const out = decodeText("ABCDEFGH", 0.5, 3);
|
||||
expect(out.slice(0, 4)).toBe("ABCD");
|
||||
expect(isBinary(out.slice(4))).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps progress outside 0..1", () => {
|
||||
expect(decodeText("Abc", 2, 0)).toBe("Abc");
|
||||
expect(isBinary(decodeText("Abc", -1, 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic for the same tick and varies with the tick", () => {
|
||||
const text = "WEB APPS & WEBSITES";
|
||||
expect(decodeText(text, 0, 5)).toBe(decodeText(text, 0, 5));
|
||||
const ticks = new Set(
|
||||
Array.from({ length: 10 }, (_, tick) => decodeText(text, 0, tick)),
|
||||
);
|
||||
expect(ticks.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("handles the empty string", () => {
|
||||
expect(decodeText("", 0.5, 0)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bitString", () => {
|
||||
it("has the requested length and only 0 and 1", () => {
|
||||
const out = bitString(40, 3);
|
||||
expect(out).toHaveLength(40);
|
||||
expect(isBinary(out)).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic and changes with the tick", () => {
|
||||
expect(bitString(40, 3)).toBe(bitString(40, 3));
|
||||
expect(bitString(40, 3)).not.toBe(bitString(40, 4));
|
||||
});
|
||||
|
||||
it("is empty for length 0", () => {
|
||||
expect(bitString(0, 1)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Pure, frame-driven binary effects. Remotion renders frame by frame, so nothing here
|
||||
* uses timers or randomness: the same inputs always give the same output.
|
||||
*/
|
||||
|
||||
const HASH_MULTIPLIER = 2654435761;
|
||||
const TICK_MULTIPLIER = 40503;
|
||||
const LETTER_OR_DIGIT = /[A-Za-z0-9]/;
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
|
||||
/** A stable pseudo-random bit for a position at a given tick. */
|
||||
const bitAt = (index: number, tick: number): "0" | "1" => {
|
||||
const hash = Math.imul(index + 1, HASH_MULTIPLIER) ^ Math.imul(tick + 1, TICK_MULTIPLIER);
|
||||
return ((hash >>> 7) & 1) === 0 ? "0" : "1";
|
||||
};
|
||||
|
||||
/**
|
||||
* Text resolving from digits, left to right. Letters and numbers not yet reached show
|
||||
* as 0 or 1; spaces and punctuation stay put so the shape of the text is readable.
|
||||
*/
|
||||
export const decodeText = (text: string, progress: number, tick: number): string => {
|
||||
const chars = Array.from(text);
|
||||
const revealed = Math.floor(clamp01(progress) * chars.length);
|
||||
return chars
|
||||
.map((char, index) =>
|
||||
index < revealed || !LETTER_OR_DIGIT.test(char) ? char : bitAt(index, tick),
|
||||
)
|
||||
.join("");
|
||||
};
|
||||
|
||||
/** A string of `length` binary digits for a given tick. */
|
||||
export const bitString = (length: number, tick: number): string =>
|
||||
Array.from({ length }, (_, index) => bitAt(index, tick)).join("");
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { curtainEdge, curtainPolygon } from "./curtain";
|
||||
|
||||
const ROWS = Array.from({ length: 30 }, (_, row) => row);
|
||||
|
||||
describe("curtainEdge", () => {
|
||||
it("is fully closed at progress 0 and fully open at progress 1, in every row", () => {
|
||||
for (const row of ROWS) {
|
||||
expect(curtainEdge(row, 0)).toBe(0);
|
||||
expect(curtainEdge(row, 1)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("never moves backwards as progress grows", () => {
|
||||
for (const row of ROWS) {
|
||||
let last = 0;
|
||||
for (let step = 0; step <= 20; step++) {
|
||||
const edge = curtainEdge(row, step / 20);
|
||||
expect(edge).toBeGreaterThanOrEqual(last);
|
||||
last = edge;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is ragged: rows are at different places half way through", () => {
|
||||
const places = new Set(ROWS.map((row) => curtainEdge(row, 0.5).toFixed(3)));
|
||||
expect(places.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("stays within 0..1 and clamps progress outside 0..1", () => {
|
||||
expect(curtainEdge(3, -1)).toBe(0);
|
||||
expect(curtainEdge(3, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("is deterministic per row", () => {
|
||||
expect(curtainEdge(7, 0.4)).toBe(curtainEdge(7, 0.4));
|
||||
});
|
||||
});
|
||||
|
||||
describe("curtainPolygon", () => {
|
||||
const box = { width: 200, height: 40, cell: 20 };
|
||||
|
||||
it("covers nothing at progress 0 and the whole box at progress 1 (behind the edge)", () => {
|
||||
expect(curtainPolygon("behind", 0, box)).toBe("polygon(0px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 0px 40px)");
|
||||
expect(curtainPolygon("behind", 1, box)).toBe(
|
||||
"polygon(0px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 0px 40px)",
|
||||
);
|
||||
});
|
||||
|
||||
it("is the complement ahead of the edge", () => {
|
||||
expect(curtainPolygon("ahead", 1, box)).toBe(
|
||||
"polygon(200px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 200px 40px)",
|
||||
);
|
||||
expect(curtainPolygon("ahead", 0, box)).toBe(
|
||||
"polygon(200px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 200px 40px)",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a row for a height that is not a multiple of the cell", () => {
|
||||
const points = curtainPolygon("behind", 1, { width: 100, height: 50, cell: 20 }).split(",");
|
||||
// Two corner points plus two per row, for three rows.
|
||||
expect(points).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The website's BitCurtain (the language switch transition): a front of binary digits with a ragged
|
||||
* edge that sweeps from left to right. Here it changes slides. Pure functions of the progress.
|
||||
*/
|
||||
|
||||
/** How ragged the edge is, as a share of the width. */
|
||||
const SPREAD = 0.24;
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
|
||||
/** A fixed pseudo-random number in 0..1 per row, so the edge keeps its shape while it moves. */
|
||||
const rowLag = (row: number): number => {
|
||||
const x = Math.sin((row + 1) * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
/** Where the edge is in a row, as a share of the width: 0 before the sweep, 1 after it. */
|
||||
export const curtainEdge = (row: number, progress: number): number =>
|
||||
clamp01(clamp01(progress) * (1 + SPREAD) - rowLag(row) * SPREAD);
|
||||
|
||||
export type CurtainBox = { readonly width: number; readonly height: number; readonly cell: number };
|
||||
|
||||
/**
|
||||
* CSS `clip-path` for one side of the edge: `behind` is what the front has already passed
|
||||
* (left of it), `ahead` is what it has not reached yet.
|
||||
*/
|
||||
export const curtainPolygon = (side: "behind" | "ahead", progress: number, box: CurtainBox): string => {
|
||||
const outer = side === "behind" ? 0 : box.width;
|
||||
const rows = Math.ceil(box.height / box.cell);
|
||||
const steps = Array.from({ length: rows }, (_, row) => {
|
||||
const x = curtainEdge(row, progress) * box.width;
|
||||
return [`${x}px ${row * box.cell}px`, `${x}px ${Math.min(box.height, (row + 1) * box.cell)}px`];
|
||||
}).flat();
|
||||
return `polygon(${[`${outer}px 0px`, ...steps, `${outer}px ${box.height}px`].join(", ")})`;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hopAt } from "./hop";
|
||||
|
||||
const A = { x: 100, y: 300 };
|
||||
const B = { x: 300, y: 300 };
|
||||
const C = { x: 700, y: 300 };
|
||||
const FRAMES = 10;
|
||||
const ARC = 0.5;
|
||||
|
||||
describe("hopAt", () => {
|
||||
it("rests on the first stop at time 0 and on the next one a hop later", () => {
|
||||
expect(hopAt(0, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
expect(hopAt(10, [A, B, C], FRAMES, ARC)).toEqual(B);
|
||||
expect(hopAt(20, [A, B, C], FRAMES, ARC)).toEqual(C);
|
||||
});
|
||||
|
||||
it("is half way across and highest in the middle of a hop", () => {
|
||||
// A to B is 200 px: the arc rises by half of that.
|
||||
const mid = hopAt(5, [A, B, C], FRAMES, ARC);
|
||||
expect(mid.x).toBeCloseTo(200, 6);
|
||||
expect(mid.y).toBeCloseTo(200, 6);
|
||||
});
|
||||
|
||||
it("jumps higher on a longer hop", () => {
|
||||
// B to C is 400 px.
|
||||
expect(hopAt(15, [A, B, C], FRAMES, ARC).y).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
it("returns from the last stop to the first and starts again", () => {
|
||||
expect(hopAt(30, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
expect(hopAt(40, [A, B, C], FRAMES, ARC)).toEqual(B);
|
||||
expect(hopAt(25, [A, B, C], FRAMES, ARC).x).toBeCloseTo(400, 6);
|
||||
});
|
||||
|
||||
it("works backwards in time, so a loop can end where it starts", () => {
|
||||
expect(hopAt(-10, [A, B, C], FRAMES, ARC)).toEqual(C);
|
||||
expect(hopAt(-30, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
});
|
||||
|
||||
it("eases: slower near a stop than in the middle of a hop", () => {
|
||||
const nearStop = hopAt(1, [A, B], FRAMES, ARC).x - A.x;
|
||||
const inFlight = hopAt(5, [A, B], FRAMES, ARC).x - hopAt(4, [A, B], FRAMES, ARC).x;
|
||||
expect(nearStop).toBeLessThan(inFlight);
|
||||
});
|
||||
|
||||
it("stays put with a single stop", () => {
|
||||
expect(hopAt(7, [A], FRAMES, ARC)).toEqual(A);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Something that hops from stop to stop in an arc, round and round. A pure function of time. */
|
||||
|
||||
export type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
const easeInOut = (t: number): number => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
||||
const wrap = (index: number, length: number): number => ((index % length) + length) % length;
|
||||
|
||||
/**
|
||||
* Where the hopper is at `time`. It rests on `stops[0]` at time 0, reaches the next stop every
|
||||
* `framesPerHop`, and returns from the last stop to the first. The arc of a hop rises by
|
||||
* `arcRatio` times the distance covered. Negative times are fine.
|
||||
*/
|
||||
export const hopAt = (time: number, stops: readonly Point[], framesPerHop: number, arcRatio: number): Point => {
|
||||
const hop = Math.floor(time / framesPerHop);
|
||||
const from = stops[wrap(hop, stops.length)];
|
||||
const to = stops[wrap(hop + 1, stops.length)];
|
||||
if (!from || !to) throw new Error("hopAt needs at least one stop");
|
||||
const t = easeInOut(time / framesPerHop - hop);
|
||||
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
||||
return {
|
||||
x: from.x + (to.x - from.x) * t,
|
||||
y: from.y + (to.y - from.y) * t - Math.sin(Math.PI * t) * distance * arcRatio,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { appRescue } from "./compositions/AppRescue";
|
||||
import { automationDocuments } from "./compositions/AutomationDocuments";
|
||||
import { automationEnquiry } from "./compositions/AutomationEnquiry";
|
||||
import { automationOrder } from "./compositions/AutomationOrder";
|
||||
import { productDeveloper } from "./compositions/ProductDeveloper";
|
||||
import { websiteDone } from "./compositions/WebsiteDone";
|
||||
import type { VideoEntry } from "./slides/ServiceVideo";
|
||||
|
||||
/** Register new videos here. Each one is offered as `<id>-<language>-<format>`. */
|
||||
export const VIDEOS: readonly VideoEntry[] = [
|
||||
automationOrder,
|
||||
automationDocuments,
|
||||
automationEnquiry,
|
||||
websiteDone,
|
||||
appRescue,
|
||||
productDeveloper,
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
import { BrandFrame } from "../brand/BrandFrame";
|
||||
import { CopyProvider } from "../content/CopyContext";
|
||||
import { COPY, type Lang, type VideoId } from "../content/videos";
|
||||
import { SlideDeck, deckDuration, type Slide } from "./SlideDeck";
|
||||
|
||||
/**
|
||||
* A video about one service, and the id under which its texts are kept. It is either a deck of
|
||||
* slides or a film: one continuous shot that brings its own stage.
|
||||
*/
|
||||
export type VideoEntry =
|
||||
| { readonly id: VideoId; readonly slides: readonly Slide[] }
|
||||
| {
|
||||
readonly id: VideoId;
|
||||
readonly Film: FC;
|
||||
/** The picture of the film alone, in stage coordinates. */
|
||||
readonly Drawing: FC;
|
||||
readonly durationInFrames: number;
|
||||
};
|
||||
|
||||
export const videoDuration = (video: VideoEntry): number =>
|
||||
"Film" in video ? video.durationInFrames : deckDuration(video.slides);
|
||||
|
||||
/** One service video in one language: brand frame, looping slide deck, texts from the copy file. */
|
||||
export const ServiceVideo: FC<{ video: VideoEntry; lang: Lang }> = ({ video, lang }) => {
|
||||
const copy = COPY[video.id][lang];
|
||||
return (
|
||||
<CopyProvider value={{ lang, copy }}>
|
||||
{"Film" in video ? (
|
||||
<video.Film />
|
||||
) : (
|
||||
<BrandFrame>
|
||||
<SlideDeck slides={video.slides} />
|
||||
</BrandFrame>
|
||||
)}
|
||||
</CopyProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { CSSProperties, FC, ReactNode } from "react";
|
||||
import { Easing, Freeze, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { BitWipe } from "../bits/BitWipe";
|
||||
import { FRAME } from "../brand/BrandFrame";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { Panel } from "../brand/Panel";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { useCopy } from "../content/CopyContext";
|
||||
import { bitString } from "../lib/bits";
|
||||
import { curtainEdge, curtainPolygon } from "../lib/curtain";
|
||||
|
||||
/** Illustrations are drawn in a box this wide; it is scaled down to fit into the panel. */
|
||||
export const STAGE_WIDTH = 872;
|
||||
const DEFAULT_ILLUSTRATION_HEIGHT = 520;
|
||||
const ILLUSTRATION_SCALE = 0.9;
|
||||
const PANEL_HEIGHT = 600;
|
||||
/** Height the panel shrinks to on a slide with extra content below the headline (the button). */
|
||||
const PANEL_HEIGHT_WITH_AFTER = 454;
|
||||
const GAP = 60;
|
||||
const TEXT_HEIGHT = 200;
|
||||
const TALL_STAGE_HEIGHT = PANEL_HEIGHT + GAP + TEXT_HEIGHT;
|
||||
const WIDE_TEXT_WIDTH = 620;
|
||||
const WIDE_GAP = 110;
|
||||
const WIDE_STAGE = { width: STAGE_WIDTH + WIDE_GAP + WIDE_TEXT_WIDTH, height: PANEL_HEIGHT };
|
||||
|
||||
const ENTER_FRAMES = 10;
|
||||
const EXIT_FRAMES = 8;
|
||||
/** Length of the digit curtain that changes the picture. */
|
||||
const WIPE_FRAMES = 16;
|
||||
const CURTAIN_CELL = 24;
|
||||
const CURTAIN_TRAIL = 9;
|
||||
const CURTAIN_LEAD = 2;
|
||||
const MAX_HEADLINE_SIZE = 100;
|
||||
/** Average width of a character of the headline font, as a share of the font size. */
|
||||
const CHAR_WIDTH = 0.53;
|
||||
|
||||
export type Slide = {
|
||||
/** Also the key of the slide's headline in the video's copy. */
|
||||
readonly id: string;
|
||||
readonly durationInFrames: number;
|
||||
/** Draws into a box of STAGE_WIDTH x illustrationHeight. Its frame 0 is the slide's first frame. */
|
||||
readonly Illustration: FC;
|
||||
readonly illustrationHeight?: number;
|
||||
/** Extra content below the headline, e.g. the button on the last slide. */
|
||||
readonly after?: ReactNode;
|
||||
};
|
||||
|
||||
type Box = { readonly width: number; readonly height: number };
|
||||
|
||||
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
const easeOut = Easing.out(Easing.cubic);
|
||||
const easeInOut = Easing.inOut(Easing.cubic);
|
||||
|
||||
/** How far the curtain is, `since` frames into a wipe. Complete on the wipe's last frame. */
|
||||
const wipeProgress = (since: number): number =>
|
||||
easeInOut(interpolate(since, [-1, WIPE_FRAMES - 1], [0, 1], clampOpts));
|
||||
|
||||
/** Largest headline size at which the longest line still fits the given width. */
|
||||
export const headlineSize = (lines: readonly string[], width: number): number => {
|
||||
const longest = Math.max(1, ...lines.map((line) => line.length));
|
||||
return Math.min(MAX_HEADLINE_SIZE, Math.floor(width / (longest * CHAR_WIDTH)));
|
||||
};
|
||||
|
||||
/**
|
||||
* The drawing of one slide, centred in the panel. The curtain reveals it from the left at its
|
||||
* start (`wipeIn`) and takes it away from the left from frame `wipeOutAt` on.
|
||||
*/
|
||||
const Picture: FC<{ slide: Slide; box: Box; wipeIn: boolean; wipeOutAt: number | null }> = ({
|
||||
slide,
|
||||
box,
|
||||
wipeIn,
|
||||
wipeOutAt,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { Illustration } = slide;
|
||||
const curtain = { ...box, cell: CURTAIN_CELL };
|
||||
const clipPath =
|
||||
wipeOutAt !== null && frame >= wipeOutAt
|
||||
? curtainPolygon("ahead", wipeProgress(frame - wipeOutAt), curtain)
|
||||
: wipeIn && frame < WIPE_FRAMES
|
||||
? curtainPolygon("behind", wipeProgress(frame), curtain)
|
||||
: undefined;
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", clipPath }}>
|
||||
<div
|
||||
style={{
|
||||
width: STAGE_WIDTH,
|
||||
height: slide.illustrationHeight ?? DEFAULT_ILLUSTRATION_HEIGHT,
|
||||
flexShrink: 0,
|
||||
transform: `scale(${ILLUSTRATION_SCALE})`,
|
||||
}}
|
||||
>
|
||||
<Illustration />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The moving edge of the curtain: bright digits at the front, a fading trail behind it. */
|
||||
const CurtainFront: FC<{ box: Box }> = ({ box }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const progress = wipeProgress(frame);
|
||||
const rows = Math.ceil(box.height / CURTAIN_CELL);
|
||||
return (
|
||||
<svg width={box.width} height={box.height} style={{ position: "absolute", inset: 0 }}>
|
||||
{Array.from({ length: rows }, (_, row) => {
|
||||
const x = curtainEdge(row, progress) * box.width;
|
||||
if (x <= 0 || x >= box.width) return null;
|
||||
const digits = bitString(CURTAIN_TRAIL + CURTAIN_LEAD, frame * 7 + row * 13);
|
||||
return Array.from(digits, (digit, index) => {
|
||||
// k counts cells from the edge back into what is already revealed; negative is ahead of it.
|
||||
const k = index - CURTAIN_LEAD;
|
||||
return (
|
||||
<text
|
||||
key={`${row}-${index}`}
|
||||
x={x - (k + 0.5) * CURTAIN_CELL}
|
||||
y={row * CURTAIN_CELL + CURTAIN_CELL / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={700}
|
||||
fontSize={18}
|
||||
fill={k < 0 ? DRAW.label : k < 2 ? DRAW.mark : DRAW.fg}
|
||||
opacity={k < 0 ? 0.5 : (1 - k / CURTAIN_TRAIL) * 0.9}
|
||||
>
|
||||
{digit}
|
||||
</text>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/** All pictures in turn, changed by the curtain, and the first one again at the very end. */
|
||||
const Pictures: FC<{ slides: readonly Slide[]; starts: readonly number[]; total: number; box: Box }> = ({
|
||||
slides,
|
||||
starts,
|
||||
total,
|
||||
box,
|
||||
}) => {
|
||||
const first = slides[0];
|
||||
const last = slides.length - 1;
|
||||
return (
|
||||
<>
|
||||
{slides.map((slide, index) => {
|
||||
// Every picture keeps playing while the next one is wiped in; the last one is wiped out within its time.
|
||||
const wipeOutAt = index === last ? slide.durationInFrames - WIPE_FRAMES : slide.durationInFrames;
|
||||
return (
|
||||
<Sequence
|
||||
key={slide.id}
|
||||
from={starts[index]}
|
||||
durationInFrames={index === last ? slide.durationInFrames : slide.durationInFrames + WIPE_FRAMES}
|
||||
layout="none"
|
||||
>
|
||||
<Picture slide={slide} box={box} wipeIn={index > 0} wipeOutAt={wipeOutAt} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
{first && (
|
||||
<Sequence from={total - WIPE_FRAMES} durationInFrames={WIPE_FRAMES} layout="none">
|
||||
<LoopPicture slide={first} box={box} />
|
||||
</Sequence>
|
||||
)}
|
||||
{[...starts.slice(1), total - WIPE_FRAMES].map((from) => (
|
||||
<Sequence key={from} from={from} durationInFrames={WIPE_FRAMES} layout="none">
|
||||
<CurtainFront box={box} />
|
||||
</Sequence>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** The first picture as it looks on frame 0, wiped back in at the end of the video. */
|
||||
const LoopPicture: FC<{ slide: Slide; box: Box }> = ({ slide, box }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
clipPath: curtainPolygon("behind", wipeProgress(frame), { ...box, cell: CURTAIN_CELL }),
|
||||
}}
|
||||
>
|
||||
<Freeze frame={0}>
|
||||
<Picture slide={slide} box={box} wipeIn={false} wipeOutAt={null} />
|
||||
</Freeze>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The headline of one slide, and what follows it. `settled` shows it complete from its first frame. */
|
||||
const Words: FC<{ slide: Slide; settled: boolean; width: number }> = ({ slide, settled, width }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const enter = settled ? 1 : interpolate(frame, [0, ENTER_FRAMES], [0, 1], clampOpts);
|
||||
const exit = interpolate(
|
||||
frame,
|
||||
[slide.durationInFrames - EXIT_FRAMES, slide.durationInFrames - 1],
|
||||
[1, 0],
|
||||
clampOpts,
|
||||
);
|
||||
const lines = useCopy().slides[slide.id] ?? [];
|
||||
const style: CSSProperties = {
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 900,
|
||||
fontSize: headlineSize(lines, width),
|
||||
lineHeight: 0.98,
|
||||
letterSpacing: "-0.04em",
|
||||
wordSpacing: "0.08em",
|
||||
};
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, opacity: Math.min(enter, exit) }}>
|
||||
{lines.map((line, index) => (
|
||||
<div key={line} style={{ ...style, color: index > 0 && index === lines.length - 1 ? COLOR.faint : COLOR.ink }}>
|
||||
<BitWipe from={settled ? -1000 : 3 + index * 4} durationInFrames={16} digitSize={18}>
|
||||
{line}
|
||||
</BitWipe>
|
||||
</div>
|
||||
))}
|
||||
{slide.after}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Fades its children in over the exit of the last slide, so the last frame equals the first. */
|
||||
const LoopBack: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, EXIT_FRAMES - 1], [0, 1], clampOpts);
|
||||
return <div style={{ position: "absolute", inset: 0, opacity }}>{children}</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A looping sequence of slides: big picture, few words. The pictures share one dark panel and are
|
||||
* changed by a curtain of digits; the headlines sit below the panel (portrait and square) or beside
|
||||
* it (16:9). The first slide is complete on frame 0, which is the preview image, and returns at the end.
|
||||
*/
|
||||
export const SlideDeck: FC<{ slides: readonly Slide[] }> = ({ slides }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const wide = width > height;
|
||||
const stage = wide ? WIDE_STAGE : { width: STAGE_WIDTH, height: TALL_STAGE_HEIGHT };
|
||||
// As large as the frame allows: the drawing is what matters.
|
||||
const scale = Math.min(
|
||||
(height - FRAME.top - FRAME.bottom) / stage.height,
|
||||
(width - FRAME.side * 2) / stage.width,
|
||||
);
|
||||
|
||||
const starts = slides.map((_, index) =>
|
||||
slides.slice(0, index).reduce((sum, slide) => sum + slide.durationInFrames, 0),
|
||||
);
|
||||
const total = deckDuration(slides);
|
||||
const first = slides[0];
|
||||
|
||||
// In portrait the panel makes room for a slide that has more below its headline.
|
||||
const shrink = wide
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
...slides.map((slide, index) => {
|
||||
if (!slide.after) return 0;
|
||||
const start = starts[index] ?? 0;
|
||||
const end = start + slide.durationInFrames;
|
||||
return Math.min(
|
||||
interpolate(frame, [start, start + ENTER_FRAMES], [0, 1], clampOpts),
|
||||
interpolate(frame, [end - WIPE_FRAMES, end - 1], [1, 0], clampOpts),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const panel = { width: STAGE_WIDTH, height: PANEL_HEIGHT - (PANEL_HEIGHT - PANEL_HEIGHT_WITH_AFTER) * easeOut(shrink) };
|
||||
const textWidth = wide ? WIDE_TEXT_WIDTH : STAGE_WIDTH;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", ...stage, flexShrink: 0, transform: `scale(${scale})` }}>
|
||||
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<Panel {...panel}>
|
||||
<Pictures slides={slides} starts={starts} total={total} box={panel} />
|
||||
</Panel>
|
||||
</div>
|
||||
<div
|
||||
style={
|
||||
wide
|
||||
? { position: "absolute", top: 200, left: STAGE_WIDTH + WIDE_GAP, width: WIDE_TEXT_WIDTH, height: TEXT_HEIGHT }
|
||||
: { position: "absolute", top: panel.height + GAP, left: 0, width: STAGE_WIDTH, height: TEXT_HEIGHT }
|
||||
}
|
||||
>
|
||||
{slides.map((slide, index) => (
|
||||
<Sequence key={slide.id} from={starts[index]} durationInFrames={slide.durationInFrames} layout="none">
|
||||
<Words slide={slide} settled={index === 0} width={textWidth} />
|
||||
</Sequence>
|
||||
))}
|
||||
{first && (
|
||||
<Sequence from={total - EXIT_FRAMES} durationInFrames={EXIT_FRAMES} layout="none">
|
||||
<LoopBack>
|
||||
<Freeze frame={0}>
|
||||
<Words slide={first} settled width={textWidth} />
|
||||
</Freeze>
|
||||
</LoopBack>
|
||||
</Sequence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const deckDuration = (slides: readonly Slide[]): number =>
|
||||
slides.reduce((sum, slide) => sum + slide.durationInFrames, 0);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
import { BrandButton } from "../brand/BrandButton";
|
||||
import { CTA_SCENE_HEIGHT } from "../illustrations/scenes";
|
||||
import type { Slide } from "./SlideDeck";
|
||||
|
||||
const CTA_FRAMES = 75;
|
||||
|
||||
/** The closing slide of every video: a smaller picture, the last statement and the website's button. */
|
||||
export const ctaSlide = (Illustration: FC): Slide => ({
|
||||
id: "cta",
|
||||
durationInFrames: CTA_FRAMES,
|
||||
Illustration,
|
||||
illustrationHeight: CTA_SCENE_HEIGHT,
|
||||
after: (
|
||||
<div style={{ marginTop: 48 }}>
|
||||
<BrandButton label="Get in touch" size={28} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"scripts",
|
||||
"remotion.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+54
-107
@@ -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,14 +11,13 @@ 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";
|
||||
@@ -40,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,
|
||||
@@ -61,8 +64,8 @@ export default async function LandingPage({ params }: Props) {
|
||||
services,
|
||||
automation,
|
||||
presentations,
|
||||
technology,
|
||||
working,
|
||||
story,
|
||||
freelance,
|
||||
work,
|
||||
faq,
|
||||
} = content;
|
||||
@@ -79,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}
|
||||
@@ -105,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>
|
||||
@@ -127,7 +157,7 @@ export default async function LandingPage({ params }: Props) {
|
||||
{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
|
||||
@@ -147,6 +177,7 @@ export default async function LandingPage({ params }: Props) {
|
||||
<Block
|
||||
index="04"
|
||||
label={presentations.label}
|
||||
chapter={{ line: story.presentations, figure: "screen", flip: false }}
|
||||
tone="dark"
|
||||
title={<Title title={presentations.title} muted="text-slate-500" />}
|
||||
>
|
||||
@@ -182,9 +213,9 @@ export default async function LandingPage({ params }: Props) {
|
||||
<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">
|
||||
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
|
||||
<h3 className="m-0 font-sans text-xl font-bold tracking-tight text-white md:text-2xl">
|
||||
{presentations.goodFor.title}
|
||||
</p>
|
||||
</h3>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{presentations.goodFor.items.map((item) => (
|
||||
<li
|
||||
@@ -218,102 +249,8 @@ export default async function LandingPage({ params }: Props) {
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
label={technology.label}
|
||||
title={<Title title={technology.title} />}
|
||||
>
|
||||
<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"}`}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</SwipeRow>
|
||||
|
||||
<Reveal>
|
||||
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
|
||||
<p className={`${MONO} m-0`}>{technology.techLabel}</p>
|
||||
<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>
|
||||
|
||||
<div className="mt-12 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>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<PipelineRun />
|
||||
</Reveal>
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
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>
|
||||
<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="07"
|
||||
label={work.label}
|
||||
chapter={{ line: story.work, figure: "square", flip: true }}
|
||||
tone="dark"
|
||||
title={<Title title={work.title} muted="text-slate-500" />}
|
||||
>
|
||||
@@ -325,13 +262,23 @@ export default async function LandingPage({ params }: Props) {
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block index="08" 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>
|
||||
);
|
||||
}
|
||||
|
||||
+69
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1384,6 +1339,75 @@ pre:has(code[class*="language-"]) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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,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 } },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Content strategy: LinkedIn first
|
||||
|
||||
Status: proposal for the owner's review. Nothing has been posted or scheduled. The material exists:
|
||||
six films and twelve image posts, each in German and English, with the text to post
|
||||
(`apps/video`, rendered by `pnpm library` into `out/library`).
|
||||
|
||||
No reach or engagement figures are quoted here: there are none for this account yet. Section 6 says
|
||||
what to measure, and what to change once there are numbers.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Enquiries by email from people who need one of the four services. Not followers, not likes.
|
||||
Every post leads to one action: write to me, via `www.mintel.me`.
|
||||
|
||||
## 2. Who it is for
|
||||
|
||||
| Audience | What they recognise | Films |
|
||||
| :------- | :------------------ | :---- |
|
||||
| Owners and managers of small and mid-sized companies, not technical | Work done by hand between tools; a website that never gets done | Orders, invoices, enquiries, website |
|
||||
| Founders and product leads | An app that has become fragile; a backlog that outgrows the team | Reliable apps, developer |
|
||||
|
||||
German is the main language, as on the website. The English versions are for an English-speaking
|
||||
network and for the developer topic, where teams often work in English.
|
||||
|
||||
## 3. The idea that holds it together
|
||||
|
||||
Every post starts from the same move: something that **looks like** one thing and **is** another.
|
||||
"Looks like ping-pong. It's your office." The viewer gets a small riddle, then recognises their own
|
||||
situation, then sees it resolved. That is the hook, the tone (a little dry, never mocking the
|
||||
client) and the visual language at once: squares, dots, lines, nothing else.
|
||||
|
||||
Three kinds of post carry it:
|
||||
|
||||
| Kind | What it is | Job |
|
||||
| :--- | :--------- | :-- |
|
||||
| Film (6) | 23 seconds, one shot, loops | Gets attention, explains one service to a layperson |
|
||||
| Poster (6) | One picture of the film with two lines: "Not ping-pong. Your office." | Repeats the idea for those who scrolled past the film |
|
||||
| Card (6) | A question clients ask before the first call, and the answer in large type | Removes a reason not to write: lock-in, ownership, "I am not technical" |
|
||||
|
||||
The cards say only what the website says (questions and principles in `apps/web/src/content`).
|
||||
|
||||
## 4. Rhythm
|
||||
|
||||
Proposal: **three posts a week for six weeks**, one topic per week.
|
||||
|
||||
- Tuesday: the film.
|
||||
- Thursday: a card.
|
||||
- Saturday: the poster of the same film.
|
||||
|
||||
This is more than the one post a week in the relaunch concept. The reasoning: one topic is seen
|
||||
three times in a week in three forms, without repeating a post. If three is too many, post only
|
||||
the films weekly and use posters and cards as the second round: the same material then lasts
|
||||
eighteen weeks. Which of the two is the owner's decision.
|
||||
|
||||
Order of topics: automation and websites first (the widest audience and the two planned ad
|
||||
campaigns), the two developer topics later.
|
||||
|
||||
| Week | Topic | Tuesday | Thursday | Saturday |
|
||||
| :--- | :---- | :------ | :------- | :------- |
|
||||
| 1 | Automation: orders | Film "ping-pong" | Card "Not a technical person?" | Poster |
|
||||
| 2 | Websites | Film "a beginning" | Card "Who does the work?" | Poster |
|
||||
| 3 | Automation: invoices | Film "skyscraper" | Card "What do you pay for?" | Poster |
|
||||
| 4 | Reliable apps | Film "Jenga" | Card "Who owns the code?" | Poster |
|
||||
| 5 | Automation: enquiries | Film "a star" | Card "What I do not do" | Poster |
|
||||
| 6 | A developer for your product | Film "loading bar" | Card "How long are you committed?" | Poster |
|
||||
|
||||
The plan is data in `apps/video/src/content/plan.ts`; the overview page `out/library/index.html`
|
||||
shows the posts in this order, with the text to copy and the files to download.
|
||||
|
||||
## 5. How to post
|
||||
|
||||
- Upload the film or image directly to LinkedIn (no link to a video elsewhere), 4:5 format.
|
||||
- The post text opens with the hook of the film. Keep it as written, or shorten it; do not add claims.
|
||||
- The address is in the text and in the picture. If a link is wanted, put it in the first comment.
|
||||
- Answer every comment and message personally. That is where enquiries start.
|
||||
- 16:9 versions are for YouTube and, later, for ads shown to people who already visited the site
|
||||
(see `google-ads.md`).
|
||||
|
||||
## 6. What to measure, and when to change something
|
||||
|
||||
Per post, noted once a week: impressions, comments, profile visits, and above all enquiries by
|
||||
email that mention LinkedIn or follow a post. Clicks on the address are counted on the website
|
||||
once the click event exists (see `google-ads.md`, prerequisite 2).
|
||||
|
||||
After three weeks:
|
||||
- A topic that brought an enquiry or real conversations gets a second film with another example.
|
||||
- A kind of post that is consistently ignored is dropped in favour of the others.
|
||||
- If nothing moves, the rhythm is not the problem: look at who sees the posts (network) first.
|
||||
|
||||
## 7. What comes after the six weeks
|
||||
|
||||
- New examples for the topic that worked, made with the `video-film` skill.
|
||||
- Posts from real work, once a client agrees: what the problem was, what changed. These need the
|
||||
client's consent and are not prepared.
|
||||
- The same material on other channels (X, YouTube) only if LinkedIn shows which topic works.
|
||||
|
||||
## 8. Open decisions for the owner
|
||||
|
||||
1. Three posts a week, or one?
|
||||
2. German only, or both languages (and if both: one account, alternating, or German only on LinkedIn)?
|
||||
3. The offer "make an app reliable" is posted in week 4 without a price. Is the offer final?
|
||||
@@ -1,7 +1,9 @@
|
||||
# mintel.me relaunch concept: freelance engineer site
|
||||
|
||||
Scope: this website only. Goal: a visitor understands what I sell within 10 seconds and
|
||||
books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable hours.
|
||||
Scope: this website, plus the videos made from it (`apps/video`, see section 6). Goal: a visitor understands
|
||||
what I sell within 10 seconds and writes an email within 2 minutes. Low sales overhead, more bookable hours.
|
||||
|
||||
Status note: sections 3 to 6 describe the site as built. Sections 2 and 9 are kept as history and are marked as such.
|
||||
|
||||
## 1. Positioning
|
||||
|
||||
@@ -13,10 +15,10 @@ books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable
|
||||
|
||||
- **Never mention AI as my own tool.** How I work is none of the client's business: no "AI-built", "AI-accelerated", "AI-code rescue", no FAQ about AI tools. The one exception is the automation service, where AI steps are part of what the client gets (sorting, summarising, reading documents).
|
||||
- **Modest and factual.** No promises I have not made (response times, weekly updates, start dates, search ranking). Only state what the owner confirmed. No superlatives.
|
||||
- **No internal method vocabulary** on client-facing pages: no specs, TDD, mutation testing, coverage, pull requests. Translate into what the client gets: changes do not break things, a written update every week, predictable cost. Technical detail belongs on `/process` and `/notes`, for engineers.
|
||||
- Do not say: "everything", "fullstack for anyone", "vibe coding". No location or tax talk; say "remote, available during European business hours".
|
||||
- **No internal method vocabulary** on client-facing pages: no specs, TDD, mutation testing, coverage, pull requests. Translate into what the client gets: changes do not break things, predictable cost.
|
||||
- Do not say: "everything", "fullstack for anyone", "vibe coding". No location or tax talk; say "remote". Not "available during European business hours" (owner, 2026-10-05): he works as a digital nomad without fixed hours; meetings can be arranged, all-day availability cannot. This is said on the freelance page only.
|
||||
- Pick the visitor up: introduce me first, then let them choose their need (developer for a product, make an app reliable, just a website), then explain for that need what they get, how it runs, what it costs.
|
||||
- Language (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.
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 277 KiB |
@@ -58,6 +58,11 @@ export const AbstractCircuit: React.FC<{
|
||||
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 = () => {
|
||||
@@ -66,7 +71,7 @@ export const AbstractCircuit: React.FC<{
|
||||
// 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 : 1;
|
||||
const ink = small ? PHONE_INK : DESKTOP_INK;
|
||||
const grow = small ? PHONE_GROW : 0;
|
||||
|
||||
// Horizontal lanes
|
||||
@@ -148,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;
|
||||
|
||||
@@ -167,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);
|
||||
@@ -175,6 +198,7 @@ export const AbstractCircuit: React.FC<{
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
};
|
||||
}, [invert]);
|
||||
|
||||
|
||||
@@ -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,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`}
|
||||
|
||||
@@ -81,7 +81,7 @@ 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,
|
||||
density: 0.6,
|
||||
|
||||
@@ -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,
|
||||
@@ -50,6 +55,11 @@ export const Block: React.FC<BlockProps> = ({
|
||||
)}
|
||||
{dark && <BitSeam />}
|
||||
<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
|
||||
|
||||
@@ -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-12 px-5 py-16 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
|
||||
@@ -43,7 +57,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
|
||||
</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 ?? "");
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -146,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 (
|
||||
@@ -156,7 +164,7 @@ export const PathPicker: React.FC = () => {
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={services.tablistLabel}
|
||||
className="grid grid-cols-2 gap-2 md:gap-3 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;
|
||||
@@ -170,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-4 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"
|
||||
@@ -243,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>
|
||||
{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-6">
|
||||
<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">
|
||||
{path.pricingModel}
|
||||
{care.title}
|
||||
</p>
|
||||
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
|
||||
{path.pricingText}
|
||||
<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="grid grid-cols-2 gap-6 md:col-span-6 md:gap-8">
|
||||
{path.prices.map((price) => (
|
||||
<div key={price.label}>
|
||||
<div className="md:col-span-5">
|
||||
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
|
||||
{price.label}
|
||||
{care.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}
|
||||
{care.price.amount}
|
||||
</p>
|
||||
<p className={`${MONO} m-0 mt-2`}>{price.note}</p>
|
||||
<p className={`${MONO} m-0 mt-2`}>{care.price.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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}
|
||||
)}
|
||||
|
||||
{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>
|
||||
<Button href="#contact" size="large">
|
||||
{path.cta}
|
||||
</Button>
|
||||
<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 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);
|
||||
};
|
||||
@@ -223,6 +223,10 @@ export const AutomationFlow: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{/* 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 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}
|
||||
@@ -237,10 +241,10 @@ export const AutomationFlow: React.FC = () => {
|
||||
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 ${
|
||||
className={`rounded-full border px-4 py-2 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"
|
||||
? "border-white bg-white text-slate-900"
|
||||
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
@@ -248,15 +252,6 @@ export const AutomationFlow: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</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="left" tone="light" className="mt-8">
|
||||
<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>
|
||||
<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"}`}
|
||||
@@ -264,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
|
||||
@@ -440,23 +438,28 @@ export const AutomationFlow: React.FC = () => {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BitAura>
|
||||
|
||||
<SwipeRow 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">
|
||||
<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 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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
.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={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
|
||||
d={`M116 150H150L${turn} ${option.y}H250`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="square"
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
<Packet
|
||||
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
|
||||
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)}>
|
||||
<rect
|
||||
x={250}
|
||||
y={option.y - 16}
|
||||
<Chip
|
||||
x={340}
|
||||
y={option.y}
|
||||
width={180}
|
||||
height={32}
|
||||
rx={16}
|
||||
fill={PAPER}
|
||||
stroke={LINE}
|
||||
label={option.label}
|
||||
size="var(--scene-label, 10px)"
|
||||
pins={6}
|
||||
/>
|
||||
<text
|
||||
x={340}
|
||||
y={option.y + 4}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: INK }}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
<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}
|
||||
<Chip
|
||||
x={x}
|
||||
y={y}
|
||||
width={112}
|
||||
height={44}
|
||||
rx={10}
|
||||
fill={SOFT}
|
||||
stroke={RIM}
|
||||
strokeWidth={FINE}
|
||||
label={label}
|
||||
size="var(--scene-label, 10px)"
|
||||
/>
|
||||
<text x={x} y={y + 4} textAnchor="middle" style={{ ...MONO, fill: INK }}>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** 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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
};
|
||||
+344
-230
@@ -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 entwickle moderne Web-Apps, intuitive Interfaces und Websites mit React, Next.js und TypeScript und automatisiere Geschäftsprozesse.",
|
||||
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,19 +41,23 @@ 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 entwickle Web-Apps, Interfaces und moderne Websites.",
|
||||
primaryCta: GET_IN_TOUCH,
|
||||
secondaryCta: "Was ich mache",
|
||||
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
|
||||
builtAndRunning: "Gebaut und in Betrieb:",
|
||||
secondaryCta: "Leistungen & Arbeitsweise",
|
||||
trust: [
|
||||
"Direkter Kontakt – Umsetzung aus einer Hand",
|
||||
"Verbindliches Angebot vor Projektstart",
|
||||
"Änderungen unkompliziert per E-Mail – ohne CMS-Einarbeitung",
|
||||
"Hosting auf Servern 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.",
|
||||
lead: "Seit 15 Jahren entwickle ich fürs Web: zunächst in Agenturen, später als Senior-Entwickler in Produktteams und heute als selbstständiger Software Engineer.",
|
||||
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.",
|
||||
"Sie arbeiten direkt mit mir. Egal, ob Sie ein Produktteam leiten oder so etwas zum ersten Mal in Auftrag geben.",
|
||||
"Mein Schwerpunkt liegt auf dem, was Nutzer sehen und bedienen: ansprechendes Design und intuitive Benutzeroberflächen. Da ich auch die dahinterliegende Software-Architektur und APIs beherrsche, liefere ich ganzheitliche, produktionsreife Web-Anwendungen aus einer Hand.",
|
||||
"Sie arbeiten auf Augenhöhe direkt mit mir zusammen – egal, ob Sie ein erfahrenes Produktteam leiten oder Ihr erstes digitales Projekt starten.",
|
||||
],
|
||||
portraitAlt: "Marc Mintel, Freelance-Webentwickler",
|
||||
careerLabel: "Der Weg bisher",
|
||||
@@ -73,118 +78,154 @@ export const de: Content = {
|
||||
},
|
||||
services: {
|
||||
label: "Leistungen",
|
||||
title: { lead: "Was brauchen Sie?", rest: "Vier typische Fälle." },
|
||||
tablistLabel: "Was brauchen Sie?",
|
||||
youGet: "Das bekommen Sie",
|
||||
howItRuns: "So läuft es ab",
|
||||
whatItCosts: "Das kostet es",
|
||||
title: {
|
||||
lead: "Was planen Sie?",
|
||||
rest: "Passgenaue Unterstützung für Ihr Vorhaben.",
|
||||
},
|
||||
tablistLabel: "Was planen Sie?",
|
||||
youGet: "Was Sie erhalten",
|
||||
howItRuns: "Der Ablauf",
|
||||
care: {
|
||||
label: "Nach dem Start",
|
||||
title:
|
||||
"Zuverlässige Pflege und laufende Betreuung – auf Wunsch dauerhaft.",
|
||||
text: "Sie müssen sich in kein System einarbeiten: Für inhaltliche Anpassungen oder Updates genügt eine kurze E-Mail.",
|
||||
price: {
|
||||
label: "Betreuung",
|
||||
amount: "89 EUR",
|
||||
note: "im Monat, netto, monatlich kündbar",
|
||||
},
|
||||
includedLabel: "Das ist enthalten",
|
||||
included: [
|
||||
"Hosting auf abgesicherten Servern in Deutschland",
|
||||
"Domain, SSL-Verschlüsselung und technische Wartung inklusive",
|
||||
"Kleine Änderungen: Anpassung von Texten, Bildern und Kontaktdaten",
|
||||
"Kein komplexes Redaktionssystem, keine Update-Pflege durch Sie",
|
||||
],
|
||||
termsLabel: "Gut zu wissen",
|
||||
terms: [
|
||||
"Die ersten drei Monate sind im Projektpreis bereits enthalten.",
|
||||
"Größere Erweiterungen (z. B. neue Unterseiten) stimme ich vorab transparent mit Ihnen ab.",
|
||||
"Feste Reaktionszeit innerhalb von zwei Werktagen (keine 24/7-Rufbereitschaft).",
|
||||
"Kein Vendor Lock-in: Sämtlicher Code und alle Inhalte gehören zu 100 % Ihnen.",
|
||||
],
|
||||
},
|
||||
offer: {
|
||||
title: "Klares, verbindliches Angebot vor Projektbeginn.",
|
||||
text: "Schildern Sie mir kurz Ihr Vorhaben. Nach einem rund 20-minütigen Kennenlernen erhalten Sie ein transparentes, schriftliches Angebot. Die Arbeit beginnt erst nach Ihrer ausdrücklichen Freigabe.",
|
||||
cta: "Angebot anfragen",
|
||||
},
|
||||
estimate: {
|
||||
label: "Preise & Schätzung",
|
||||
title: "Transparente Aufwandsschätzung",
|
||||
steps: [
|
||||
{
|
||||
title: "Unverbindliches Erstgespräch (ca. 20 Minuten)",
|
||||
text: "Wir besprechen Ihre Anforderungen, Ziele und Rahmenbedingungen.",
|
||||
},
|
||||
{
|
||||
title: "Schriftliche Aufwandsschätzung",
|
||||
text: "Sie erhalten eine transparente Aufschlüsselung der geschätzten Stunden und Leistungen.",
|
||||
},
|
||||
{
|
||||
title: "Budgetfreigabe & Abrechnung",
|
||||
text: "Abgerechnet wird nach tatsächlichem Aufwand. Drohen Abweichungen, informiere ich Sie frühzeitig.",
|
||||
},
|
||||
],
|
||||
orientationLabel: "Zur Orientierung",
|
||||
net: "Alle Preise netto.",
|
||||
cta: "Schätzung anfragen",
|
||||
},
|
||||
paths: [
|
||||
{
|
||||
id: "developer",
|
||||
service: "Entwicklung von Web-Apps und Frontends",
|
||||
need: "Wir brauchen einen Entwickler für unser Produkt",
|
||||
forWhom: "Start-ups, Produktteams, Agenturen, Web3-Teams",
|
||||
headline: "Ein erfahrener Entwickler, der in Ihr Projekt einsteigt.",
|
||||
headline: "Erfahrene Verstärkung für Ihr Entwicklerteam.",
|
||||
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 digitales Produkt und mehr Vorhaben, als Ihre internen Kapazitäten abdecken. Ich steige an fest vereinbarten Tagen ein und übernehme, was Priorität hat: neue Features, UI-Optimierungen oder technische Refactorings.",
|
||||
youGet: [
|
||||
"Web-Apps, Dashboards, Kundenportale und interne Werkzeuge",
|
||||
"Oberflächen mit Blick für Design und Bedienbarkeit",
|
||||
"Interfaces mit Fokus auf Design, Performance und Bedienbarkeit",
|
||||
"Web3-Frontends: Wallet-Anbindung, Live-Daten, Dashboards",
|
||||
"Kleine Werkzeuge und Automatisierung für wiederkehrende Arbeit",
|
||||
"Kleine Tools und Automatisierungen für wiederkehrende Aufgaben",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "Sie erzählen mir, was Sie bauen und wo Sie Unterstützung brauchen. Wenn ich dafür nicht der Richtige bin, sage ich das.",
|
||||
text: "Sie schildern Ihr Projekt und wo Sie Unterstützung benötigen. Sollte meine Expertise dafür nicht optimal passen, sage ich Ihnen das offen.",
|
||||
},
|
||||
{
|
||||
title: "Wir vereinbaren die Tage",
|
||||
text: "Wie viele Tage pro Woche ich für Sie arbeite und was zuerst drankommt.",
|
||||
title: "Feste Kapazitäten vereinbaren",
|
||||
text: "Wir legen die wöchentlichen Tage fest und priorisieren die anstehenden Aufgaben.",
|
||||
},
|
||||
{
|
||||
title: "Ich arbeite die Liste ab",
|
||||
text: "Fertige Teile gehen zur Durchsicht an Sie, sobald sie fertig sind. Ich erfasse meine Stunden, und Sie sehen, wofür sie angefallen sind.",
|
||||
title: "Fokussierte Umsetzung",
|
||||
text: "Arbeitsergebnisse übergebe ich fortlaufend zur Durchsicht. Die Arbeitszeit wird detailliert und transparent erfasst.",
|
||||
},
|
||||
{
|
||||
title: "Zwei Wochen Kündigungsfrist",
|
||||
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:
|
||||
"Feste Wochentage im Monatskontingent. Abgerechnet werden ausschließlich die tatsächlich geleisteten Stunden.",
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
service: "Code-Review und Stabilisierung von Web-Apps",
|
||||
need: "Unsere App soll zuverlässig werden",
|
||||
forWhom:
|
||||
"Teams, deren App schnell gebaut wurde und sich jetzt schwer ändern lässt",
|
||||
"Teams mit gewachsenen Codebasen, die an Stabilität verloren haben oder schwer erweiterbar sind",
|
||||
headline:
|
||||
"Eine Woche, um Ihre App durchzusehen und das Dringendste zu beheben.",
|
||||
"Einwöchiger Code-Sprint: Analyse, Stabilisierung und Sofortmaßnahmen.",
|
||||
intro:
|
||||
"Die App läuft, aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten. Ich nehme mir eine Woche, gehe den Code durch, behebe, was in dieser Zeit möglich ist, und schreibe auf, was ich als Nächstes tun würde.",
|
||||
"Die Anwendung läuft, aber wiederkehrende Bugs und zähe Release-Zyklen bremsen Ihr Team. In einer fokussierten Woche durchleuchte ich die Codebasis, behebe die drängendsten Probleme und erarbeite eine klare Roadmap für die nachhaltige Weiterentwicklung.",
|
||||
youGet: [
|
||||
"Eine ehrliche Einschätzung: was solide ist und was wackelt",
|
||||
"Automatisierte Tests für die wichtigsten Teile",
|
||||
"Die dringendsten Probleme behoben, soweit eine Woche reicht",
|
||||
"Eine schriftliche Liste der nächsten Schritte, in verständlicher Sprache",
|
||||
"Fundierte Bestandsaufnahme: Was steht solide da, wo liegen Stabilitäts- und Sicherheitsrisiken?",
|
||||
"Automatisierte Tests für geschäftskritische Workflows",
|
||||
"Sofortige Behebung der gravierendsten Engpässe im Rahmen der Sprint-Woche",
|
||||
"Pragmatische Handlungsempfehlungen für nächste Schritte – verständlich dokumentiert",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "Sie zeigen mir die App und erzählen, wo sie Ärger macht.",
|
||||
text: "Sie geben mir Einblick in die Anwendung und erläutern, an welchen Stellen es hakt.",
|
||||
},
|
||||
{
|
||||
title: "Eine Woche Arbeit",
|
||||
text: "Ich lese den Code, ergänze Tests, wo sie am meisten bringen, und behebe die dringendsten Probleme.",
|
||||
title: "Fünf Tage Deep Dive",
|
||||
text: "Ich analysiere die Architektur, etabliere Tests an kritischen Pfaden und behebe die dringendsten Bugs.",
|
||||
},
|
||||
{
|
||||
title: "Eine schriftliche Zusammenfassung",
|
||||
text: "Was ich gefunden habe, was ich geändert habe und was ich empfehle. So geschrieben, dass Sie es nachvollziehen können, ohne selbst zu entwickeln.",
|
||||
title: "Verständlicher Abschlussbericht",
|
||||
text: "Befunde, umgesetzte Fixes und strategische Empfehlungen – so aufbereitet, dass auch Nicht-Entwickler fundierte Entscheidungen treffen können.",
|
||||
},
|
||||
{
|
||||
title: "Sie entscheiden, wie es weitergeht",
|
||||
text: "Mit mir, mit Ihrem eigenen Team oder gar nicht.",
|
||||
title: "Sie entscheiden über die nächsten Schritte",
|
||||
text: "Gemeinsam mit mir, intern mit Ihrem Team oder zu einem späteren Zeitpunkt.",
|
||||
},
|
||||
],
|
||||
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 Rahmen umfasst eine Arbeitswoche (35 Stunden). Sollte sich im Erstgespräch zeigen, dass dieser Rahmen für Ihre Problemstellung nicht ausreicht, spreche ich das direkt an.",
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
service: "Prozessautomatisierung und Systemintegration",
|
||||
need: "Wir wollen einen Ablauf automatisieren",
|
||||
forWhom:
|
||||
"Unternehmen, in denen Menschen Daten zwischen Programmen kopieren oder dieselben Schritte wiederholen",
|
||||
"Unternehmen, in denen Daten manuell zwischen Tools übertragen werden oder repetitive Abläufe Zeit kosten",
|
||||
headline:
|
||||
"Systeme, die Arbeit aneinander weitergeben, damit Menschen es nicht tun müssen.",
|
||||
"Systeme nahtlos verbinden – manuelle Routineaufgaben und Medienbrüche eliminieren.",
|
||||
intro:
|
||||
"Jemand exportiert hier eine Liste, tippt sie dort ein und schreibt eine E-Mail dazu. Ich verbinde die Programme, die Sie schon nutzen, sodass wiederkehrende Schritte von selbst laufen und Ihre Leute die Arbeit machen können, für die man sie braucht.",
|
||||
"Daten exportieren, manuell abtippen, Bestätigungen per Hand verschicken: Ich verknüpfe Ihre bestehende Software über APIs, damit Datenflüsse automatisch ineinandergreifen und Ihr Team sich auf wertschöpfende Aufgaben konzentrieren kann.",
|
||||
youGet: [
|
||||
"Verbindungen zwischen Ihren Systemen über deren Schnittstellen (APIs)",
|
||||
"Wiederkehrende Abläufe, die ohne Handarbeit laufen",
|
||||
"Wo es hilft, Schritte mit KI: sortieren, zusammenfassen, Dokumente lesen",
|
||||
"Eine kurze Beschreibung, wie es funktioniert, damit es gewartet werden kann",
|
||||
"Direkte Schnittstellenanbindung (APIs) Ihrer bestehenden Software",
|
||||
"Vollautomatisierte Workflows ohne manuelle Zwischenschritte",
|
||||
"Gezielter KI-Einsatz: Intelligentes Auslesen von Belegen, Vorstrukturieren und Validieren",
|
||||
"Kompakte Dokumentation für stabilen Betrieb und einfache spätere Wartung",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
@@ -192,77 +233,59 @@ export const de: Content = {
|
||||
text: "Sie beschreiben den Ablauf und welche Programme beteiligt sind.",
|
||||
},
|
||||
{
|
||||
title: "Ich sehe mir an, wie es heute läuft",
|
||||
text: "Schritt für Schritt, mit den Menschen, die es machen. Oft lässt sich ein Teil automatisieren, nicht alles.",
|
||||
title: "Ist-Analyse des Prozesses",
|
||||
text: "Wir analysieren den bestehenden Ablauf mit den Beteiligten und identifizieren die lohnendsten Automatisierungspotenziale.",
|
||||
},
|
||||
{
|
||||
title: "Ich verbinde die Systeme",
|
||||
text: "Einen Schritt nach dem anderen. Sie testen es mit echten Fällen, bevor es die Handarbeit ersetzt.",
|
||||
title: "Schrittweise Integration",
|
||||
text: "Die Anbindung erfolgt modular und wird mit realen Datensätzen validiert, bevor der Prozess produktiv geschaltet wird.",
|
||||
},
|
||||
{
|
||||
title: "Es läuft von selbst",
|
||||
text: "Sie bekommen eine Notiz dazu, was es tut und was zu prüfen ist, wenn sich eines der Programme ändert.",
|
||||
title: "Automatisierter Betrieb",
|
||||
text: "Der Prozess läuft selbstständig. Sie erhalten eine prägnante Dokumentation für Betrieb und zukünftige Anpassungen.",
|
||||
},
|
||||
],
|
||||
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:
|
||||
"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,
|
||||
orientation: "100 EUR pro Stunde, nach Schätzung",
|
||||
orientationNote:
|
||||
"Der Aufwand hängt von der Komplexität des Workflows ab. Nach der Erstanalyse erhalten Sie eine verbindliche Schätzung. Eventuelle Gebühren für Drittsoftware tragen Sie direkt.",
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
service: "Gestaltung und Entwicklung von Websites",
|
||||
need: "Ich brauche einfach eine Website",
|
||||
need: "Ich brauche eine professionelle Unternehmenswebsite",
|
||||
forWhom:
|
||||
"Unternehmen und Selbstständige, die sich nicht selbst darum kümmern wollen",
|
||||
headline: "Eine Website, für Sie gebaut und betreut.",
|
||||
"Unternehmen und Selbstständige, die eine schlüsselfertige, moderne Weblösung suchen",
|
||||
headline:
|
||||
"Ihre Unternehmenswebsite: Maßgeschneidert entwickelt und zuverlässig betreut.",
|
||||
intro:
|
||||
"Sie wollen keinen Website-Baukasten lernen und sich nicht mit Hosting beschäftigen. Sie sagen mir, was Ihr Unternehmen macht und was die Seite erreichen soll. Ich gestalte sie, baue sie und bringe sie online.",
|
||||
"Keine zeitraubenden Baukästen, keine Standard-Templates und kein Ärger mit Hosting: Sie schildern mir Ihr Angebot und Ihre Ziele – ich übernehme Konzeption, Design, technische Umsetzung und den Go-Live.",
|
||||
youGet: [
|
||||
"Ein Design für Ihr Unternehmen, keine Vorlage",
|
||||
"Funktioniert auf Handy, Tablet und Desktop",
|
||||
"Um das Hosting kümmere ich mich, Sie betreuen keinen Server",
|
||||
"Änderungen per E-Mail: Sie schreiben, was Sie brauchen, ich setze es um",
|
||||
"Individuelles Design, abgestimmt auf Ihr Unternehmen – keine austauschbare Vorlage",
|
||||
"Optimale Darstellung auf Smartphones, Tablets und Desktops (Responsive Design)",
|
||||
"Hosting, Domain und SSL aus einer Hand – ohne technischen Aufwand für Sie",
|
||||
"Änderungen unkompliziert per E-Mail: Sie senden Ihre Wünsche, ich setze sie zeitnah um",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "Sie erzählen mir von Ihrem Unternehmen und wofür die Website da ist.",
|
||||
text: "Sie erzählen mir von Ihrem Unternehmen, Ihrer Zielgruppe und den Zielen der Website.",
|
||||
},
|
||||
{
|
||||
title: "Ein Entwurf zum Ansehen",
|
||||
text: "Sie bekommen eine erste Version zum Durchklicken. Wir passen sie an, bis sie passt.",
|
||||
title: "Interaktiver Entwurf",
|
||||
text: "Sie erhalten einen ersten klickbaren Entwurf und wir stimmen das Feintuning gemeinsam ab.",
|
||||
},
|
||||
{
|
||||
title: "Ich baue sie und bringe sie online",
|
||||
text: "Mit Ihren Texten und Bildern und einem Kontaktformular. Sie geben sie frei, bevor sie live geht.",
|
||||
title: "Umsetzung & Go-Live",
|
||||
text: "Technische Fertigstellung mit Ihren Inhalten, optimierten Ladezeiten und Kontaktmöglichkeiten. Online geht es erst nach Ihrer Freigabe.",
|
||||
},
|
||||
{
|
||||
title: "Änderungen danach",
|
||||
text: "Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.",
|
||||
title: "Laufende Pflege",
|
||||
text: "Neue Inhalte, aktuelle Öffnungszeiten oder geänderte Preise: Eine kurze Nachricht genügt.",
|
||||
},
|
||||
],
|
||||
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:
|
||||
"Gilt für eine typische, mehrseitige Unternehmenswebsite. Die spätere Betreuung liegt bei 89 EUR im Monat.",
|
||||
},
|
||||
],
|
||||
scenes: {
|
||||
@@ -271,30 +294,37 @@ export const de: Content = {
|
||||
talkLength: "etwa 20 Minuten",
|
||||
days: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
daysNote: "zum Beispiel drei Tage pro Woche",
|
||||
review: "fertige Teile gehen an Sie zur Durchsicht",
|
||||
review: "fertige Zwischenstände gehen an Sie zur Durchsicht",
|
||||
week: "Woche {n}",
|
||||
monthEnd: "Monatsende",
|
||||
notice: "zwei Wochen Frist",
|
||||
repair: "Wackliges wird getestet und behoben",
|
||||
summaryParts: ["gefunden", "geändert", "als Nächstes"],
|
||||
summaryNote: "so geschrieben, dass Sie folgen können",
|
||||
options: ["weiter mit mir", "Ihr eigenes Team", "hier aufhören"],
|
||||
repair: "Kritische Stellen testen & stabilisieren",
|
||||
summaryParts: ["Befunde", "Fixes", "Nächste Schritte"],
|
||||
summaryNote: "klar und verständlich dokumentiert",
|
||||
options: [
|
||||
"Zusammenarbeit fortsetzen",
|
||||
"Übergabe an Ihr Team",
|
||||
"Projekt abschließen",
|
||||
],
|
||||
online: "online",
|
||||
exampleDomain: "ihrefirma.de",
|
||||
firstVersion: "eine erste Version zum Durchklicken",
|
||||
approve: "Sie geben frei, bevor es live geht",
|
||||
changeByEmail: "Sie schreiben, ich setze es um",
|
||||
firstVersion: "Erster klickbarer Entwurf",
|
||||
approve: "Freigabe vor dem Go-Live",
|
||||
changeByEmail: "Änderungen einfach per E-Mail",
|
||||
tools: ["Shop", "Buchhaltung", "E-Mail", "CRM"],
|
||||
byHand: "heute: von Hand kopiert",
|
||||
wiring: "Schritt für Schritt verbunden",
|
||||
running: "die Schritte laufen ohne Handarbeit",
|
||||
byHand: "Bisher: manuelle Übertragung",
|
||||
wiring: "Schrittweise integriert",
|
||||
running: "Abläufe laufen vollautomatisch",
|
||||
},
|
||||
},
|
||||
automation: {
|
||||
label: "Prozessautomatisierung",
|
||||
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
|
||||
title: {
|
||||
lead: "Routineaufgaben automatisieren.",
|
||||
rest: "Medienbrüche und Fehlerquellen eliminieren.",
|
||||
},
|
||||
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:",
|
||||
"In vielen Unternehmen werden Daten noch manuell von einer Software in die nächste übertragen: exportieren, abtippen, Benachrichtigungen versenden. Gezielte Automatisierung verknüpft Ihre Systeme über APIs, sodass Prozesse nahtlos im Hintergrund ablaufen. Drei typische Beispiele:",
|
||||
examplesLabel: "Beispielabläufe",
|
||||
running: "läuft",
|
||||
runComplete: "Durchlauf fertig",
|
||||
@@ -302,21 +332,21 @@ export const de: Content = {
|
||||
byHand: "Von Hand",
|
||||
automated: "Automatisiert",
|
||||
kinds: {
|
||||
trigger: "Auslöser: etwas passiert",
|
||||
rule: "Regel: eine feste Prüfung",
|
||||
ai: "KI-Schritt: liest oder sortiert",
|
||||
action: "Aktion: ein System tut etwas",
|
||||
human: "Mensch: entscheidet die unklaren Fälle",
|
||||
trigger: "Auslöser: ein Ereignis tritt ein",
|
||||
rule: "Regel: vordefinierte Prüfung",
|
||||
ai: "KI-Schritt: automatisiertes Auslesen oder Sortieren",
|
||||
action: "Aktion: System führt Operation aus",
|
||||
human: "Menschliche Prüfung: Freigabe bei Grenzfällen",
|
||||
},
|
||||
workflows: {
|
||||
order: {
|
||||
name: "Bestellung bis Rechnung",
|
||||
summary:
|
||||
"Im Shop geht eine neue Bestellung ein. Rechnung, Kundendatensatz und Bestätigung folgen, ohne dass jemand tippt.",
|
||||
"Geht im Online-Shop eine Bestellung ein, werden Rechnung, CRM-Eintrag und Bestätigung vollautomatisch erzeugt.",
|
||||
byHand:
|
||||
"Jemand öffnet die Bestellung, schreibt die Rechnung, aktualisiert den Kundendatensatz und verschickt eine E-Mail.",
|
||||
"Manuell: Bestellung manuell prüfen, Rechnung erstellen, Kundendaten im CRM pflegen und Bestätigung versenden.",
|
||||
automated:
|
||||
"Die Bestellung stößt alles an. Menschen sehen nur das fertige Ergebnis.",
|
||||
"Automatisiert: Die Bestellung stößt alle Folgeschritte an – Ihr Team behält den Überblick.",
|
||||
nodes: {
|
||||
shop: {
|
||||
system: "Shop",
|
||||
@@ -348,11 +378,11 @@ export const de: Content = {
|
||||
documents: {
|
||||
name: "Eingehende Dokumente",
|
||||
summary:
|
||||
"Rechnungen kommen als PDF per E-Mail. Ein KI-Schritt liest sie, eine Regel prüft das Ergebnis, und ein Mensch sieht nur die unklaren Fälle.",
|
||||
"Eingehende PDF-Rechnungen werden per KI strukturiert ausgelesen und validiert – Mitarbeiter greifen nur bei Grenzfällen ein.",
|
||||
byHand:
|
||||
"Jemand öffnet jedes PDF, liest Lieferant, Betrag und Datum ab und tippt sie in die Buchhaltung.",
|
||||
"Manuell: Jedes Dokument einzeln öffnen, Kopfdaten ablesen und händisch in die Buchhaltung übertragen.",
|
||||
automated:
|
||||
"Klare Fälle werden direkt gebucht. Unklare gehen mit dem Dokument im Anhang an einen Menschen.",
|
||||
"Automatisiert: Eindeutige Belege werden direkt verbucht, unklare Fälle zur kurzen manuellen Freigabe vorgelegt.",
|
||||
nodes: {
|
||||
inbox: {
|
||||
system: "Posteingang",
|
||||
@@ -371,20 +401,20 @@ export const de: Content = {
|
||||
log: "automatisch gebucht",
|
||||
},
|
||||
ask: {
|
||||
system: "Ein Mensch",
|
||||
label: "Prüfen, entscheiden",
|
||||
log: "unklar: zur Prüfung an einen Menschen",
|
||||
system: "Mitarbeiter",
|
||||
label: "Prüfen & freigeben",
|
||||
log: "Grenzfall: Zur manuellen Prüfung vorgelegt",
|
||||
},
|
||||
},
|
||||
},
|
||||
lead: {
|
||||
name: "Anfrage bis Nachfassen",
|
||||
summary:
|
||||
"Jemand füllt das Kontaktformular aus. Der Kontakt wird angelegt, die richtige Person erfährt davon, und eine Erinnerung sorgt dafür, dass niemand es vergisst.",
|
||||
"Geht eine Anfrage ein, wird der Lead automatisch im CRM erfasst, das Vertriebsteam benachrichtigt und eine Wiedervorlage angelegt.",
|
||||
byHand:
|
||||
"Jemand kopiert die Anfrage ins CRM, leitet sie per E-Mail weiter und hofft, dass das Nachfassen nicht vergessen wird.",
|
||||
"Manuell: Anfrage händisch ins CRM kopieren, per Mail weiterleiten und manuell an das Nachfassen denken.",
|
||||
automated:
|
||||
"Die Anfrage wird abgelegt, zugewiesen und zum geplanten Zeitpunkt nachverfolgt.",
|
||||
"Automatisiert: Vollständige Erfassung, sofortige Zuweisung und verlässliche Wiedervorlage.",
|
||||
nodes: {
|
||||
form: {
|
||||
system: "Website",
|
||||
@@ -418,25 +448,25 @@ export const de: Content = {
|
||||
{
|
||||
title: "Wann es sich lohnt",
|
||||
items: [
|
||||
"Dieselben Schritte wiederholen sich täglich oder wöchentlich",
|
||||
"Daten werden von einem Programm ins andere getippt",
|
||||
"Fehler passieren, weil die Arbeit eintönig ist",
|
||||
"Dieselben Arbeitsschritte wiederholen sich täglich oder wöchentlich",
|
||||
"Daten werden manuell zwischen verschiedenen Programmen übertragen",
|
||||
"Fehler und Verzögerungen entstehen durch monotone Routinearbeiten",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Was bei Menschen bleibt",
|
||||
title: "Was beim Team bleibt",
|
||||
items: [
|
||||
"Entscheidungen und Ausnahmen",
|
||||
"Alles, was ein Kunde von einem Menschen hören sollte",
|
||||
"Das letzte Wort, wenn die Automatisierung unsicher ist",
|
||||
"Strategische Entscheidungen und fachliche Ausnahmen",
|
||||
"Persönlicher Kundenkontakt und Beratung",
|
||||
"Die finale Entscheidung, wenn ein Fall unklar ist",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Was ich von Ihnen brauche",
|
||||
title: "Was ich von Ihnen benötige",
|
||||
items: [
|
||||
"Zugang zu den Programmen und ihren Schnittstellen (APIs)",
|
||||
"Jemanden, der den Ablauf so kennt, wie er heute gemacht wird",
|
||||
"Echte Beispiele zum Testen",
|
||||
"Zugänge zu den beteiligten Systemen und deren Schnittstellen (APIs)",
|
||||
"Einblicke in den bisherigen Ablauf durch die zuständigen Kollegen",
|
||||
"Reale Beispieldaten zum Testen und Validieren",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -445,10 +475,10 @@ export const de: Content = {
|
||||
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.",
|
||||
"Klassische Präsentationen sind starre Foliendateien, die als schwerfällige Anhänge verschickt werden. Ich entwickle Präsentationen als interaktive Web-Erlebnisse: direkt im Browser abrufbar, responsiv für jedes Endgerät und mit multimedialen Möglichkeiten, die statische Folien nicht bieten.",
|
||||
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.",
|
||||
"Klassische Folien behalten ihr Format starr bei und sind auf Smartphones kaum lesbar. Eine Web-Präsentation passt Layout und Typografie responsiv an die jeweilige Bildschirmgröße an.",
|
||||
slides: "Foliendatei",
|
||||
web: "Web-Präsentation",
|
||||
pickScreen: "Gezeigt auf",
|
||||
@@ -466,20 +496,20 @@ export const de: Content = {
|
||||
},
|
||||
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: "Optimal auf jedem Bildschirm",
|
||||
text: "Egal ob Beamer, Laptop oder Smartphone: Layout und Typografie passen sich der jeweiligen Bildschirmgröße automatisch 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: "Ein zentraler Link, immer aktuell",
|
||||
text: "Keine veralteten Datei-Versionen mehr im Umlauf: Aktualisierungen sind sofort für jeden Empfänger live verfügbar.",
|
||||
},
|
||||
{
|
||||
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: "Echte Interaktivität statt Screenshots",
|
||||
text: "Binden Sie Live-Daten, funktionierende Demos oder klickbare Prototypen direkt ein – statt statischer Bildschirmfotos.",
|
||||
},
|
||||
{
|
||||
title: "Auch danach noch nützlich",
|
||||
text: "Nach dem Termin ist sie eine Seite, die man allein lesen, weitergeben und wiederfinden kann.",
|
||||
title: "Bleibender Eindruck nach dem Termin",
|
||||
text: "Nach dem Pitch bleibt Ihre Präsentation als hochwertige Landingpage abrufbar, teilbar und suchbar.",
|
||||
},
|
||||
],
|
||||
goodFor: {
|
||||
@@ -492,46 +522,131 @@ export const de: Content = {
|
||||
],
|
||||
},
|
||||
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.",
|
||||
title: "Wann klassische Folien genügen",
|
||||
text: "Für ein kurzes internes Meeting ist eine PowerPoint- oder Keynote-Datei die pragmatischere Lösung. Eine Web-Präsentation lohnt sich bei wichtigen Pitches, wiederkehrenden Vorträgen oder wenn sie Ihr Unternehmen repräsentiert.",
|
||||
},
|
||||
cta: "Nach einer Präsentation fragen",
|
||||
},
|
||||
story: {
|
||||
hello:
|
||||
"Für anspruchsvolle Webprojekte brauchen Sie verlässliche Umsetzungskompetenz.",
|
||||
services: "Zu Beginn klären wir Ihre konkreten Ziele und Anforderungen.",
|
||||
automation:
|
||||
"Oft braucht es keine neue Software, sondern nahtlos verzahnte Prozesse.",
|
||||
presentations:
|
||||
"Oder eine Präsentation, die Ihr Gegenüber nachhaltig überzeugt.",
|
||||
work: "Im Ergebnis: Lösungen, die dauerhaft stabil und performant funktionieren.",
|
||||
faq: "Häufige Fragen vor dem Start.",
|
||||
contact: "Lassen Sie uns unverbindlich sprechen.",
|
||||
},
|
||||
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 automatisierte Tests Deployments absichern.",
|
||||
label: "Freelance",
|
||||
title: {
|
||||
lead: "Erfahrene Verstärkung für Ihr Team.",
|
||||
rest: "Technische Details & Arbeitsweise.",
|
||||
},
|
||||
intro:
|
||||
"Für Engineering Leads, CTOs und Produktteams: Hier erfahren Sie, bei welchen Vorhaben ich unterstützen kann, auf welchen Tech-Stack ich setze und wie meine Qualitätssicherung aufgebaut ist.",
|
||||
pipelineLabel: "Absicherung",
|
||||
faq: {
|
||||
title: { lead: "Gut zu wissen,", rest: "bevor Sie buchen." },
|
||||
items: [
|
||||
{
|
||||
question: "Wie flexibel ist die Zusammenarbeit?",
|
||||
answer:
|
||||
"Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
|
||||
},
|
||||
{
|
||||
question:
|
||||
"Können Sie in ein bestehendes Team und eine gewachsene Codebasis einsteigen?",
|
||||
answer:
|
||||
"Ja, absolut. Durch langjährige Erfahrung in Agenturen und SaaS-Produktteams finde ich mich schnell in gewachsenen Codebasen und bestehenden Team-Workflows zurecht.",
|
||||
},
|
||||
{
|
||||
question: "Wem gehört der Code?",
|
||||
answer:
|
||||
"Vollständig Ihnen. Sämtlicher Code und alle Entwürfe gehen in Ihr Eigentum über – ohne Abhängigkeiten oder Vendor Lock-in.",
|
||||
},
|
||||
{
|
||||
question: "Welche Bereiche decken Sie nicht ab?",
|
||||
answer:
|
||||
"Native Mobile Apps (iOS/Android Store-Apps), Windows-Desktop-Software und Server-Hardware-Administration. Sollte Ihr Projekt das erfordern, kommuniziere ich das direkt im Erstgespräch.",
|
||||
},
|
||||
],
|
||||
},
|
||||
cases: { lead: "Wofür Teams mich buchen.", rest: "Zwei typische Fälle." },
|
||||
booking: {
|
||||
label: "Buchung",
|
||||
title: {
|
||||
lead: "Monatsweise Zusammenarbeit.",
|
||||
rest: "Planbare Kapazitäten.",
|
||||
},
|
||||
text: "Ich stehe Ihnen an fest vereinbarten Wochentagen zur Verfügung, jeweils gebucht im Monatskontingent. Sie zahlen transparent nur die tatsächlich geleisteten Stunden.",
|
||||
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",
|
||||
"Monatliche Abrechnung mit detailliertem Stundennachweis",
|
||||
"Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen",
|
||||
"Keine Buchung einzelner Tage",
|
||||
],
|
||||
cta: "Verfügbarkeit anfragen",
|
||||
remote: {
|
||||
title: "Remote & fokussiertes Arbeiten",
|
||||
text: "Ich arbeite ortsunabhängig mit flexibler Zeiteinteilung. Für Team-Meetings, Pair Programming und Absprachen stehe ich nach Vereinbarung zuverlässig bereit. Statt permanenter Unterbrechungen setze ich auf fokussierte Deep-Work-Phasen – so kommt Ihr Projekt zügig und in hoher Qualität voran.",
|
||||
},
|
||||
},
|
||||
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." },
|
||||
title: {
|
||||
lead: "Projekte & Schwerpunkte:",
|
||||
rest: "Wo meine Stärken liegen.",
|
||||
},
|
||||
fit: [
|
||||
{
|
||||
title: "Passt gut",
|
||||
title: "Ideale Einsatzbereiche",
|
||||
items: [
|
||||
"Web-Apps: Dashboards, Portale, interne Werkzeuge",
|
||||
"Oberflächen, bei denen Design und Bedienbarkeit zählen",
|
||||
"Interfaces, bei denen Design, Performance und UX zählen",
|
||||
"Unternehmenswebsites, auch in mehreren Sprachen",
|
||||
"Web3-Frontends: Wallets, Live-Daten, Dashboards",
|
||||
"Automatisierung: Systeme und ihre APIs verbinden",
|
||||
"Prozessautomatisierung: Anbindung und Synchronisation über APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Auch möglich",
|
||||
title: "Ebenfalls abgedeckt",
|
||||
items: [
|
||||
"Das Backend, das eine App braucht: APIs, Login, Integrationen",
|
||||
"Werkzeuge für die Kommandozeile",
|
||||
"Bestehenden Code übernehmen und aufräumen",
|
||||
"App-Backend: REST- und GraphQL-APIs, Authentifizierung, Datenbanken",
|
||||
"Entwickler-Werkzeuge und CLI-Tools",
|
||||
"Refactoring und Modernisierung bestehender Codebasen",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Nicht mein Gebiet",
|
||||
title: "Außerhalb meines Fokus",
|
||||
items: [
|
||||
"Mobile Apps für die App-Stores",
|
||||
"Windows-Software",
|
||||
"Serverbetrieb, spezialisierte Datenbankarbeit",
|
||||
"Native Mobile Apps für App-Stores",
|
||||
"Klassische Windows-Software",
|
||||
"Server-Hardwarebetrieb und dedizierte Datenbankadministration",
|
||||
],
|
||||
},
|
||||
],
|
||||
techLabel: "Womit ich arbeite",
|
||||
tech: [
|
||||
{
|
||||
area: "Oberfläche",
|
||||
area: "Interface",
|
||||
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
|
||||
},
|
||||
{
|
||||
@@ -544,9 +659,9 @@ export const de: Content = {
|
||||
items: ["Automatisierte Tests", "Docker", "CI/CD"],
|
||||
},
|
||||
],
|
||||
pipelineTitle: "Was passiert, bevor eine Änderung live geht.",
|
||||
pipelineTitle: "Qualitätssicherung vor jedem Deployment.",
|
||||
pipelineIntro:
|
||||
"Software hängt zusammen: Eine Änderung an einer Stelle kann eine andere kaputt machen. Ich sichere mit automatisierten Tests ab, was ich baue, und jede Änderung muss sie bestehen, bevor sie veröffentlicht wird. So sieht ein solcher Durchlauf aus.",
|
||||
"In komplexen Webanwendungen können lokale Code-Änderungen unerwartete Seiteneffekte auslösen. Deshalb sichere ich alle Entwicklungen mit einer automatisierten CI/CD-Pipeline und Tests ab. Jede Änderung muss alle Qualitätsprüfungen bestehen, bevor sie live geschaltet wird. So sieht ein Durchlauf aus:",
|
||||
pipeline: {
|
||||
simulate: "Änderung simulieren in",
|
||||
scenarios: {
|
||||
@@ -579,16 +694,16 @@ export const de: Content = {
|
||||
stages: ["Lint", "Typen", "Tests", "Deploy"],
|
||||
explanations: [
|
||||
{
|
||||
title: "Eine Änderung kommt herein",
|
||||
text: "Im Bereich {area} wird etwas geändert. Bevor es live gehen kann, wird das ganze Projekt automatisch geprüft.",
|
||||
title: "Code-Änderung eingereicht",
|
||||
text: "Im Modul {area} wird eine Änderung committet. Vor dem Deployment wird das gesamte Projekt automatisch auf Herz und Nieren geprüft.",
|
||||
},
|
||||
{
|
||||
title: "Ein Test schlägt fehl, die Freigabe ist gesperrt",
|
||||
text: "Die Änderung hat an einer anderen Stelle etwas kaputt gemacht. Das fällt hier auf, bei mir, und nichts davon erreicht Ihre Nutzer.",
|
||||
title: "Test schlägt fehl – Release blockiert",
|
||||
text: "Die Änderung hat einen unerwarteten Seiteneffekt ausgelöst. Die Test-Pipeline schlägt an und blockiert das Deployment – fehlerhafter Code erreicht niemals Ihre Nutzer.",
|
||||
},
|
||||
{
|
||||
title: "Behoben, erneut geprüft, veröffentlicht",
|
||||
text: "Ich repariere, was kaputtgegangen ist, der Durchlauf besteht, und erst dann geht die Änderung live.",
|
||||
title: "Behoben, verifiziert und veröffentlicht",
|
||||
text: "Die Ursache wird korrigiert, die Pipeline läuft vollständig grün durch und erst dann geht die Änderung sicher live.",
|
||||
},
|
||||
],
|
||||
runAgain: "Erneut starten",
|
||||
@@ -608,47 +723,50 @@ export const de: Content = {
|
||||
blocked: "Freigabe gesperrt",
|
||||
released: "veröffentlicht",
|
||||
footnote:
|
||||
"Eine Illustration mit erfundenen Dateinamen und Zahlen, einem echten Testlauf nachempfunden. Tests finden nicht jeden Fehler, aber diese Art schon.",
|
||||
"Exemplarische Darstellung eines realen CI/CD-Pipelineschritts. Automatisierte Tests fangen Regressionsfehler frühzeitig und zuverlässig ab.",
|
||||
},
|
||||
},
|
||||
working: {
|
||||
label: "Zusammenarbeit",
|
||||
title: { lead: "So arbeite ich.", rest: "Nichts Ungewöhnliches." },
|
||||
title: {
|
||||
lead: "So läuft die Zusammenarbeit.",
|
||||
rest: "Direkt, transparent und verbindlich.",
|
||||
},
|
||||
principles: [
|
||||
{
|
||||
title: "Ein Ansprechpartner",
|
||||
text: "Sie sprechen mit mir, und ich bin derjenige, der die Arbeit macht.",
|
||||
title: "Fester Ansprechpartner",
|
||||
text: "Sie sprechen direkt mit mir – und ich bin auch derjenige, der Ihr Projekt technisch umsetzt.",
|
||||
},
|
||||
{
|
||||
title: "Stunden, die Sie nachprüfen können",
|
||||
text: "Ich erfasse meine Zeit. Die Monatsrechnung zeigt, wofür die Stunden angefallen sind.",
|
||||
title: "Transparente Zeiterfassung",
|
||||
text: "Sämtliche Stunden werden minutengenau erfasst. Die Monatsrechnung schlüsselt exakt auf, woran gearbeitet wurde.",
|
||||
},
|
||||
{
|
||||
title: "Sorgfältige Arbeit",
|
||||
text: "Ich teste, was ich baue, bevor ich es übergebe, und sage es, wenn etwas noch nicht fertig ist oder ich mir nicht sicher bin.",
|
||||
title: "Verlässliche Qualität",
|
||||
text: "Ich übergebe ausschließlich getesteten Code und kommuniziere Risiken oder Unklarheiten frühzeitig und offen.",
|
||||
},
|
||||
{
|
||||
title: "Was ich baue, gehört Ihnen",
|
||||
text: "Code und Design gehören Ihnen. Sie können beides an einen anderen Entwickler übergeben.",
|
||||
title: "Volles Eigentum",
|
||||
text: "Code und Design gehören zu 100 % Ihnen. Eine Übergabe an interne Teams oder andere Entwickler ist jederzeit problemlos möglich.",
|
||||
},
|
||||
],
|
||||
diagram: {
|
||||
description:
|
||||
"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.",
|
||||
"In vielen Agenturen wandert ein Briefing durch viele Hände, bevor es beim Entwickler ankommt. Bei mir haben Sie den direkten Draht zu demjenigen, der den Code schreibt.",
|
||||
chain: ["Sie", "Vertrieb", "Manager", "Designer", "Entwickler"],
|
||||
you: "Sie",
|
||||
me: "Ich",
|
||||
often: "oft",
|
||||
withMe: "bei mir",
|
||||
oftenLong: "oft: eine Anfrage wird weitergereicht",
|
||||
withMeLong: "bei mir: eine direkte Leitung",
|
||||
oftenLong: "Klassisch: Viele Zwischenstationen und Informationsverluste",
|
||||
withMeLong: "Bei mir: Direkter Austausch ohne Reibungsverluste",
|
||||
},
|
||||
},
|
||||
work: {
|
||||
label: "Meine Arbeit",
|
||||
title: {
|
||||
lead: "Zwei Websites, die ich gebaut habe.",
|
||||
rest: "Beide sind live.",
|
||||
lead: "Ausgewählte Web-Projekte.",
|
||||
rest: "Erfolgreich im Live-Einsatz.",
|
||||
},
|
||||
items: {
|
||||
"klz-cables.com": {
|
||||
@@ -675,41 +793,37 @@ export const de: Content = {
|
||||
openLive: "Live-Website öffnen",
|
||||
desktopAlt: "{name} auf einem Desktop-Bildschirm",
|
||||
phoneAlt: "{name} auf einem Handy",
|
||||
note: "Neben der Arbeit für Kunden baue ich eigene Software, unter anderem ein System in Rust und Solidity, das auf sieben Blockchains läuft.",
|
||||
note: "Neben Kundenprojekten entwickle ich eigene Software – unter anderem ein System in Rust und Solidity, das auf sieben Blockchains im Produktiveinsatz ist.",
|
||||
},
|
||||
faq: {
|
||||
label: "Fragen",
|
||||
title: { lead: "Gut zu wissen,", rest: "bevor wir sprechen." },
|
||||
items: [
|
||||
{
|
||||
question: "Ich bin kein Technikmensch. Ist das ein Problem?",
|
||||
question:
|
||||
"Ich habe keinen technischen Hintergrund. Ist das ein Problem?",
|
||||
answer:
|
||||
"Nein. Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen. Code müssen Sie nicht lesen.",
|
||||
"Überhaupt nicht. Ich erkläre technische Zusammenhänge in verständlicher Sprache. Sie müssen weder Code lesen noch Fachjargon beherrschen, um fundierte Entscheidungen zu treffen.",
|
||||
},
|
||||
{
|
||||
question: "Wie lange bin ich gebunden?",
|
||||
question: "Was kostet ein Projekt?",
|
||||
answer:
|
||||
"Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
|
||||
"Das richtet sich nach dem konkreten Projektumfang. Nach einem kurzen Kennenlernen erhalten Sie ein transparentes, schriftliches Angebot. Die Arbeit beginnt erst nach Ihrer ausdrücklichen Freigabe.",
|
||||
},
|
||||
{
|
||||
question: "Wem gehört, was Sie bauen?",
|
||||
question: "Wem gehört das Projektergebnis?",
|
||||
answer:
|
||||
"Ihnen. Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.",
|
||||
"Vollständig Ihnen. Sämtlicher Code, Designs und Inhalte gehören Ihnen – ohne Abhängigkeiten oder Vendor Lock-in.",
|
||||
},
|
||||
{
|
||||
question: "Können Sie mit unserem bestehenden Team und Code arbeiten?",
|
||||
question: "Wer übernimmt die spätere Betreuung der Website?",
|
||||
answer:
|
||||
"Ja. Ich habe jahrelang in Agenturen und Produktteams gearbeitet, der Einstieg in ein bestehendes Projekt ist mir vertraut.",
|
||||
"Auf Wunsch übernehme ich die laufende Betreuung. Für 89 EUR im Monat ist das Hosting auf sicheren Servern in Deutschland abgedeckt und kleine inhaltliche Anpassungen sind inklusive. Sie müssen sich um keine Technik kümmern. Bestehende E-Mail-Postfächer bleiben unverändert bei Ihrem Anbieter.",
|
||||
},
|
||||
{
|
||||
question: "Wie erreiche ich Sie?",
|
||||
question: "Wie erreichen wir 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.",
|
||||
"Am besten per E-Mail oder im Video-Call. Termine stimmen wir flexibel so ab, wie es in Ihren Zeitplan passt.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -718,7 +832,7 @@ export const de: Content = {
|
||||
{ text: "REDEN", tone: "bits" },
|
||||
{ text: "WIR.", tone: "solid" },
|
||||
],
|
||||
lead: "Wählen Sie aus, was Sie brauchen, und die E-Mail schreibt sich von selbst. Oder schreiben Sie mir direkt:",
|
||||
lead: "Wählen Sie Ihr Anliegen aus, um eine passende E-Mail-Vorlage zu öffnen – oder schreiben Sie mir direkt:",
|
||||
newMessage: "Neue Nachricht",
|
||||
draft: "Entwurf",
|
||||
need: "Ich brauche",
|
||||
@@ -733,40 +847,40 @@ export const de: Content = {
|
||||
id: "developer",
|
||||
label: "Einen Entwickler",
|
||||
draft: {
|
||||
subject: "Ein Entwickler für unser Produkt",
|
||||
body: `${GREETING}\n\nwir arbeiten an [was Sie bauen] und brauchen Unterstützung bei [was als Nächstes ansteht].\n\nEin erstes Gespräch würde uns um den [Datum oder Woche] passen.\n\n${SIGN_OFF}`,
|
||||
subject: "Entwickler für unser Produkt",
|
||||
body: `${GREETING}\n\nwir arbeiten an [Projekt / Produkt] und suchen Unterstützung bei [anstehende Aufgaben / Bereich].\n\nEin Kennenlernen würde uns um den [Datum oder Zeitraum] passen.\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
label: "Eine zuverlässige App",
|
||||
label: "App stabilisieren",
|
||||
draft: {
|
||||
subject: "Unsere App soll zuverlässig werden",
|
||||
body: `${GREETING}\n\nunsere App [Name oder kurze Beschreibung] läuft, aber [was immer wieder schiefgeht].\n\nKönnten Sie sich das ansehen?\n\n${SIGN_OFF}`,
|
||||
subject: "Unterstützung bei App-Stabilisierung",
|
||||
body: `${GREETING}\n\nunsere Anwendung [Name oder Kurzbeschreibung] läuft, allerdings [Schwachstellen / wiederkehrende Probleme].\n\nKönnen Sie sich das im Rahmen eines Code-Audits ansehen?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
label: "Automatisierung",
|
||||
draft: {
|
||||
subject: "Wir möchten einen Ablauf automatisieren",
|
||||
body: `${GREETING}\n\nwir möchten [den Ablauf] automatisieren. Heute wird er von Hand in [beteiligte Programme] erledigt.\n\nKönnen wir darüber sprechen?\n\n${SIGN_OFF}`,
|
||||
subject: "Prozessautomatisierung anfragen",
|
||||
body: `${GREETING}\n\nwir möchten folgenden Ablauf automatisieren: [Ablauf beschreiben]. Aktuell wird dies manuell zwischen [beteiligte Systeme / Tools] erledigt.\n\nKönnen wir uns dazu kurz austauschen?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
label: "Eine Website",
|
||||
draft: {
|
||||
subject: "Eine Website für mein Unternehmen",
|
||||
body: `${GREETING}\n\nich brauche eine Website für [Ihr Unternehmen]. Sie soll [wofür sie da ist].\n\nWann könnten wir sprechen?\n\n${SIGN_OFF}`,
|
||||
subject: "Neue Unternehmenswebsite",
|
||||
body: `${GREETING}\n\nich plane eine neue Website für [Ihr Unternehmen]. Ziel der Seite ist [Zweck / Zielgruppe].\n\nWann passt Ihnen ein kurzes Erstgespräch?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Etwas anderes",
|
||||
draft: {
|
||||
subject: "Eine Frage",
|
||||
body: `${GREETING}\n\n[was Sie vorhaben]\n\n${SIGN_OFF}`,
|
||||
subject: "Allgemeine Anfrage",
|
||||
body: `${GREETING}\n\n[Ihr Anliegen]\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+363
-245
@@ -1,15 +1,15 @@
|
||||
import type { Content } from "./types";
|
||||
|
||||
const SIGN_OFF = "Best regards";
|
||||
const FIRST_CALL = "A first call, about 20 minutes";
|
||||
const FIRST_CALL = "Intro call, about 20 minutes";
|
||||
const GET_IN_TOUCH = "Get in touch";
|
||||
|
||||
/** The English texts of the site. de.ts holds the same in German. */
|
||||
export const en: Content = {
|
||||
site: {
|
||||
title: "Freelance web developer for web apps & websites | Marc Mintel",
|
||||
title: "Freelance Web Developer for Web Apps & Websites | Marc Mintel",
|
||||
description:
|
||||
"Freelance web developer with 15 years of experience. I build web apps, interfaces and websites with React, Next.js and TypeScript, and automate processes.",
|
||||
"Freelance web developer with 15 years of experience. I build modern web apps, intuitive interfaces and websites with React, Next.js and TypeScript, and automate digital workflows.",
|
||||
jobTitle: "Freelance web developer",
|
||||
ogLocale: "en_US",
|
||||
shareImageAlt:
|
||||
@@ -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",
|
||||
@@ -39,19 +40,23 @@ export const en: Content = {
|
||||
{ text: "& WEBSITES.", tone: "bits" },
|
||||
],
|
||||
lead: "Freelance web developer with 15 years of experience.",
|
||||
leadStrong: "I build web apps, interfaces and websites.",
|
||||
leadStrong: "I develop web apps, interfaces and modern websites.",
|
||||
primaryCta: GET_IN_TOUCH,
|
||||
secondaryCta: "What I do",
|
||||
trust: ["15 years in web", "Remote, EU core hours"],
|
||||
builtAndRunning: "Built and running:",
|
||||
secondaryCta: "Services & Approach",
|
||||
trust: [
|
||||
"Direct technical contact – end-to-end execution",
|
||||
"Binding quote before project kickoff",
|
||||
"Effortless updates by email – no CMS training needed",
|
||||
"Hosted on secure servers in Germany",
|
||||
],
|
||||
},
|
||||
hello: {
|
||||
label: "Hello",
|
||||
title: "I am Marc.",
|
||||
lead: "I have been building for the web for 15 years: first in agencies, later as a senior developer, and now on my own.",
|
||||
lead: "I have been engineering for the web for 15 years: first in digital agencies, later as a senior developer in product teams, and now as an independent software engineer.",
|
||||
paragraphs: [
|
||||
"My strength is the part people see and use: design and interface. I am also at home with what lies behind it, so I can build a complete application and not just its surface.",
|
||||
"You work with me directly. Whether you run a product team or are commissioning something like this for the first time.",
|
||||
"My primary focus is on what users see and interact with: thoughtful design and intuitive interfaces. Having deep expertise in backend architecture and APIs as well, I deliver complete, production-ready applications from a single source.",
|
||||
"You work directly with me – whether you lead an established product team or are launching your first digital initiative.",
|
||||
],
|
||||
portraitAlt: "Marc Mintel, freelance web developer",
|
||||
careerLabel: "The path so far",
|
||||
@@ -61,196 +66,224 @@ export const en: Content = {
|
||||
{
|
||||
years: "2020 – 2023",
|
||||
role: "Product companies",
|
||||
place: "Senior software developer",
|
||||
place: "Senior software engineer",
|
||||
},
|
||||
{
|
||||
years: "Today",
|
||||
role: "Independent",
|
||||
place: "Working directly with clients",
|
||||
place: "Direct collaboration with clients",
|
||||
},
|
||||
],
|
||||
},
|
||||
services: {
|
||||
label: "Services",
|
||||
title: { lead: "What do you need?", rest: "Four typical cases." },
|
||||
tablistLabel: "What do you need?",
|
||||
youGet: "What you get",
|
||||
howItRuns: "How it runs",
|
||||
whatItCosts: "What it costs",
|
||||
title: {
|
||||
lead: "What are you planning?",
|
||||
rest: "Tailored engineering for your goals.",
|
||||
},
|
||||
tablistLabel: "What are you planning?",
|
||||
youGet: "What you receive",
|
||||
howItRuns: "The process",
|
||||
care: {
|
||||
label: "After launch",
|
||||
title: "Ongoing maintenance and support – reliable and long-term.",
|
||||
text: "No need to learn a complex CMS: for content updates or adjustments, a quick email is all it takes.",
|
||||
price: {
|
||||
label: "Maintenance",
|
||||
amount: "89 EUR",
|
||||
note: "per month, net, cancel monthly",
|
||||
},
|
||||
includedLabel: "What is included",
|
||||
included: [
|
||||
"Hosted on secure servers in Germany",
|
||||
"Domain, SSL encryption and technical updates included",
|
||||
"Minor updates: text adjustments, images or business hours",
|
||||
"No complex CMS to manage, no maintenance burden on your end",
|
||||
],
|
||||
termsLabel: "Good to know",
|
||||
terms: [
|
||||
"The first three months are already included in the project price.",
|
||||
"Larger additions (such as new subpages) are quoted transparently in advance.",
|
||||
"Response time within two business days (no 24/7 on-call service).",
|
||||
"No vendor lock-in: all code and content belong 100% to you.",
|
||||
],
|
||||
},
|
||||
offer: {
|
||||
title: "A clear, binding quote before project kickoff.",
|
||||
text: "Send me a brief outline of your project. After a 20-minute intro call, you receive a transparent, written quote. Work only starts once you approve.",
|
||||
cta: "Request a quote",
|
||||
},
|
||||
estimate: {
|
||||
label: "Pricing & estimates",
|
||||
title: "Transparent project estimates",
|
||||
steps: [
|
||||
{
|
||||
title: "Intro call (approx. 20 minutes)",
|
||||
text: "We discuss your goals, requirements and scope.",
|
||||
},
|
||||
{
|
||||
title: "Written estimate",
|
||||
text: "You receive a clear breakdown of estimated hours and deliverables.",
|
||||
},
|
||||
{
|
||||
title: "Budget approval & billing",
|
||||
text: "Work is billed against actual hours. If anything diverges from the estimate, I flag it early.",
|
||||
},
|
||||
],
|
||||
orientationLabel: "For orientation",
|
||||
net: "All prices net.",
|
||||
cta: "Request an estimate",
|
||||
},
|
||||
paths: [
|
||||
{
|
||||
id: "developer",
|
||||
service: "Web app and frontend development",
|
||||
need: "We need a developer for our product",
|
||||
forWhom: "Startups, product teams, agencies, Web3 teams",
|
||||
headline: "An experienced developer who joins your project.",
|
||||
headline: "Senior engineering power for your product team.",
|
||||
intro:
|
||||
"You have a product and more work than your team can handle. I work on agreed days of the week on what is next on your list: new features, the interface, or the parts nobody gets around to.",
|
||||
"You have a product and more initiatives than your in-house team can deliver. I join on agreed days each week to tackle what matters most: new features, UI improvements, or technical refactoring.",
|
||||
youGet: [
|
||||
"Web apps, dashboards, customer portals and internal tools",
|
||||
"Interfaces with attention to design and usability",
|
||||
"Interfaces with a focus on design, performance and usability",
|
||||
"Web3 frontends: wallet connection, live data, dashboards",
|
||||
"Small tools and automation for recurring work",
|
||||
"Custom tools and automation for recurring workflows",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "You tell me what you are building and where you need help. If I am not the right person for it, I say so.",
|
||||
text: "You walk me through your product and current needs. If my skill set is not the ideal match, I will let you know openly.",
|
||||
},
|
||||
{
|
||||
title: "We agree on the days",
|
||||
text: "How many days a week I work for you, and what should come first.",
|
||||
title: "Agreeing on cadence",
|
||||
text: "We define weekly days and prioritize the upcoming roadmap items.",
|
||||
},
|
||||
{
|
||||
title: "I work through the list",
|
||||
text: "Finished parts go to you for review as they are done. I track my hours and you can see what they were spent on.",
|
||||
title: "Focused execution",
|
||||
text: "Completed work is delivered continuously for review. Hours are logged transparently with clear task attribution.",
|
||||
},
|
||||
{
|
||||
title: "Two weeks notice",
|
||||
text: "The agreement can be ended with two weeks notice to the end of a month.",
|
||||
text: "The agreement can be cancelled with two weeks notice to the end of any 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:
|
||||
"Fixed weekly days on a monthly retainer basis. Only actual hours worked are billed.",
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
service: "Code review and stabilisation of web apps",
|
||||
need: "Our app needs to become reliable",
|
||||
forWhom: "Teams whose app was built quickly and is now hard to change",
|
||||
headline: "A week to look at your app and fix what is most urgent.",
|
||||
forWhom:
|
||||
"Teams with fast-growing codebases that have accumulated technical debt or stability issues",
|
||||
headline:
|
||||
"One-week code sprint: audit, stabilization and immediate fixes.",
|
||||
intro:
|
||||
"The app works, but bugs come back and small changes take longer than they should. I take a week to go through the code, fix what I can in that time and write down what I would do next.",
|
||||
"The app is live, but recurring bugs and sluggish releases are slowing your team down. Over a dedicated five-day sprint, I audit the codebase, fix the most pressing issues, and outline a clear roadmap for long-term health.",
|
||||
youGet: [
|
||||
"An honest assessment: what is solid and what is fragile",
|
||||
"Automated tests for the most important parts",
|
||||
"The most urgent problems fixed, as far as a week allows",
|
||||
"A written list of next steps, in plain language",
|
||||
"Thorough architecture audit: solid foundations vs. stability and security risks",
|
||||
"Automated test coverage for mission-critical workflows",
|
||||
"Immediate resolution of high-priority bottlenecks within the sprint",
|
||||
"Actionable roadmap of recommended next steps – documented in plain language",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "You show me the app and tell me where it causes trouble.",
|
||||
text: "You demo the app and walk me through the key pain points and fragile areas.",
|
||||
},
|
||||
{
|
||||
title: "One week of work",
|
||||
text: "I read the code, add tests where they matter most and fix the most urgent problems.",
|
||||
title: "Five-day deep dive",
|
||||
text: "I inspect the code, add tests where leverage is highest, and resolve the most urgent blockers.",
|
||||
},
|
||||
{
|
||||
title: "A written summary",
|
||||
text: "What I found, what I changed and what I recommend. Written so that you can follow it without being a developer.",
|
||||
title: "Actionable report",
|
||||
text: "Findings, applied fixes, and strategic recommendations – written so non-developers can make informed decisions.",
|
||||
},
|
||||
{
|
||||
title: "You decide how to continue",
|
||||
text: "With me, with your own team, or not at all.",
|
||||
title: "You choose how to proceed",
|
||||
text: "Continue together, hand over to your internal team, or wrap up.",
|
||||
},
|
||||
],
|
||||
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 a focused 35-hour sprint. If our initial call reveals that a single week cannot meaningfully move the needle, I will say so upfront.",
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
service: "Process automation and system integration",
|
||||
need: "We want to automate a process",
|
||||
forWhom:
|
||||
"Companies where people copy data between tools or repeat the same steps",
|
||||
"Companies where teams manually copy data across tools or waste hours on repetitive tasks",
|
||||
headline:
|
||||
"Systems that pass work on to each other, so people do not have to.",
|
||||
"Seamless system integration – eliminating manual data entry and routine busywork.",
|
||||
intro:
|
||||
"Someone exports a list here, types it in there and sends an email about it. I connect the tools you already use, so that recurring steps run on their own and your people can do the work that needs them.",
|
||||
"Exporting spreadsheets, retyping entries into CRMs, emailing notifications by hand: I connect your existing software through robust APIs so data flows automatically and your team can focus on high-value work.",
|
||||
youGet: [
|
||||
"Connections between your systems through their interfaces (APIs)",
|
||||
"Recurring workflows that run without manual steps",
|
||||
"Where it helps, steps handled with AI: sorting, summarising, reading documents",
|
||||
"A short description of how it works, so it can be maintained",
|
||||
"Direct API integrations between your existing business tools",
|
||||
"Fully automated workflows without manual intervention",
|
||||
"Targeted AI steps where valuable: document parsing, categorization, summarization",
|
||||
"Concise system documentation for reliable operation and easy maintenance",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "You describe the process and which tools are involved.",
|
||||
text: "You explain the current workflow and the tools involved.",
|
||||
},
|
||||
{
|
||||
title: "I look at how it is done today",
|
||||
text: "Step by step, with the people who do it. Often a part can be automated, not everything.",
|
||||
title: "Process discovery",
|
||||
text: "We review the current process with the people doing the work and identify high-impact automation targets.",
|
||||
},
|
||||
{
|
||||
title: "I connect the systems",
|
||||
text: "One step at a time. You test it with real cases before it replaces the manual work.",
|
||||
title: "Modular integration",
|
||||
text: "Step-by-step implementation, tested with production-like data before replacing manual workflows.",
|
||||
},
|
||||
{
|
||||
title: "It runs on its own",
|
||||
text: "You get a note on what it does and what to check when one of the tools changes.",
|
||||
title: "Autonomous operation",
|
||||
text: "The automation runs smoothly. You receive clean documentation on maintenance and system 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:
|
||||
"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,
|
||||
orientation: "100 EUR per hour, after an estimate",
|
||||
orientationNote:
|
||||
"Project scope depends on workflow complexity. You receive a firm estimate after discovery. Software subscription fees are billed directly by the providers.",
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
service: "Website design and development",
|
||||
need: "I just need a website",
|
||||
forWhom: "Companies and self-employed people who want it taken care of",
|
||||
headline: "A website, built and looked after for you.",
|
||||
need: "I need a professional company website",
|
||||
forWhom:
|
||||
"Businesses and founders looking for a turnkey, bespoke web presence",
|
||||
headline:
|
||||
"Your company website: custom-designed, engineered and maintained.",
|
||||
intro:
|
||||
"You do not want to learn a website builder or deal with hosting. You tell me what your business does and what the site should achieve. I design it, build it and put it online.",
|
||||
"No clunky site builders, no generic templates, and no server headaches: you tell me about your business and your goals – I take care of concept, design, development, and launch.",
|
||||
youGet: [
|
||||
"A design made for your business, not a template",
|
||||
"Works on phone, tablet and desktop",
|
||||
"Hosting taken care of, no server for you to manage",
|
||||
"Changes by email: you write what you need, I make the change",
|
||||
"Bespoke design tailored to your brand – no off-the-shelf templates",
|
||||
"Fully responsive layout across mobile, tablet and desktop",
|
||||
"Hosting, domain and SSL managed end-to-end with zero hassle",
|
||||
"Effortless updates by email: send what you need, and it gets implemented promptly",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "You tell me about your business and what the website is for.",
|
||||
text: "You tell me about your business, target audience, and what the website should achieve.",
|
||||
},
|
||||
{
|
||||
title: "A design to look at",
|
||||
text: "You get a first version to click through. We adjust it until it fits.",
|
||||
title: "Interactive prototype",
|
||||
text: "You receive a clickable initial version and we refine layout and copy together.",
|
||||
},
|
||||
{
|
||||
title: "I build it and put it online",
|
||||
text: "With your texts and images and a contact form. You approve it before it goes live.",
|
||||
title: "Development & launch",
|
||||
text: "Complete technical implementation with content, speed optimization, and forms. Goes live only with your sign-off.",
|
||||
},
|
||||
{
|
||||
title: "Changes afterwards",
|
||||
text: "A new photo or a changed price: you send me an email.",
|
||||
title: "Ongoing care",
|
||||
text: "New images, updated business hours, or revised pricing: just send a quick 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:
|
||||
"Applies to a typical multi-page company website. Ongoing maintenance is 89 EUR per month.",
|
||||
},
|
||||
],
|
||||
scenes: {
|
||||
@@ -259,52 +292,55 @@ export const en: Content = {
|
||||
talkLength: "about 20 minutes",
|
||||
days: ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
||||
daysNote: "for example three days a week",
|
||||
review: "finished parts go to you for review",
|
||||
review: "milestones delivered continuously for review",
|
||||
week: "week {n}",
|
||||
monthEnd: "end of month",
|
||||
notice: "two weeks notice",
|
||||
repair: "fragile parts get tests and fixes",
|
||||
summaryParts: ["found", "changed", "next"],
|
||||
summaryNote: "written so you can follow it",
|
||||
options: ["continue with me", "your own team", "stop here"],
|
||||
repair: "testing & stabilizing critical paths",
|
||||
summaryParts: ["findings", "fixes", "next steps"],
|
||||
summaryNote: "clearly documented for your team",
|
||||
options: ["continue together", "hand over to team", "wrap up"],
|
||||
online: "online",
|
||||
exampleDomain: "yourcompany.com",
|
||||
firstVersion: "a first version to click through",
|
||||
approve: "you approve it before it goes live",
|
||||
changeByEmail: "you send an email, I make the change",
|
||||
firstVersion: "interactive prototype to click through",
|
||||
approve: "sign-off before going live",
|
||||
changeByEmail: "updates easily handled by email",
|
||||
tools: ["Shop", "Accounting", "Email", "CRM"],
|
||||
byHand: "today: copied by hand",
|
||||
wiring: "connected one step at a time",
|
||||
running: "the steps run without anyone copying",
|
||||
byHand: "previously: copied manually",
|
||||
wiring: "integrated step by step",
|
||||
running: "workflows run fully automated",
|
||||
},
|
||||
},
|
||||
automation: {
|
||||
label: "Process automation",
|
||||
title: { lead: "Let the systems", rest: "do the copying." },
|
||||
title: {
|
||||
lead: "Automate routine workflows.",
|
||||
rest: "Eliminate errors and manual handoffs.",
|
||||
},
|
||||
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:",
|
||||
"In many companies, team members spend hours moving data between tools: exporting files, copy-pasting records, emailing updates. Smart automation connects these tools via APIs so data flows in the background. Three typical examples:",
|
||||
examplesLabel: "Example workflows",
|
||||
running: "running",
|
||||
runComplete: "run complete",
|
||||
log: "Log",
|
||||
byHand: "By hand",
|
||||
byHand: "Manual",
|
||||
automated: "Automated",
|
||||
kinds: {
|
||||
trigger: "Trigger: something happens",
|
||||
rule: "Rule: a fixed check",
|
||||
ai: "AI step: reads or sorts",
|
||||
action: "Action: a system does something",
|
||||
human: "Person: decides the unclear cases",
|
||||
trigger: "Trigger: an event occurs",
|
||||
rule: "Rule: predefined business logic",
|
||||
ai: "AI step: automated parsing or classification",
|
||||
action: "Action: system executes operation",
|
||||
human: "Human review: manual sign-off on edge cases",
|
||||
},
|
||||
workflows: {
|
||||
order: {
|
||||
name: "Order to invoice",
|
||||
summary:
|
||||
"A new order arrives in the shop. The invoice, the customer record and the confirmation follow without anyone typing.",
|
||||
"When an order is placed in the shop, the invoice, CRM record and confirmation email are generated automatically.",
|
||||
byHand:
|
||||
"Someone opens the order, writes the invoice, updates the customer record and sends an email.",
|
||||
"Manual: inspect the order, generate the invoice, update the CRM, and email the customer.",
|
||||
automated:
|
||||
"The order triggers everything. People only see the finished result.",
|
||||
"Automated: the order triggers every downstream step – your team simply monitors the result.",
|
||||
nodes: {
|
||||
shop: { system: "Shop", label: "New order", log: "order received" },
|
||||
stock: {
|
||||
@@ -332,11 +368,11 @@ export const en: Content = {
|
||||
documents: {
|
||||
name: "Incoming documents",
|
||||
summary:
|
||||
"Invoices arrive as PDF by email. An AI step reads them, a rule checks the result, and a person only sees the unclear ones.",
|
||||
"Invoices arrive as email attachments. AI extracts structured data, rules validate numbers, and staff only review edge cases.",
|
||||
byHand:
|
||||
"Someone opens every PDF, reads supplier, amount and date, and types them into the accounting system.",
|
||||
"Manual: open each PDF, read vendor, amount and date, and retype them into the accounting system.",
|
||||
automated:
|
||||
"Clear cases are booked directly. Unclear ones go to a person with the document attached.",
|
||||
"Automated: standard invoices are booked directly; ambiguous cases are flagged for quick human approval.",
|
||||
nodes: {
|
||||
inbox: {
|
||||
system: "Inbox",
|
||||
@@ -355,20 +391,20 @@ export const en: Content = {
|
||||
log: "booked automatically",
|
||||
},
|
||||
ask: {
|
||||
system: "A person",
|
||||
label: "Review and decide",
|
||||
log: "unclear: sent to a person for review",
|
||||
system: "Team member",
|
||||
label: "Review & approve",
|
||||
log: "unclear: routed to staff for review",
|
||||
},
|
||||
},
|
||||
},
|
||||
lead: {
|
||||
name: "Enquiry to follow-up",
|
||||
summary:
|
||||
"Someone fills in the contact form. The contact is created, the right person is told, and a reminder makes sure nobody forgets.",
|
||||
"A prospect submits an inquiry form. The lead is created in the CRM, the sales team gets notified, and automated tasks ensure no lead goes cold.",
|
||||
byHand:
|
||||
"Someone copies the enquiry into the CRM, forwards it by email and hopes the follow-up is not forgotten.",
|
||||
"Manual: copy inquiries into the CRM, forward emails to reps, and hope follow-ups aren't forgotten.",
|
||||
automated:
|
||||
"The enquiry is filed, assigned and followed up on schedule.",
|
||||
"Automated: instant lead capture, immediate routing, and scheduled reminders.",
|
||||
nodes: {
|
||||
form: {
|
||||
system: "Website",
|
||||
@@ -402,25 +438,25 @@ export const en: Content = {
|
||||
{
|
||||
title: "Worth automating when",
|
||||
items: [
|
||||
"The same steps repeat every day or every week",
|
||||
"Data is typed from one tool into another",
|
||||
"Mistakes happen because the work is monotonous",
|
||||
"Identical tasks repeat daily or weekly",
|
||||
"Data is manually moved between different software tools",
|
||||
"Delays or mistakes occur due to monotonous routine work",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "What stays with people",
|
||||
title: "What stays with your team",
|
||||
items: [
|
||||
"Decisions and exceptions",
|
||||
"Anything a customer should hear from a person",
|
||||
"The final say when the automation is unsure",
|
||||
"Strategic decisions and nuanced exceptions",
|
||||
"High-touch customer relationships and advisory",
|
||||
"Final sign-off on ambiguous cases",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "What I need from you",
|
||||
items: [
|
||||
"Access to the tools and their interfaces (APIs)",
|
||||
"Someone who knows the process as it is done today",
|
||||
"Real examples to test with",
|
||||
"Access to the software tools and their APIs",
|
||||
"Insights into the current workflow from the people running it",
|
||||
"Real-world test data for validation",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -429,10 +465,10 @@ export const en: Content = {
|
||||
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.",
|
||||
"Most presentations are still static slide decks sent as bulky email attachments. I build presentations as interactive web experiences: accessible in any browser, fully responsive on any device, and capable of live interactions slides could never support.",
|
||||
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.",
|
||||
"Slide decks force fixed aspect ratios and become unreadable on mobile screens. A web presentation dynamically adapts layout and typography to the screen size.",
|
||||
slides: "Slide file",
|
||||
web: "Web presentation",
|
||||
pickScreen: "Shown on",
|
||||
@@ -446,65 +482,149 @@ export const en: Content = {
|
||||
},
|
||||
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: "Flawless on every screen",
|
||||
text: "Whether on a 4K projector, laptop, or smartphone: content reflows naturally for optimal readability.",
|
||||
},
|
||||
{
|
||||
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: "One live link, always up to date",
|
||||
text: "No more circulating outdated attachment versions: edits are instantly live for anyone opening the URL.",
|
||||
},
|
||||
{
|
||||
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: "Real interactivity over static screenshots",
|
||||
text: "Embed live metrics, working product demos, or clickable prototypes directly into the narrative.",
|
||||
},
|
||||
{
|
||||
title: "Still useful afterwards",
|
||||
text: "After the meeting it is a page people can read on their own, pass on and find again.",
|
||||
title: "A lasting asset after the meeting",
|
||||
text: "Instead of being lost in inboxes, your presentation remains a high-impact, shareable landing page.",
|
||||
},
|
||||
],
|
||||
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",
|
||||
"Pitches and proposals sent as a link",
|
||||
"Reports with dynamic, changing data",
|
||||
"Display 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.",
|
||||
title: "When standard slides are sufficient",
|
||||
text: "For a quick internal status update, a PowerPoint or Keynote deck is faster and more pragmatic. A web presentation shines when presenting high-stakes pitches, recurring keynotes, or public-facing company milestones.",
|
||||
},
|
||||
cta: "Inquire about a web presentation",
|
||||
},
|
||||
story: {
|
||||
hello: "Ambitious web projects require proven technical craftsmanship.",
|
||||
services:
|
||||
"First, we clarify your core requirements and architectural goals.",
|
||||
automation:
|
||||
"Often, the right solution isn't more software – it's connected workflows.",
|
||||
presentations:
|
||||
"Or a high-stakes presentation that needs to leave a lasting impression.",
|
||||
work: "The end result: rock-solid software that performs reliably in production.",
|
||||
faq: "Answers to common questions before we begin.",
|
||||
contact: "Let's discuss your project.",
|
||||
},
|
||||
freelance: {
|
||||
metaTitle: "Freelance Web Developer for Engineering Teams | Marc Mintel",
|
||||
metaDescription:
|
||||
"Technical focus, technology stack (React, Next.js, TypeScript), and automated CI/CD safeguards before deploying to production.",
|
||||
label: "Freelance",
|
||||
title: {
|
||||
lead: "Senior engineering power for your team.",
|
||||
rest: "Technical details & approach.",
|
||||
},
|
||||
intro:
|
||||
"For engineering leads, CTOs, and product managers: an overview of where I fit best, the technologies I use, and how automated tests safeguard production releases.",
|
||||
pipelineLabel: "Safeguards",
|
||||
faq: {
|
||||
title: { lead: "Good to know", rest: "before you book." },
|
||||
items: [
|
||||
{
|
||||
question: "How flexible is the engagement?",
|
||||
answer:
|
||||
"Work is billed on time and materials. Either party can cancel with two weeks notice to the end of any month.",
|
||||
},
|
||||
{
|
||||
question: "Can you onboard into our existing codebase and team?",
|
||||
answer:
|
||||
"Yes. Having worked in agencies and SaaS product companies for years, I am well versed in navigating complex codebases and established team workflows.",
|
||||
},
|
||||
{
|
||||
question: "Who owns the code?",
|
||||
answer:
|
||||
"You do. 100% of all code, assets, and documentation are transferred to you – with zero vendor lock-in.",
|
||||
},
|
||||
{
|
||||
question: "What falls outside your core focus?",
|
||||
answer:
|
||||
"Native mobile apps (iOS/Android App Store), Windows desktop applications, and bare-metal server management. If your initiative requires these, I will be upfront in our initial call.",
|
||||
},
|
||||
],
|
||||
},
|
||||
cases: { lead: "What teams book me for.", rest: "Two typical cases." },
|
||||
booking: {
|
||||
label: "Booking",
|
||||
title: {
|
||||
lead: "Retainer-based monthly bookings.",
|
||||
rest: "Predictable engineering bandwidth.",
|
||||
},
|
||||
text: "I dedicate agreed days of the week to your project, scheduled on a monthly retainer basis. No opaque packages: you only pay for actual hours logged.",
|
||||
rate: { label: "Per hour", amount: "100 EUR", note: "net" },
|
||||
example: {
|
||||
label: "Example: 2 days a week",
|
||||
amount: "approx. 6,400 EUR",
|
||||
note: "64 hours over four weeks, net",
|
||||
},
|
||||
terms: [
|
||||
"Dedicated weekly days, booked on a monthly basis",
|
||||
"Itemized monthly invoices with transparent time tracking",
|
||||
"Two weeks notice to the end of any month on both sides",
|
||||
"No single-day ad-hoc bookings",
|
||||
],
|
||||
cta: "Check availability",
|
||||
remote: {
|
||||
title: "Remote & focused deep work",
|
||||
text: "I work fully remotely with flexible scheduling. I am available for sprint ceremonies, pair programming, and standups by arrangement. Instead of all-day chat distractions, I prioritize dedicated deep-work blocks – ensuring your roadmap moves forward swiftly and with high code quality.",
|
||||
},
|
||||
},
|
||||
teaser: {
|
||||
title: "Are you an engineering lead or CTO?",
|
||||
text: "Explore my technical focus, stack, and CI/CD quality assurance workflows on a dedicated page.",
|
||||
cta: "Explore technical details",
|
||||
},
|
||||
cta: "Ask about a presentation",
|
||||
},
|
||||
technology: {
|
||||
label: "Technology",
|
||||
title: { lead: "Which projects", rest: "I am right for." },
|
||||
title: {
|
||||
lead: "Technical Focus & Specialties:",
|
||||
rest: "Where I add the most value.",
|
||||
},
|
||||
fit: [
|
||||
{
|
||||
title: "A good fit",
|
||||
title: "Ideal project fit",
|
||||
items: [
|
||||
"Web apps: dashboards, portals, internal tools",
|
||||
"Interfaces where design and usability matter",
|
||||
"Interfaces where design, performance and UX matter",
|
||||
"Company websites, also in several languages",
|
||||
"Web3 frontends: wallets, live data, dashboards",
|
||||
"Automation: connecting systems and their APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Also possible",
|
||||
title: "Also covered",
|
||||
items: [
|
||||
"The backend an app needs: APIs, login, integrations",
|
||||
"Command-line tools",
|
||||
"Taking over and tidying up existing code",
|
||||
"App backends: REST & GraphQL APIs, authentication, databases",
|
||||
"Command-line tools and developer utilities",
|
||||
"Refactoring and modernizing existing codebases",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Not my field",
|
||||
title: "Outside my focus",
|
||||
items: [
|
||||
"Mobile apps for the app stores",
|
||||
"Windows software",
|
||||
"Running servers, specialised database work",
|
||||
"Native mobile apps for app stores",
|
||||
"Legacy Windows software",
|
||||
"Bare-metal server hosting and specialized DBA work",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -515,15 +635,15 @@ export const en: Content = {
|
||||
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
|
||||
},
|
||||
{
|
||||
area: "Behind it",
|
||||
area: "Backend & Data",
|
||||
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
|
||||
},
|
||||
{ area: "Web3 and systems", items: ["Solidity", "Rust"] },
|
||||
{ area: "Web3 & Systems", items: ["Solidity", "Rust"] },
|
||||
{ area: "Delivery", items: ["Automated tests", "Docker", "CI/CD"] },
|
||||
],
|
||||
pipelineTitle: "What happens before a change goes live.",
|
||||
pipelineTitle: "Automated quality gates before every production release.",
|
||||
pipelineIntro:
|
||||
"Software is connected: a change in one place can break another. I secure what I build with automated tests, and every change has to pass them before it is released. This is what such a run looks like.",
|
||||
"In complex applications, isolated changes can introduce subtle regressions elsewhere. I protect everything I build with automated CI/CD pipelines and regression tests. Every commit must pass all quality gates before being deployed. Here is how that looks in practice:",
|
||||
pipeline: {
|
||||
simulate: "Simulate a change in",
|
||||
scenarios: {
|
||||
@@ -555,23 +675,23 @@ export const en: Content = {
|
||||
stages: ["Lint", "Types", "Tests", "Deploy"],
|
||||
explanations: [
|
||||
{
|
||||
title: "A change comes in",
|
||||
text: "Something is changed in {area}. Before it can go live, the whole project is checked automatically.",
|
||||
title: "Change submitted",
|
||||
text: "A code change is pushed in {area}. Before deployment, the entire application undergoes automated linting, type-checking, and test suites.",
|
||||
},
|
||||
{
|
||||
title: "A test fails, the release is blocked",
|
||||
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
|
||||
title: "Test fails – release blocked",
|
||||
text: "The change triggered an unexpected regression. The automated pipeline halts immediately – broken code never reaches your users.",
|
||||
},
|
||||
{
|
||||
title: "Fixed, checked again, released",
|
||||
text: "I repair what broke, the run passes, and only then does the change go live.",
|
||||
title: "Fixed, verified, and deployed",
|
||||
text: "The regression is resolved, the test suite turns green, and only then is the release securely promoted to production.",
|
||||
},
|
||||
],
|
||||
runAgain: "Run again",
|
||||
run: "run",
|
||||
fixPushed: "fix pushed",
|
||||
lint: "$ lint: checking the code style…",
|
||||
types: "$ types: checking that the parts fit together…",
|
||||
lint: "$ lint: checking code style…",
|
||||
types: "$ types: validating type safety and interfaces…",
|
||||
tests: "tests",
|
||||
expected: "expected",
|
||||
received: "received",
|
||||
@@ -584,105 +704,103 @@ export const en: Content = {
|
||||
blocked: "release blocked",
|
||||
released: "released",
|
||||
footnote:
|
||||
"An illustration with made-up file names and numbers, modelled on a real test run. Tests do not catch every mistake, but they do catch this kind.",
|
||||
"An illustrative scenario modeled on a real CI/CD pipeline run. Automated test suites systematically catch regression defects before they impact customers.",
|
||||
},
|
||||
},
|
||||
working: {
|
||||
label: "Working with me",
|
||||
title: { lead: "How I work.", rest: "Nothing unusual." },
|
||||
title: {
|
||||
lead: "Working relationship.",
|
||||
rest: "Direct, transparent, and dependable.",
|
||||
},
|
||||
principles: [
|
||||
{
|
||||
title: "One contact",
|
||||
text: "You talk to me, and I am the one who does the work.",
|
||||
title: "Dedicated point of contact",
|
||||
text: "You communicate directly with me – and I am the engineer who implements your code.",
|
||||
},
|
||||
{
|
||||
title: "Hours you can check",
|
||||
text: "I track my time. The monthly invoice shows what the hours were spent on.",
|
||||
title: "Transparent time tracking",
|
||||
text: "Every hour is logged with precision. Monthly invoices itemize exactly what tasks were completed.",
|
||||
},
|
||||
{
|
||||
title: "Careful work",
|
||||
text: "I test what I build before I hand it over, and I say so when something is not finished or I am not sure.",
|
||||
title: "Engineered quality",
|
||||
text: "I only hand over tested code, and I proactively flag technical risks or uncertainties as soon as they arise.",
|
||||
},
|
||||
{
|
||||
title: "What I build is yours",
|
||||
text: "Code and design belong to you. You can hand them to another developer.",
|
||||
title: "Full IP ownership",
|
||||
text: "All code, architecture, and design belong entirely to you. Handoffs to internal teams or other developers are seamless.",
|
||||
},
|
||||
],
|
||||
diagram: {
|
||||
description:
|
||||
"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.",
|
||||
"In many agencies, requirements filter through account managers, PMs, and designers before reaching an engineer. With me, you have a direct line to the person writing the code.",
|
||||
chain: ["You", "Sales", "Manager", "Designer", "Developer"],
|
||||
you: "You",
|
||||
me: "Me",
|
||||
often: "often",
|
||||
withMe: "with me",
|
||||
oftenLong: "often: a request is passed along",
|
||||
withMeLong: "with me: one direct line",
|
||||
oftenLong: "Agencies: multiple handoffs and communication overhead",
|
||||
withMeLong: "With me: direct technical alignment without friction",
|
||||
},
|
||||
},
|
||||
work: {
|
||||
label: "My work",
|
||||
title: { lead: "Two sites I built.", rest: "Both are live." },
|
||||
title: { lead: "Featured web projects.", rest: "Live and battle-tested." },
|
||||
items: {
|
||||
"klz-cables.com": {
|
||||
client: "Cable supplier",
|
||||
text: "The company website of a supplier of energy cables, in German and English.",
|
||||
text: "Corporate website for a European energy cable supplier, localized in German and English.",
|
||||
did: [
|
||||
"Product catalogue with categories and detail pages",
|
||||
"Datasheets generated from the product data",
|
||||
"Content the client can edit in a CMS",
|
||||
"Product catalogue with structured categories and detail pages",
|
||||
"Dynamic datasheet generation from product specifications",
|
||||
"Headless CMS integration for intuitive client editing",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
|
||||
},
|
||||
"e-tib.com": {
|
||||
client: "Cable network construction",
|
||||
text: "The company website of a group in cable network construction, in German and English.",
|
||||
text: "Corporate website for an infrastructure and network construction group, in German and English.",
|
||||
did: [
|
||||
"Pages for services, references, careers and events",
|
||||
"The same content in two languages",
|
||||
"Works on phone, tablet and desktop",
|
||||
"Dedicated sections for services, references, careers and events",
|
||||
"Synchronized multilingual content architecture",
|
||||
"Responsive layouts optimized for mobile, tablet and desktop",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
|
||||
},
|
||||
},
|
||||
openLive: "Open the live site",
|
||||
openLive: "Open live website",
|
||||
desktopAlt: "{name} on a desktop screen",
|
||||
phoneAlt: "{name} on a phone",
|
||||
note: "Besides client work I build my own software, among other things a system in Rust and Solidity that runs on seven blockchains.",
|
||||
phoneAlt: "{name} on a mobile screen",
|
||||
note: "Alongside client work, I develop proprietary systems – including an engine in Rust and Solidity running in production across seven blockchains.",
|
||||
},
|
||||
faq: {
|
||||
label: "Questions",
|
||||
title: { lead: "Good to know", rest: "before we talk." },
|
||||
items: [
|
||||
{
|
||||
question: "I am not technical. Is that a problem?",
|
||||
question: "I don't have a technical background. Is that an issue?",
|
||||
answer:
|
||||
"No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
|
||||
"Not at all. I communicate technical concepts in plain, accessible language. You never need to read code or decipher jargon to make sound business decisions.",
|
||||
},
|
||||
{
|
||||
question: "How long am I committed?",
|
||||
question: "How much does a project cost?",
|
||||
answer:
|
||||
"Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
|
||||
"It depends on scope and complexity. After a brief intro call, you receive a transparent, itemized quote. Engineering work only begins once you give your explicit sign-off.",
|
||||
},
|
||||
{
|
||||
question: "Who owns what you build?",
|
||||
answer:
|
||||
"You do. Code and design belong to you, and you can hand them to another developer.",
|
||||
"You do. Full copyright and intellectual property rights for code, assets, and documentation belong to you – no strings attached.",
|
||||
},
|
||||
{
|
||||
question: "Can you work with our existing team and code?",
|
||||
question: "Who maintains the website after launch?",
|
||||
answer:
|
||||
"Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
|
||||
"I do, if you wish. For 89 EUR per month, hosting on secure German servers, security updates, and minor content adjustments via email are covered. No CMS overhead for you. Existing email inboxes remain with your current host.",
|
||||
},
|
||||
{
|
||||
question: "How do I reach you?",
|
||||
question: "How do we get in touch?",
|
||||
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.",
|
||||
"Best via email or video call. As I work remotely, we schedule meetings to suit your calendar.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -691,7 +809,7 @@ export const en: Content = {
|
||||
{ text: "LET'S", tone: "bits" },
|
||||
{ text: "TALK.", tone: "solid" },
|
||||
],
|
||||
lead: "Pick what you need and the email writes itself. Or write to me directly:",
|
||||
lead: "Choose your inquiry topic to generate a pre-filled email draft – or write to me directly:",
|
||||
newMessage: "New message",
|
||||
draft: "draft",
|
||||
need: "I need",
|
||||
@@ -704,42 +822,42 @@ export const en: Content = {
|
||||
topics: [
|
||||
{
|
||||
id: "developer",
|
||||
label: "A developer",
|
||||
label: "Hire a developer",
|
||||
draft: {
|
||||
subject: "A developer for our product",
|
||||
body: `Hi Marc,\n\nwe are working on [what you are building] and need help with [what is next on your list].\n\nA first call would suit us around [date or week].\n\n${SIGN_OFF}`,
|
||||
subject: "Senior developer for our product",
|
||||
body: `Hi Marc,\n\nwe are working on [project / product description] and are looking for engineering support with [upcoming initiatives / feature areas].\n\nAn intro call would suit us around [target date or week].\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
label: "A reliable app",
|
||||
label: "Stabilize an app",
|
||||
draft: {
|
||||
subject: "Our app needs to become reliable",
|
||||
body: `Hi Marc,\n\nour app [name or short description] works, but [what keeps going wrong].\n\nCould you take a look?\n\n${SIGN_OFF}`,
|
||||
subject: "Web app stabilization & code audit",
|
||||
body: `Hi Marc,\n\nour application [name or brief description] is live, but [current pain points / stability issues].\n\nCould you take a look as part of a code audit?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
label: "Automation",
|
||||
label: "Process automation",
|
||||
draft: {
|
||||
subject: "We want to automate a process",
|
||||
body: `Hi Marc,\n\nwe would like to automate [the process]. Today it is done by hand in [the tools involved].\n\nCan we talk about it?\n\n${SIGN_OFF}`,
|
||||
subject: "Process automation inquiry",
|
||||
body: `Hi Marc,\n\nwe would like to automate the following workflow: [workflow description]. Currently it is handled manually across [involved tools / systems].\n\nCan we schedule a quick call to discuss?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
label: "A website",
|
||||
label: "Company website",
|
||||
draft: {
|
||||
subject: "A website for my business",
|
||||
body: `Hi Marc,\n\nI need a website for [your business]. It should [what it is for].\n\nWhen could we talk?\n\n${SIGN_OFF}`,
|
||||
subject: "New company website project",
|
||||
body: `Hi Marc,\n\nI am planning a new website for [company name]. The goal is to [business objective / target audience].\n\nWhen would suit you for a brief intro call?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
label: "General inquiry",
|
||||
draft: {
|
||||
subject: "A question",
|
||||
body: `Hi Marc,\n\n[what you have in mind]\n\n${SIGN_OFF}`,
|
||||
subject: "Project inquiry",
|
||||
body: `Hi Marc,\n\n[your message]\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -220,6 +250,41 @@ export type Content = {
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -27,10 +27,17 @@ export default tseslint.config(
|
||||
"**/.stryker-tmp/**",
|
||||
"**/reports/**",
|
||||
"**/coverage/**",
|
||||
// Worktrees are separate checkouts; each is linted from its own root.
|
||||
"**/.claude/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
// Worktrees under .claude/worktrees are checkouts with their own tsconfig.
|
||||
// Without a fixed root the parser cannot decide which one a file belongs to.
|
||||
languageOptions: { parserOptions: { tsconfigRootDir: __dirname } },
|
||||
},
|
||||
{
|
||||
files: ["apps/web/**/*.{js,jsx,ts,tsx}"],
|
||||
plugins: {
|
||||
|
||||
Reference in New Issue
Block a user