Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:47:11 +02:00
mmintelandClaude Opus 5.5 9ed436330b Merge feat/german-locale: German version, glow effects and search metadata
Build & Deploy / 🩺 Smoke Test (push) Failing after 2s
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m36s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Brings the German main language (English under /en), the binary glow
effects with the refined drawings and calls to action, and the search
metadata onto the server-side analytics from v2.1.0.

Both sides had added apps/web/proxy.ts. It is now one request shell: it
picks the language and maps the address (routes.ts), and records the page
view server-side (pageview-decision.ts) for every request that is not a
redirect, using the public address the visitor asked for. The browser
analytics component and the /stats proxy route stay removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:20:10 +02:00
mmintel 494f69db85 docs: incident ledger with the analytics outage entry
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m27s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-10-04 13:10:48 +02:00
mmintelandClaude Opus 5.5 75993100fa feat: German as the main language, English under /en, with a language switch
The site now exists in German (at the root, with /impressum and /datenschutz)
and English (/en). A request without a language follows the visitor's choice
from the language switch, then the browser language, then German; the old
English legal addresses move to /en for good.

All visible texts are data in src/content/de.ts and en.ts, so components hold
none of their own. The header carries a DE/EN switch whose bit curtain covers
the page while it is swapped and reopens at the same reading position. Title,
description, canonical, hreflang, Open Graph, the share image, the sitemap and
the structured data are per language. The privacy notice now describes the
cookie that remembers the chosen language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:07:22 +02:00
mmintelandClaude Opus 5.5 e89a248ca5 feat: binary glow effects, refined drawings and calls to action
Dark surfaces now carry light: frames glow along their edge and shed binary
digits (BitAura), dark sections open with a lit seam (BitSeam), the career
path is a line of light and "My work" became a dark stage. Buttons carry a
travelling light and a spot that follows the pointer (BitGlow).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:07:09 +02:00
mmintelandClaude Opus 5.5 e9f05b02fc feat: search metadata, structured data and indexing rules
Only the production deployment may be indexed; testing and preview builds
answer with noindex and a closed robots file. The homepage now describes the
person, the services and the FAQ as structured data, and title, description
and the 404 page are written for what people search for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:07:03 +02:00
mmintel ed35413a44 fix(analytics): identify page views via fetch metadata, not router headers
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m26s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 3s
Next.js strips the router-internal headers (RSC, next-router-prefetch)
before the request proxy runs, so prefetch detection based on them never
fired: link prefetches were counted as page views. A page view is now a
browser navigation, identified by the standard sec-fetch-dest: document
header, which survives into the proxy. Prefetch payloads, subresource
fetches and metadata-less clients are skipped as non-navigation
requests.
2026-10-04 12:51:54 +02:00
mmintel a54df524c7 feat(analytics): record pageviews server-side only
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m31s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Umami never received data: the client-side path (browser beacon to
/stats/api/send) has been dead in production. Pageviews are now
classified and recorded entirely on the server in the request proxy;
no tracking script ships to the browser anymore.

- Pure domain decision (decidePageview) filters real page views from
  assets, infrastructure paths, prefetches and bot user agents
- Pure payload builder maps facts onto the Umami send contract as an
  unnamed event, with the client identity travelling in headers
- proxy.ts wires request facts into the domain and fires the send
  without delaying the response; configured via UMAMI_WEBSITE_ID and
  UMAMI_API_ENDPOINT
- Removes the dead client analytics stack (components, adapters,
  /stats proxy route) and its layout mount
2026-10-04 12:24:13 +02:00
mmintelandClaude Opus 5.5 390936856c feat: message starter in the contact section
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m26s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 2s
The visitor picks what they need and a prepared email types itself out; one
press opens it in their own mail app with subject and body filled in. The
mailto link is built by a tested pure function (src/domain/email.ts, spec
first) and only in the browser, so the address still never appears in the
page source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:01:03 +02:00
mmintelandClaude Opus 5.5 dc4a01cbe4 feat: binary micro-interactions across the page
Section headlines are written out behind a column of digits, mono labels
resolve from binary, the portrait assembles itself from bits, section indexes
rattle and keep one bit alive, cards get corner marks, the header shows the
scroll position as an eight-bit number, pressed links and buttons shed a few
digits, and the work stage leans towards the pointer. All effects respect
reduced motion and leave the content readable without JavaScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:14:22 +02:00
mmintelandClaude Opus 5.5 123ab8e354 feat: share image, metadata and icons that match the new site
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m28s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🩺 Smoke Test (push) Successful in 4s
Build & Deploy / 🔔 Notify (push) Successful in 2s
The link preview used an old template with a blog description. The share
image is now a screenshot of the real hero (scripts/render-share-image.mjs),
served as Open Graph and Twitter image. Title and description come from one
place and are used by the page, the previews and the structured data. Adds an
apple touch icon and removes the old share-image template and its fonts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:33:02 +02:00
mmintelandClaude Opus 5.5 007a691357 Merge origin/main: deployment retarget to infra.mintel.me
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 8m40s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 4s
Build & Deploy / 🔔 Notify (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:33:53 +02:00
mmintelandClaude Opus 5.5 fa40fefe2f feat: rebuild mintel.me as a single-page freelance site
The site now presents one freelance developer on one page: hero, introduction,
four typical cases (developer for a product, make an app reliable, automate a
process, a website) with what you get, how it runs and what it costs, a
process automation section, technology and project fit, how I work, two live
references, questions and contact.

- Hero and closing headline are drawn from running binary digits.
- Each case explains its four steps with an animated drawing; the automation
  section shows example workflows with work travelling between systems; a
  pipeline run shows what a test catches before a release.
- Contact is one email address, kept in parts and assembled in the browser so
  it never appears in the page source (src/domain/email.ts, spec first).
- Imprint and privacy pages added.
- The old site (blog, case studies, contact form, fixed-price pages, video
  rendering and related scripts) is removed. Its URLs redirect to the homepage.
- The deploy smoke test checks the legal pages and the redirects instead of
  the removed case study.
- Dev scripts use docker compose v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:33:06 +02:00
mmintelandClaude Sonnet 5.5 4eb663f42a feat: add /notes with two engineering write-ups and diagrams
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:21:00 +02:00
mmintelandClaude Sonnet 5.5 cc0d9bd9c2 feat: rewrite /about and /book with tested booking URL parsing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:20:40 +02:00
mmintelandClaude Sonnet 5.5 a9702dc3ff feat: add /process page with tested delivery report model and sample
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:20:28 +02:00
mmintelandClaude Sonnet 5.5 c71916cf08 feat: new homepage with hero, proof, offers and live references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:20:05 +02:00
mmintelandClaude Sonnet 5.5 f258e2a483 feat: dark theme foundation, new header/footer, shared site primitives
Adds dark design tokens, Inter + JetBrains Mono, a header with Notes,
Process, About and a Book a call button, and a footer with the availability
line driven by the tested domain core. Shared primitives live in
src/components/site, site constants in src/content/site.ts. Legacy pages
(blog, legal, contact, tags, technologies) keep a light surface via
LegacyLightSurface. Stub routes for /notes, /process and /book.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:00:07 +02:00
mmintelandClaude Sonnet 5.5 d33a5d6c63 chore: enable TypeScript strict mode and drop dead Payload scripts
The shared base tsconfig had strict off, so null-safety and union narrowing
were not checked. Strict is now on for apps/web with 5 small typing fixes.
check-db.ts and migrate-drafts.js depended on Payload, which is no longer a
dependency, and are removed. CLAUDE.md no longer claims Payload.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:59:47 +02:00
mmintelandClaude Sonnet 5.5 6e5d14882e chore: add agent guardrails (CLAUDE.md, stop hook, permissions, git hooks)
CLAUDE.md documents commands, the spec-first workflow, the functional-core
rule and a do-not list. A Claude Code Stop hook blocks finishing while
typecheck or unit tests fail. Permissions deny reading env files, hook
bypass flags, hard resets and recursive deletes, and ask before CI edits
or any push. Husky pre-commit now also typechecks and runs unit tests;
pre-push runs coverage and the mutation gate when src/domain changed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:14:04 +02:00
mmintelandClaude Sonnet 5.5 0995a3b5ca test: add vitest, coverage and mutation gate with availability domain core
Pure logic lives in apps/web/src/domain and must stay at 100% coverage and a
100% mutation score. The first unit is the availability logic for the hero:
parsing, resolving and formatting the next free capacity block.

Vitest is pinned to 4.1.11 because @stryker-mutator/vitest-runner 10.0.0
runs zero tests per mutant under vitest 5 and reports surviving mutants.
ESLint now ignores Stryker, coverage and report output.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:12:47 +02:00
mmintelandClaude Sonnet 5.5 43d0a49c3d chore: remove tracked scratch files and ignore them
Posts.ts.tmp, tmp.txt, wget_out.txt, build logs, a debug HTML dump and a
generated markdown output were tracked but unreferenced. tmp_posts.json is
kept because scripts/extract-to-mdx.js still reads it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:57:05 +02:00
mmintelandClaude Sonnet 5.5 c97b07de19 docs: add freelance relaunch concept for mintel.me
Defines positioning, site structure, offers and pricing, and implementation
order for repositioning the site from fixed-price websites to a senior
freelance engineer offering weekly capacity blocks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:55:10 +02:00
mmintel 57b19055b4 chore(ci): retarget deployments to infra.mintel.me (server consolidation)
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 9m19s
Build & Deploy / 🚀 Deploy (push) Failing after 2s
Build & Deploy / 🩺 Smoke Test (push) Skipped
Build & Deploy / 🔔 Notify (push) Failing after 2s
2026-10-01 22:45:28 +02:00
mmintel d9e0845266 chore: remove debug route
Build & Deploy / 🔍 Prepare (push) Successful in 42s
Build & Deploy / 🏗️ Build (push) Successful in 10m13s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🩺 Smoke Test (push) Successful in 6s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-21 12:14:15 +02:00
mmintel ede34ab7ce fix(prod): auto-reload on ChunkLoadError or Server Action mismatch
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🏗️ Build (push) Successful in 10m30s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🩺 Smoke Test (push) Successful in 7s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-10 11:11:54 +02:00
mmintel 3fac158185 fix(contact): correct canvas-confetti import to avoid TypeError on success animation
Build & Deploy / 🔍 Prepare (push) Successful in 27s
Build & Deploy / 🏗️ Build (push) Successful in 18m45s
Build & Deploy / 🚀 Deploy (push) Successful in 40s
Build & Deploy / 🩺 Smoke Test (push) Successful in 6s
Build & Deploy / 🔔 Notify (push) Successful in 5s
2026-06-03 17:12:08 +02:00
894 changed files with 36403 additions and 60751 deletions
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Stop hook: the agent may only finish when typecheck and unit tests pass on the current code.
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel)}"
payload="$(cat)"
# Avoid a loop: if we already blocked once in this turn, let it stop and show the failure to the user.
if printf '%s' "$payload" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
exit 0
fi
# Nothing changed in the app, nothing to verify.
if [ -z "$(git status --porcelain -- apps/web package.json pnpm-lock.yaml)" ]; then
exit 0
fi
run() {
local label="$1"; shift
local out
if ! out="$("$@" 2>&1)"; then
{
echo "VERIFY FAILED: $label"
echo "$out" | tail -n 40
echo "Fix the cause (do not weaken tests), then finish again."
} >&2
exit 2
fi
}
run "typecheck" pnpm --filter @mintel/web typecheck
run "unit tests" pnpm --filter @mintel/web test
exit 0
+41
View File
@@ -0,0 +1,41 @@
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./apps/web/.env)",
"Read(./apps/web/.env.*)",
"Edit(./.env)",
"Edit(./.env.*)",
"Bash(git push --force*)",
"Bash(git push -f*)",
"Bash(git push * main*)",
"Bash(git push * master*)",
"Bash(git commit *--no-verify*)",
"Bash(git commit * -n *)",
"Bash(git reset --hard*)",
"Bash(git clean*)",
"Bash(rm -rf*)"
],
"ask": [
"Edit(./.gitea/workflows/**)",
"Write(./.gitea/workflows/**)",
"Edit(./Dockerfile*)",
"Edit(./docker-compose*)",
"Bash(git push*)"
]
},
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify.sh",
"timeout": 180
}
]
}
]
}
}
+110
View File
@@ -0,0 +1,110 @@
---
name: video-film
description: Make or change a social media film or image post in apps/video (Remotion) in the approved mintel.me style - one continuous shot, minimal, with a camera pull-back as the reveal, in German and English. Use when asked for a new video, a new topic, changes to an existing film, new formats, or to re-render the video library.
---
# Making a film for mintel.me
The videos in `apps/video` are short looping films for LinkedIn and ads. The owner approved this
style after rejecting several others. Read `apps/video/CLAUDE.md` first; this skill is the procedure.
## What the owner rejected, so do not go back to it
- Slides with a panel, a progress bar, labels and a frame around everything.
- Much text, small text, text sizes that change from line to line.
- Hectic motion: hops of a third of a second, six statements in 15 seconds.
- Busy drawings: icons, labels, counters, stacks, several effects at once.
- A tiny logo in a black box.
- German that reads translated.
## The style
One continuous shot on a dark stage. Only squares, dots, lines and bars. One statement at a time,
two short lines, large and centred under the drawing. Calm: nothing moves faster than it has to, every
statement stays for about three seconds.
Every film tells the same kind of story in six beats, on the shared timeline `T` in
`src/films/shot.ts` (690 frames at 30 fps, 23 seconds):
| Beat | Caption id | What happens |
| :--- | :--------- | :----------- |
| Hook | `hook` | Close on something small and abstract that looks like something else. "Looks like ping-pong." |
| Reveal | `reveal` | The camera pulls back and shows what it really is. "It's your office." This is the aha moment. |
| Problem | `problem` | The whole picture keeps going, and it is clearly too much. |
| Turn | `turn` | Everything slows to a halt, then the form changes: arcs become a line, a pile leaves by itself. |
| Calm | `calm` | The result at rest, with one quiet movement. |
| Call to action | `cta` | A question that picks up the hook ("Done with ping-pong?") and the website's button. |
Then the picture folds back into its opening, so the last frame equals the first and the film loops.
Frame 0 is the preview image: it must already look good and carry the hook line.
## Procedure
1. **Bring the worktree up to date with `main`** and read the service in
`apps/web/src/content/de.ts` and `en.ts`. Claims, prices and terms come from there and nowhere else.
2. **Find the picture before writing code.** Answer in one line each:
- What does the viewer see close up, and what harmless thing does it look like?
- What does the camera reveal when it pulls back?
- Which single change of form resolves it?
If the reveal does not make you think "oh", look for another picture. Existing ones:
ping-pong / office, skyscraper / pile of invoices, star / unanswered enquiries,
blinking cursor / empty website, Jenga / fragile app, loading bar / backlog.
3. **Write the six statements** in `src/content/videos.ts`, German and English, plus the post text.
Two lines each, as short as possible: one font size is used for the whole film, set by the longest
line. The hook is "Sieht aus wie ..." / "Looks like ...". A little sarcasm is welcome. German uses
"Sie" and has to sound like a native wrote it; write it first, do not translate it from English.
The post text opens with the hook. Copy rules of the root `CLAUDE.md` apply.
4. **Write the film** as `src/films/<Name>Film.tsx`. Copy the structure of `AppFilm.tsx` (simplest)
or `OrderFilm.tsx` (several moving things):
- `cameraAt(frame, zoomIn, zoomOut, focus)` gives `transform`, `zoom`, `hair` and `others`.
Everything that is not part of the opening picture gets `opacity={others}`.
- Draw in stage coordinates, (0, 0) in the centre, 1000 x 700. Use `hair` for stroke widths and
divide glow sizes by `zoom`, so lines look the same at every zoom.
- All motion is a pure function of the frame: `progress`, `inOut`, `out`, `mix` from
`src/illustrations/motion.ts`. No timers, no randomness, no CSS animations.
- For the loop: after `T.fold` the opening picture has to be rebuilt exactly. Anything periodic
needs a period that divides 690 (23, 30, 46). Compare frame 0 and frame 689.
- Logic worth testing goes into `src/lib` with a spec, written first (see `hop.ts`).
5. **Register it**: `src/compositions/<VideoId>.tsx` exporting a `VideoEntry` with `Film`, and the
list in `src/Root.tsx`. A new id also goes into `VIDEO_IDS` in `videos.ts`.
6. **Check**: `pnpm typecheck` and `pnpm test` in `apps/video`.
7. **Render and look**: `node scripts/export.ts --only <VideoId>`, then pull frames from the MP4
(start, middle of the pull-back, problem, turn, calm, call to action, last frame) in `feed` and
`wide`, and read them. Check: nothing overlaps the text, the drawing fades at the stage edges
during the camera move, frame 689 equals frame 0, German lines fit.
8. **Report** what was rendered, what you looked at, and what you did not. You cannot play video:
say so, and leave the judgement of pace and feel to the owner.
## Traps that have cost time here
- **A render can silently not happen.** Redirect logs with `>|`, not `>` (the shell refuses to
overwrite an existing file and the command never starts). After every export, compare the file times
with the clock before sending anything. Run one export at a time.
- **Glows**: `drop-shadow` on SVG shapes is fine. Blur filters under a CSS mask, blend modes and dashed
strokes came out with hard edges; build soft light from gradients that fade to transparent.
- **The stage is not clipped by itself.** While the camera is close, the drawing is larger than the
stage; `FilmStage` fades it at the edges. Do not rely on things being "off screen".
- **Do not name a helper script `copy.py`, `json.py` or similar**: it shadows the Python module.
- **Text from an old worktree is stale.** The website changed language and wording during this work.
## Image posts
Every film gets a poster, and the plan pairs it with a card (see `apps/web/plans/content-strategy.md`).
- Poster: add an entry to `src/content/images.ts` with `poster: { video, frame }`. Pick the frame where the
whole picture is in view and the problem is obvious (around frame 250 to 300). Two lines, as short as
possible: "Kein Pingpong." / "Ihr Büro."
- Card: a question a client asks before the first call (`label`) and the answer in two or three lines.
The answer must be on the website already.
- Add the new posts to `src/content/plan.ts`, run `pnpm test`, then `node scripts/export.ts --images`
and look at the PNGs in `feed` and `square`.
- A poster is not a frozen frame inside a one-frame image: `Freeze` did not work there. The poster is a
composition as long as the film, rendered at that frame.
## Formats and output
Compositions are `<VideoId>-<de|en>-<feed|wide|square|story>`. `node scripts/export.ts` renders
`feed` (4:5, LinkedIn) and `wide` (16:9, YouTube and ads) in both languages into
`out/library/<lang>/week-<n>/<slot>-<day>-<id>/`, with `post.txt` and an overview page
`out/library/index.html`. `out/` is not committed. Keep it tidy: check frames, contact sheets and logs
go into `out/tmp`, nothing loose into `out/`, and `out/tmp` is emptied before you report.
+27 -33
View File
@@ -215,7 +215,7 @@ jobs:
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
ssh-keyscan -H infra.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
echo "Re-running docker build with plain progress to capture exact logs..."
echo "${{ secrets.NPM_TOKEN }}" > /tmp/npm_token.txt
docker build \
@@ -227,7 +227,7 @@ jobs:
--progress plain \
-t temp-image . > docker_build_failed.log 2>&1
cat login.log >> docker_build_failed.log || true
scp docker_build_failed.log root@alpha.mintel.me:/root/docker_build_failed.log
scp docker_build_failed.log root@infra.mintel.me:/root/docker_build_failed.log
# JOB 4: Deploy
# ──────────────────────────────────────────────────────────────────────────────
deploy:
@@ -347,11 +347,11 @@ jobs:
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
ssh-keyscan -H infra.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# SSH keepalive to prevent timeout during long docker pull
cat > ~/.ssh/config <<SSHCFG
Host alpha.mintel.me
Host infra.mintel.me
ServerAliveInterval 15
ServerAliveCountMax 20
ConnectTimeout 30
@@ -369,9 +369,9 @@ jobs:
fi
# Upload files
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
ssh root@infra.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
scp .env.deploy root@infra.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yml root@infra.mintel.me:$SITE_DIR/docker-compose.yml
# Deploy
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
@@ -400,7 +400,7 @@ jobs:
TOKEN="$VALID_TOKEN"
# Deploy — alpha is pre-logged into registry.infra.mintel.me, no credential passing needed
ssh root@alpha.mintel.me "
ssh root@infra.mintel.me "
docker network create '${{ needs.prepare.outputs.project_name }}-internal' || true
docker volume create 'mintel-me_payload-db-data' || true
cd $SITE_DIR
@@ -472,36 +472,30 @@ jobs:
exit 1
fi
- name: 🌐 Check Case Study Assets and Rewrites
- name: 🌐 Check Legal Pages and Old URLs
shell: sh
run: |
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
echo "Verifying case study page..."
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/case-studies/klz-cables")
if [ "$STATUS" != "200" ]; then
echo "❌ Case study page failed! (Status: $STATUS)"
exit 1
fi
echo "✅ Case study page UP"
# 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)"
exit 1
fi
echo "✅ $PAGE OK"
done
echo "Verifying breeze CSS (root path)..."
CSS_PATH="/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$CSS_PATH")
if [ "$STATUS" != "200" ]; then
echo "❌ Root asset path failed! (Status: $STATUS)"
exit 1
fi
echo "✅ Root asset path OK"
echo "Verifying breeze CSS (relative path from case-study)..."
REL_CSS_PATH="/case-studies/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$REL_CSS_PATH")
if [ "$STATUS" != "200" ]; then
echo "❌ Relative asset path failed! (Status: $STATUS)"
exit 1
fi
echo "✅ Relative asset path OK"
# URLs of the previous site must redirect instead of returning 404.
for OLD in /blog /contact /websites /case-studies/klz-cables; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$OLD")
if [ "$STATUS" != "308" ]; then
echo "❌ $OLD does not redirect! (Status: $STATUS)"
exit 1
fi
echo "✅ $OLD redirects"
done
# ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Notifications
+15
View File
@@ -61,3 +61,18 @@ backups/
_at-mintel/
local_build_*.log
*.tar
# scratch and generated artifacts (never commit)
*.tmp
*.log
tmp.txt
wget_out.txt
build*.log
public_test.html
optimized_output.md
# test, coverage and mutation output
/coverage
apps/web/coverage/
apps/web/reports/
.stryker-tmp/
+2
View File
@@ -1 +1,3 @@
pnpm exec lint-staged
pnpm --filter @mintel/web typecheck
pnpm --filter @mintel/web test
+8
View File
@@ -0,0 +1,8 @@
# Pushing is gated harder than committing: full coverage, and mutation when the domain core changed.
pnpm --filter @mintel/web test:coverage || exit 1
base="$(git merge-base HEAD origin/main 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -n 1)"
if git diff --name-only "$base"...HEAD | grep -q '^apps/web/src/domain/'; then
echo "src/domain changed: running mutation gate (about 2 minutes)"
pnpm --filter @mintel/web test:mutation || exit 1
fi
+117
View File
@@ -0,0 +1,117 @@
# mintel.me
Personal website of a senior freelance engineer. Next.js 16 (App Router), pnpm monorepo.
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)
- `pnpm --filter @mintel/web typecheck` — must pass with zero errors
- `pnpm --filter @mintel/web test` — unit specs (Vitest); 0 tests run counts as a failure
- `pnpm --filter @mintel/web test:coverage` — `src/domain` must stay at 100%
- `pnpm --filter @mintel/web test:mutation` — Stryker on `src/domain`, ~2 min, must score 100
- `pnpm --filter @mintel/web lint`
## How to work here (spec first)
1. Write the behavior as concrete cases (inputs, outputs, edge cases, errors) as a `*.spec.ts`.
2. Run it and confirm it fails for the right reason (missing behavior, not an import error).
3. Implement the simplest clean solution, run again.
4. Run typecheck, coverage and mutation. Survivors mean weak specs or dead code: fix one or the other.
5. Report the exact commands and results. "Done" means they were run on the current code.
Never weaken, skip or delete a test to make it pass.
## Copy rules (client-facing pages)
- 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.
- Two content pages. The homepage tells one story from top to bottom (who, what you need, examples, how
working together goes, results, questions, contact); each section opens with a `bridge` sentence from
`story` in the content files that leads over from the one before. The technical sections (project fit,
tools, how a change is secured) live on `/freelance` (`/en/freelance`), for people who develop themselves
or lead a team. Do not move technical detail back onto the homepage.
- The header has the link to the freelance page, the language switch and "Get in touch", which jumps to the
contact section (`#contact`). Contact is one email address: no calendar integration, no form. The only
other pages are the imprint and the privacy notice. URLs of the previous site redirect to `/`
(see `next.config.mjs`).
- Title, description and link previews come from `site` in `src/content/de.ts` and `en.ts`.
The share image is a screenshot of the real hero, one per language: after changing the hero headline or
its design, run `pnpm --filter @mintel/web share-image` and commit the PNGs in `src/assets/share/`.
- The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`.
Never write the full address into markup, metadata or copy.
- The hero design (binary-digit headline) is approved: do not change its design.
- Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind.
## Two languages (German is the main one)
- German lives at the root (`/`, `/freelance`, `/impressum`, `/datenschutz`), English under `/en`
(`/en/freelance`, `/en/imprint`, `/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses
onto it. All routing and detection rules are pure functions in `src/domain/routes.ts` and `locale.ts`.
- Detection: an address without a language follows the `lang` cookie (set only by the language switch), then
the browser's `Accept-Language`, then German. `/en/...` is never redirected.
- Every visible text is data in `src/content/de.ts` and `en.ts` (type `Content` in `types.ts`). Components
hold no texts: server components take them from `contentFor(locale)`, client components from
`useContent()`. A new text goes into both files; German uses the formal "Sie".
- Never name a file `src/i18n.ts` or `i18n/request.ts`: `@mintel/next-config` would switch on next-intl.
- The privacy notice describes the language cookie; keep it true when the detection changes.
## Motion (binary micro-interactions)
- Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits),
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
`BinaryTicker`, `ScrollBits`, `BitBurst`, `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`).
- Every effect must respect `prefers-reduced-motion` and leave content readable without JavaScript.
## Architecture rules
- Functional core, imperative shell. Pure logic goes in `apps/web/src/domain/` (no I/O, no clock, no
randomness, no framework imports; pass `today` etc. in as arguments). Everything there is covered 100%
and mutation-tested. Pages, routes and adapters stay thin and call into the core.
- Parse at the boundary into precise types (`Result`, discriminated unions); no nullable state bags.
- No `any`, `@ts-ignore`, `!` non-null assertions or eslint-disable to silence errors. Fix the cause.
## Do not
- Read or edit `.env*` (only `.env.example`). Never print or log secrets.
- Commit scratch files (`tmp*`, `*.log`, `*.tmp`, debug HTML, build output). They are gitignored; keep it so.
- Use `--no-verify`, force-push, push to `main`, or run `reset --hard`, `clean`, `rm -rf` without asking.
- Edit `.gitea/workflows/*` or deploy config without asking.
- Upgrade `vitest` past 4.1.x: `@stryker-mutator/vitest-runner` 10.0.0 runs zero tests per mutant under
vitest 5 and reports false survivors. Re-verify mutation results after any tooling bump.
- Add dependencies for trivial things, or touch unrelated code (no drive-by refactors).
## Gotchas
- `tsconfig` includes `.next/types`: after deleting a route, stale generated files there break `tsc`.
Run `next typegen` and remove the stale file.
- Run only one Stryker process at a time; parallel runs share `.stryker-tmp` and hang.
- Stryker needs `plugins` listed explicitly in `stryker.config.json` (pnpm layout) and ignores the large
asset folders via `ignorePatterns`; keep them in sync when adding top-level folders.
- `@mintel/*` packages are `link:` dependencies on a sibling `at-mintel` checkout. The app itself now only
uses `@mintel/next-config`; the other entries in `package.json` are leftovers from the old site.
- Pushing `main` deploys to the testing environment; production is deployed from a `v*` tag.
- Git branches: work on `feat/*`, never directly on `main`.
-95
View File
@@ -1,95 +0,0 @@
import type { CollectionConfig } from "payload";
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
import { allBlocks } from "../blocks/allBlocks";
export const Posts: CollectionConfig = {
slug: "posts",
admin: {
useAsTitle: "title",
},
access: {
read: () => true, // Publicly readable API
},
fields: [
{
name: "aiOptimizer",
type: "ui",
admin: {
position: "sidebar",
components: {
Field: "@/src/payload/components/OptimizeButton#OptimizeButton",
},
},
},
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
},
hooks: {
beforeValidate: [
({ value, data }) => {
if (value) return value;
if (data?.title) {
return data.title
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "");
}
return value;
},
],
},
},
{
name: "description",
type: "text",
required: true,
},
{
name: "date",
type: "date",
required: true,
},
{
name: "tags",
type: "array",
required: true,
fields: [
{
name: "tag",
type: "text",
},
],
},
{
name: "featuredImage",
type: "upload",
relationTo: "media",
admin: {
description: "The main hero image for the blog post.",
position: "sidebar",
},
},
{
name: "content",
type: "richText",
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: allBlocks,
}),
],
}),
},
],
};
+3
View File
@@ -0,0 +1,3 @@
node_modules
out
.remotion
+102
View File
@@ -0,0 +1,102 @@
# apps/video
Social media videos (LinkedIn first, 16:9 for YouTube and Google Ads) in the mintel.me look, made with Remotion.
Standalone package: it is **not** in `pnpm-workspace.yaml`, so it never ends up in the web build, the Dockerfile
or `pnpm -r`. `apps/web` must never import from here, and this package imports nothing from `apps/web`.
## Commands (run from this folder)
- Install: `pnpm install --ignore-workspace`
- Studio (the playground, http://localhost:3000): `pnpm studio`
- One frame as PNG: `pnpm still WebsiteDone-de-feed out/check.png --frame=40`
- One video: `pnpm render WebsiteDone-de-feed out/website.mp4`
- The whole library: `pnpm library` (= `node scripts/export.ts`; `--only <VideoId>`, `--formats feed,wide`,
`--page` to rewrite only post texts and the overview). Writes `out/library/index.html` (watch, copy the post
text, download) and `out/library/<language>/week-<n>/<slot>-<day>-<id>/` with the files and `post.txt`, in plan order.
- `pnpm typecheck`, `pnpm test`
Compositions are named `<VideoId>-<language>-<format>`: languages `en`, `de`; formats `feed` (4:5, LinkedIn),
`wide` (16:9), `square`, `story` (9:16). `out/` is gitignored and holds only two things: `out/library`
(what gets posted) and `out/tmp` (your check frames, sheets and logs; delete it when you are done).
## Where things are
- `src/content/videos.ts`: every word. Headlines per slide, the post text per video, both languages, and the
German for labels inside drawings (`LABELS_DE`). Drawing code writes labels in English; they are translated
when rendered. German addresses the viewer as "Sie".
- `src/compositions/<Video>.tsx`: the slides of one video (ids, durations, illustrations). No text.
- `src/slides`: `SlideDeck` (panel, headline, curtain, loop, portrait and 16:9 layout), `ServiceVideo`
(frame + language + deck), `ctaSlide` (closing slide with the button).
- `src/illustrations`: `parts.tsx` (tile, icon, cursor, data chip, window, page, badges, day box), `scenes.tsx`
(scenes several videos share), `motion.ts` (easing helpers).
- `src/brand`: tokens, fonts, `BrandFrame` (logo, labels, digit texture), `Panel` (the dark panel with the
website's light effects), `BrandButton`.
- `src/lib`: pure, tested logic (`bits.ts`, `aura.ts`).
## How to make a new video
1. Add the texts to `src/content/videos.ts` (new `VideoId`, both languages, post text).
2. Add `src/compositions/<Name>.tsx` with its slides and register it in `src/Root.tsx`.
3. Look before reporting: render the MP4, pull one frame per slide and read them, in `feed` and `wide`, in both
languages (German lines are longer). Do not hand over a video you have not seen.
4. `pnpm library --only <Name>` and tell the owner where the files are.
## Films (the current format, owner approved 2026-10-04)
Every video is a film in `src/films`: one continuous shot on a dark stage, not a deck of slides.
- Shared: `FilmStage` (backdrop, one centred statement at a time, signature, address) and `shot.ts`
(one timeline `T`, the camera move `cameraAt`, the six caption slots hook/reveal/problem/turn/calm/cta).
- The pattern: close on something abstract that looks like something else ("Looks like ping-pong."), the
camera pulls back and shows what it really is ("It's your office."), it stops, resolves, rests, and
returns to the opening picture so the film loops. Frame 0 is the preview image.
- Minimal and calm (owner feedback: earlier versions were hectic). Only squares, dots, lines and bars; no
icons or labels in the drawing. One statement of two short lines, held for about three seconds.
A little sarcasm in the hook is welcome. German has to read like a native wrote it.
- One font size per film: the largest at which the longest line fits, so keep lines short.
- The logo is the white signature (`public/logo-mark.svg`): bottom left, and very large and faint behind.
- The slide deck code (`src/slides/SlideDeck`, `src/illustrations`, `BrandFrame`, `Panel`) is no longer used
by any video and can be removed once the owner confirms.
## Image posts and the posting plan
- Image posts are made the same way as films: `src/content/images.ts` holds every word and, for a poster, which
film and frame it shows; `src/images/ImagePost.tsx` draws them. A poster is a film rendered at one frame with
two lines ("Not ping-pong. Your office."); a card is a client's question with the answer in large type.
Cards say only what the website says. Compositions: `<ImageId>-<language>-<feed|square>`.
- `src/content/plan.ts` is the posting plan (which film or image in which week); the reasoning is in
`apps/web/plans/content-strategy.md`. The spec checks that every film and image is planned exactly once
and that every video post opens with the hook of its film.
- `pnpm library` renders films and images and writes the overview page in plan order;
`--images`, `--videos`, `--only <id>`, `--page`.
## Rules
- Before any work: bring the worktree up to date with `main` (owner feedback, twice). Take wording, styles and
effects from the website as it is **now**: texts in `apps/web/src/content/de.ts` and `en.ts`, styles in
`apps/web/app/globals.css`, drawings in `apps/web/src/components/illustrations`. Do not state prices, terms
or durations that are not in the texts.
- Picture first, few words (owner feedback): 6 to 8 slides of about 2 s, each one large animated illustration
plus a headline of two to four words. No paragraphs, no captions.
- It has to look stunning (owner feedback, repeated: "not enough yet"). The videos carry every effect of the
website: the dark `Panel` with `BitAura` (light along the edge, digits drifting off), `BitGlow` (light over
the dot grid) and `bit-aurora` (indigo light drifting behind the drawing); slides change with the `BitCurtain`
(ragged front of digits, `src/lib/curtain.ts`); the button has the `bit-cta` light running round its edge.
When the website gets a new effect, bring it here as a pure function of the frame in `src/lib`.
- Drawings follow `PathScenes.tsx`: fine light lines, tinted glass surfaces (`DRAW.tint`, `DRAW.glass`) instead
of solid blocks, amber packets with a halo (`DRAW.mark`), emerald for done, soft glows on what is active.
- UI in a video must be the website's real UI, rebuilt faithfully (owner feedback): read the component in
`apps/web/src/components` first. The button is `BrandButton` (twin of `Button.tsx`); do not draw your own.
- Almost no frame (owner feedback: the drawing matters far more than what surrounds it). No border, no name,
no topic label, no progress bar. Only one small line at the bottom: the logo, small and dimmed, and
`www.mintel.me` (with `www.` so that it reads as an address). The stage is scaled as large as the frame allows.
- Light is drawn with plain gradients that fade to transparent. No `filter: blur`, `mix-blend-mode` or dashed
strokes for glows: they came out with hard edges in the rendered video (owner feedback).
- Every video loops: the first slide is complete on frame 0 (it is the preview image) and fades back in at the
end. Anything that moves outside the slides (`Panel`) must repeat exactly over the length of the video.
- Frame-driven only: all motion comes from `useCurrentFrame()` / `interpolate()`. No `setTimeout`,
`setInterval`, CSS animations, `Math.random()` or framer-motion: they do not render correctly frame by frame.
Pure logic goes in `src/lib` with a `*.spec.ts`.
- 30 fps, about 15 seconds. The video must work without sound and make sense in the first two seconds.
- Copy rules as on the website (see root `CLAUDE.md`): no AI as the owner's tool, no internal method vocabulary,
modest and factual, no promises the owner did not confirm. Never write the full email address.
- The hero design of the website (binary-digit headline) is approved: do not recreate it here with changes.
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@mintel/video",
"private": true,
"type": "module",
"description": "Social media videos in the mintel.me look. Standalone: not part of the pnpm workspace, never imported by apps/web.",
"scripts": {
"studio": "remotion studio src/index.ts",
"render": "remotion render src/index.ts",
"still": "remotion still src/index.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"library": "node scripts/export.ts"
},
"dependencies": {
"@remotion/cli": "4.0.532",
"@remotion/google-fonts": "4.0.532",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"remotion": "4.0.532"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.2.0",
"typescript": "5.9.3",
"vitest": "~4.1.11"
}
}
+3632
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 745 744" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Favicon" x="0.855" y="0.277" width="743.633" height="743.633" style="fill:none;"/><path d="M739.657,49.961c-0,-24.755 -20.098,-44.852 -44.853,-44.852l-644.264,-0c-24.755,-0 -44.853,20.097 -44.853,44.852l-0,644.265c-0,24.755 20.098,44.853 44.853,44.853l644.264,-0c24.755,-0 44.853,-20.098 44.853,-44.853l-0,-644.265Z"/><path d="M135.778,286.282c0.157,0.236 0.52,0.761 0.852,1.092c1.2,1.192 2.956,2.358 5.296,3.587l0.005,0.003l2,1.327l1.693,1.752l1.239,2.072l0.713,2.215l0.211,2.192l-0.208,2.068l-1.385,3.646l-2.46,2.971l-1.634,1.187l-1.911,0.914l-2.161,0.551l-2.323,0.093l-2.343,-0.412l-2.211,-0.892c-11.589,-6.087 -16.814,-12.713 -18.39,-18.621c-1.835,-6.879 0.575,-13.751 6.509,-19.965c12.824,-13.43 44.389,-24.257 57.136,-27.454l0.001,-0c30.563,-7.658 62.165,-12.34 93.648,-13.693c25.54,-1.098 54.423,-1.803 78.831,7.271c15.51,5.768 26.031,14.726 32.759,25.559c56.484,-46.707 118.893,-93.116 187.414,-119.225c10.958,-4.178 27.246,-8.604 40.339,-7.415c7.908,0.718 14.744,3.442 19.461,8.573c4.662,5.072 7.541,12.672 6.521,23.945c-3.634,40.091 -31.543,80.109 -52.575,112.911c-25.848,40.321 -53.663,79.557 -82.723,117.821c29.191,-23.305 58.382,-46.61 87.573,-69.913l1.731,-1.145l1.85,-0.796l3.75,-0.585l3.526,0.562l3.17,1.552l2.672,2.595l1.028,1.728l0.705,1.995l0.285,2.178l-0.185,2.22l-0.631,2.115l-0.992,1.91c-10.505,16.533 -21.014,33.063 -31.523,49.592l-0.001,0.003c-1.852,2.909 -11.995,19.195 -18.14,30.842c-0.096,0.182 -0.192,0.366 -0.288,0.553c13.673,-3.721 27.615,-13.517 38.776,-19.936c10.441,-6.004 20.778,-12.208 30.865,-18.787l0.003,-0.002l2.134,-1.1l2.356,-0.626l2.421,-0.072l2.289,0.463l2.022,0.893l1.703,1.201l2.495,3.003l1.373,3.598l0.232,1.999l-0.139,2.106l-0.573,2.146l-1.048,2.067l-1.498,1.843l-1.854,1.496l-0.007,0.005c-15.715,10.242 -31.914,19.975 -48.604,28.526c-6.986,3.579 -18.808,10.744 -29.918,13.789c-9.41,2.579 -18.37,2.143 -24.958,-2.988c-5.648,-4.398 -7.104,-11.077 -5.042,-18.895c3.104,-11.773 15.551,-27.001 19.276,-32.858l2.604,-4.095c-37.274,29.759 -74.551,59.518 -111.826,89.274l-1.82,1.214l-2.004,0.868l-2.111,0.471l-2.12,0.061l-2.039,-0.329l-1.892,-0.676l-3.201,-2.224l-2.259,-3.202l-0.689,-1.912l-0.33,-2.068l0.077,-2.148l0.5,-2.129l0.902,-2.005l1.242,-1.804c59.233,-71.996 118.215,-147.452 163.946,-228.856l0.002,-0.003c3.532,-6.277 19.498,-32.912 25.637,-54.458c1.456,-5.11 2.365,-9.885 2.213,-13.918c-0.128,-3.403 -1.052,-6.169 -4.397,-6.847c-6.883,-1.395 -14.409,-0.182 -21.911,2.075c-12.591,3.787 -25.072,10.598 -34.871,15.502l-0.002,0.001c-37.202,18.606 -72.519,40.846 -106.083,65.396c-19.253,14.077 -38.067,28.773 -56.448,44.009c5.948,31.922 -8.725,71.663 -25.261,97.617c-26.624,41.789 -61.114,78.396 -97.084,112.241c-35.155,33.081 -71.676,66.504 -111.219,94.355l-0.002,0.001c-4.905,3.453 -13.056,9.944 -21.245,13.763c-7.008,3.268 -14.124,4.488 -20.021,2.432c-7.666,-2.674 -10.711,-8.481 -11.105,-15.23c-0.288,-4.928 1.117,-10.472 2.745,-14.802l0.001,-0.003c17.424,-46.26 54.722,-88.018 86.091,-125.21c52.005,-61.657 108.894,-119.681 170.402,-171.929c-5.142,-9.861 -13.608,-17.675 -25.833,-20.957c-27.596,-7.404 -57.826,-6.098 -86.019,-3.428c-30.452,2.883 -61.745,7.625 -90.667,17.984c-6.667,2.388 -17.118,6.215 -22.892,11.134c-0.89,0.758 -1.884,2.115 -2.149,2.485Zm-6.046,295.699c3.853,-1.713 7.757,-4.116 11.623,-6.805c12.067,-8.393 23.538,-19.805 31.614,-26.433c45.641,-37.472 90.707,-76.66 129.793,-121.083c26.644,-30.283 57.988,-66.814 65.641,-107.833c1.02,-5.466 1.414,-11.09 1.137,-16.634c-41.419,35.852 -80.575,74.39 -117.54,114.67c-41.853,45.61 -85.416,93.945 -115.619,148.393c-0.671,1.213 -4.049,9.375 -6.649,15.725Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+7
View File
@@ -0,0 +1,7 @@
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setCodec("h264");
Config.setPixelFormat("yuv420p");
Config.setCrf(16);
Config.setOverwriteOutput(true);
+205
View File
@@ -0,0 +1,205 @@
/**
* Renders the content library: every film and every image post in every language, with the text
* to post next to it, and an overview page in the order of the posting plan.
*
* node scripts/export.ts everything
* node scripts/export.ts --images only the image posts
* node scripts/export.ts --videos only the films
* node scripts/export.ts --only WebsiteDone one film or one image
* node scripts/export.ts --formats feed one video format (feed, wide, square, story)
* node scripts/export.ts --page only rewrite post texts and the overview page
*
* Output, in the order of the posting plan:
* out/library/index.html
* out/library/<language>/week-<n>/<slot>-<day>-<id>/ the files to upload and post.txt
* Anything else in out/ is scratch: put checks and logs into out/tmp, which can be deleted at any time.
* Run one export at a time, and redirect its log with `>|`, not `>`.
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { IMAGES, IMAGE_IDS, type ImageId } from "../src/content/images.ts";
import { PLAN, POSTING_DAYS, type PlannedPost } from "../src/content/plan.ts";
import { COPY, LANGS, VIDEO_IDS, type Lang, type VideoId } from "../src/content/videos.ts";
const LIBRARY = join("out", "library");
const DEFAULT_VIDEO_FORMATS = ["feed", "wide"];
const IMAGE_FORMATS = ["feed", "square"];
const FORMAT_NOTE: Readonly<Record<string, string>> = {
feed: "4:5, LinkedIn feed",
wide: "16:9, YouTube and Google Ads",
square: "1:1",
story: "9:16, vertical",
};
const option = (name: string): string | undefined => {
const index = process.argv.indexOf(`--${name}`);
return index === -1 ? undefined : process.argv[index + 1];
};
const flag = (name: string): boolean => process.argv.includes(`--${name}`);
const only = option("only");
const videoFormats = option("formats")?.split(",") ?? DEFAULT_VIDEO_FORMATS;
const pageOnly = flag("page");
const wanted = (id: string): boolean => only === undefined || id === only;
const videoIds = flag("images") ? [] : VIDEO_IDS.filter(wanted);
const imageIds = flag("videos") ? [] : IMAGE_IDS.filter(wanted);
if (videoIds.length + imageIds.length === 0) {
throw new Error(`Nothing to export for "${only}". Known: ${[...VIDEO_IDS, ...IMAGE_IDS].join(", ")}`);
}
/** Where a post lives inside the library: by language, then week, then day, as it will be posted. */
const place = (id: string, lang: Lang): string => {
for (const [weekIndex, week] of PLAN.entries()) {
const slot = week.posts.findIndex((post) => post.id === id);
if (slot !== -1) {
return `${lang}/week-${weekIndex + 1}/${slot + 1}-${(POSTING_DAYS[slot] ?? "").toLowerCase()}-${id}`;
}
}
throw new Error(`${id} is not in the posting plan (src/content/plan.ts)`);
};
const folder = (id: string, lang: Lang): string => join(LIBRARY, place(id, lang));
const fileName = (id: string, lang: Lang, format: string, extension: string): string =>
`${id}-${lang}-${format}.${extension}`;
/** Runs a Remotion command and makes sure it really wrote the file. */
const render = (command: "render" | "still", composition: string, target: string, frame = 0): void => {
console.log(`Rendering ${target}`);
const startedAt = Date.now();
const extra = command === "still" ? [`--frame=${frame}`] : [];
execFileSync("pnpm", ["exec", "remotion", command, "src/index.ts", composition, target, "--log=error", ...extra], {
stdio: "inherit",
});
// A render can end without an error and without a new file (seen with two renders started at once).
if (!existsSync(target) || statSync(target).mtimeMs < startedAt) {
throw new Error(`${target} was not written. Run one export at a time.`);
}
};
const postText = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
kind === "video" ? COPY[id as VideoId][lang].post : IMAGES[id as ImageId].copy[lang].post;
const title = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
kind === "video" ? COPY[id as VideoId][lang].title : IMAGES[id as ImageId].copy[lang].title;
for (const lang of LANGS) {
for (const id of videoIds) {
mkdirSync(folder(id, lang), { recursive: true });
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("video", id, lang)}\n`);
if (pageOnly) continue;
for (const format of videoFormats) {
render("render", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "mp4")));
}
}
for (const id of imageIds) {
mkdirSync(folder(id, lang), { recursive: true });
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("image", id, lang)}\n`);
if (pageOnly) continue;
for (const format of IMAGE_FORMATS) {
render("still", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "png")), IMAGES[id].poster?.frame);
}
}
}
const escapeHtml = (text: string): string =>
text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const media = (post: PlannedPost, lang: Lang): string => {
const extension = post.kind === "video" ? "mp4" : "png";
const shown = Object.keys(FORMAT_NOTE)
.filter((format) => existsSync(join(folder(post.id, lang), fileName(post.id, lang, format, extension))))
.map((format) => {
const src = `${place(post.id, lang)}/${fileName(post.id, lang, format, extension)}`;
const element =
post.kind === "video"
? `<video src="${src}" controls loop muted playsinline preload="metadata"></video>`
: `<img src="${src}" alt="" loading="lazy">`;
return `<figure class="${format}">${element}<figcaption>${FORMAT_NOTE[format]} · <a href="${src}" download>Download</a></figcaption></figure>`;
})
.join("\n");
return shown || "<p>Not rendered yet.</p>";
};
const card = (post: PlannedPost, day: string, lang: Lang): string => `<article lang="${lang}" data-lang="${lang}">
<header><span class="tag">${day} · ${post.kind}</span><h3>${escapeHtml(title(post.kind, post.id, lang))}</h3></header>
<div class="media">${media(post, lang)}</div>
<div class="post">
<div class="post-head"><span class="tag">Post text</span><button type="button" class="copy">Copy</button></div>
<textarea readonly rows="12">${escapeHtml(postText(post.kind, post.id, lang))}</textarea>
</div>
</article>`;
const weeks = PLAN.map(
(week, index) => `<section>
<h2><span class="tag">Week ${index + 1}</span>${escapeHtml(week.topic)}</h2>
${week.posts.flatMap((post, slot) => LANGS.map((lang) => card(post, POSTING_DAYS[slot] ?? "", lang))).join("\n")}
</section>`,
).join("\n");
const page = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Content library</title>
<style>
:root { --ink: #0f172a; --muted: #64748b; --line: #e2e8f0; --wash: #f8fafc; }
* { box-sizing: border-box; }
body { margin: 0; background: #fff; color: var(--ink); font-family: Inter, system-ui, sans-serif; }
main { max-width: 1400px; margin: 0 auto; padding: 48px 20px 96px; }
h1 { font-size: clamp(40px, 7vw, 88px); font-weight: 900; letter-spacing: -0.05em; line-height: 0.95; margin: 0 0 12px; }
.intro { color: var(--muted); font-size: 18px; max-width: 62ch; margin: 0 0 24px; }
.switch { display: flex; gap: 8px; margin-bottom: 40px; }
section { border-top: 2px solid var(--ink); padding-top: 20px; margin-top: 56px; }
h2 { font-size: 34px; letter-spacing: -0.03em; margin: 0 0 8px; display: flex; flex-direction: column; gap: 8px; }
h3 { font-size: 22px; letter-spacing: -0.02em; margin: 0; }
article { border-top: 1px solid var(--line); padding: 28px 0; display: grid; gap: 20px; }
@media (min-width: 900px) { article { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); } article header { grid-column: 1 / -1; } }
body[data-show="de"] article[data-lang="en"], body[data-show="en"] article[data-lang="de"] { display: none; }
header { display: flex; flex-direction: column; gap: 6px; }
.tag, figcaption { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
.media { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
figure { margin: 0; }
video, img { display: block; width: 100%; border-radius: 16px; background: var(--ink); }
figure.feed, figure.square { width: min(100%, 300px); }
figure.story { width: min(100%, 230px); }
figure.wide { width: min(100%, 460px); }
figcaption { margin-top: 10px; }
a { color: var(--ink); }
.post-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
textarea { width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 16px; font: 15px/1.5 Inter, system-ui, sans-serif; color: var(--ink); background: var(--wash); resize: vertical; }
button { border: 1px solid var(--ink); border-radius: 999px; background: var(--ink); color: #fff; padding: 10px 20px; font: 700 11px Inter, system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
button[aria-pressed="false"] { background: #fff; color: var(--ink); }
</style>
</head>
<body data-show="de">
<main>
<h1>Content library</h1>
<p class="intro">Six weeks, three posts each: the film on Tuesday, a card on Thursday, the poster on Saturday. The plan is in <code>src/content/plan.ts</code>, the reasoning in <code>apps/web/plans/content-strategy.md</code>.</p>
<div class="switch">
<button type="button" data-set="de" aria-pressed="true">Deutsch</button>
<button type="button" data-set="en" aria-pressed="false">English</button>
</div>
${weeks}
</main>
<script>
for (const button of document.querySelectorAll("[data-set]")) {
button.addEventListener("click", () => {
document.body.dataset.show = button.dataset.set;
for (const other of document.querySelectorAll("[data-set]")) other.setAttribute("aria-pressed", String(other === button));
});
}
for (const button of document.querySelectorAll(".copy")) {
button.addEventListener("click", async () => {
await navigator.clipboard.writeText(button.closest(".post").querySelector("textarea").value);
button.textContent = "Copied";
setTimeout(() => { button.textContent = "Copy"; }, 1500);
});
}
</script>
</body>
</html>
`;
mkdirSync(LIBRARY, { recursive: true });
writeFileSync(join(LIBRARY, "index.html"), page);
console.log(`Overview: ${join(LIBRARY, "index.html")}`);
+50
View File
@@ -0,0 +1,50 @@
import type { FC } from "react";
import { Composition } from "remotion";
import { FORMATS, FPS, type FormatName } from "./brand/tokens";
import { IMAGES, IMAGE_IDS } from "./content/images";
import { LANGS } from "./content/videos";
import { ImagePost } from "./images/ImagePost";
import { VIDEOS } from "./registry";
import { SHOT_FRAMES } from "./films/shot";
import { ServiceVideo, videoDuration } from "./slides/ServiceVideo";
const FORMAT_NAMES = Object.keys(FORMATS) as FormatName[];
/** Images are posted in the feed: 4:5, and square for places that crop. */
const IMAGE_FORMATS = ["feed", "square"] as const;
/** Videos are `<VideoId>-<language>-<format>`, images `<ImageId>-<language>-<feed|square>`. */
export const Root: FC = () => (
<>
{VIDEOS.flatMap((video) =>
LANGS.flatMap((lang) =>
FORMAT_NAMES.map((format) => (
<Composition
key={`${video.id}-${lang}-${format}`}
id={`${video.id}-${lang}-${format}`}
component={() => <ServiceVideo video={video} lang={lang} />}
durationInFrames={videoDuration(video)}
fps={FPS}
width={FORMATS[format].width}
height={FORMATS[format].height}
/>
)),
),
)}
{IMAGE_IDS.flatMap((id) =>
LANGS.flatMap((lang) =>
IMAGE_FORMATS.map((format) => (
// A poster is a film at one of its frames, so it needs the film's length; a card is a single frame.
<Composition
key={`${id}-${lang}-${format}`}
id={`${id}-${lang}-${format}`}
component={() => <ImagePost id={id} lang={lang} />}
durationInFrames={IMAGES[id].poster ? SHOT_FRAMES : 1}
fps={FPS}
width={FORMATS[format].width}
height={FORMATS[format].height}
/>
)),
),
)}
</>
);
+70
View File
@@ -0,0 +1,70 @@
import type { FC, ReactNode } from "react";
import { Easing, interpolate, useCurrentFrame } from "remotion";
import { FONT } from "../brand/fonts";
import { COLOR } from "../brand/tokens";
import { bitString } from "../lib/bits";
const COLUMN_DIGITS = 48;
const SCRAMBLE_EVERY_FRAMES = 2;
const EDGE_PCT = 2;
/**
* Content revealed left to right behind a narrow column of binary digits (video twin of
* BitWipe on the site). The content keeps its place, so nothing reflows.
*/
export const BitWipe: FC<{
children: ReactNode;
/** Frame (inside the current sequence) where the wipe starts. */
from?: number;
durationInFrames?: number;
/** Size of the digit column in px. */
digitSize?: number;
tone?: "dark" | "light";
}> = ({ children, from = 0, durationInFrames = 24, digitSize = 22, tone = "dark" }) => {
const frame = useCurrentFrame();
const t = interpolate(frame, [from, from + durationInFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.bezier(0.65, 0, 0.35, 1),
});
const done = t >= 1;
const rightInset = 100 - t * (100 + EDGE_PCT);
const columnOpacity = interpolate(t, [0, 0.08, 0.86, 1], [0, 1, 1, 0]);
return (
<div style={{ position: "relative", display: "inline-block", whiteSpace: "nowrap" }}>
<div
style={{
clipPath: done
? "none"
: `inset(-12% ${Math.max(0, rightInset)}% -12% -2%)`,
}}
>
{children}
</div>
{t > 0 && !done && (
<span
aria-hidden="true"
style={{
position: "absolute",
top: 0,
bottom: 0,
left: `${t * 100}%`,
width: digitSize * 1.1,
overflow: "hidden",
opacity: columnOpacity,
fontFamily: FONT.mono,
fontWeight: 700,
fontSize: digitSize,
lineHeight: 1.05,
writingMode: "vertical-rl",
wordBreak: "break-all",
color: tone === "light" ? COLOR.paper : COLOR.ink,
}}
>
{bitString(COLUMN_DIGITS, Math.floor(frame / SCRAMBLE_EVERY_FRAMES))}
</span>
)}
</div>
);
};
+39
View File
@@ -0,0 +1,39 @@
import type { CSSProperties, FC } from "react";
import { Easing, interpolate, useCurrentFrame } from "remotion";
import { FONT } from "../brand/fonts";
import { COLOR } from "../brand/tokens";
import { decodeText } from "../lib/bits";
const SCRAMBLE_EVERY_FRAMES = 2;
/** Mono label resolving from digits (video twin of DecodeText on the site). Monospaced only. */
export const DecodeLabel: FC<{
text: string;
/** Frame (inside the current sequence) where decoding starts. */
from?: number;
durationInFrames?: number;
style?: CSSProperties;
}> = ({ text, from = 0, durationInFrames = 18, style }) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [from, from + durationInFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
});
const tick = Math.floor(frame / SCRAMBLE_EVERY_FRAMES);
return (
<span
style={{
fontFamily: FONT.mono,
fontWeight: 500,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: COLOR.muted,
whiteSpace: "pre",
...style,
}}
>
{decodeText(text, progress, tick)}
</span>
);
};
+102
View File
@@ -0,0 +1,102 @@
import type { CSSProperties, FC } from "react";
import { useCurrentFrame } from "remotion";
import { useT } from "../content/CopyContext";
import { FONT } from "./fonts";
import { COLOR, DRAW } from "./tokens";
/** One lap of the light round the edge, as on the website (6 s at 30 fps). */
const LAP_FRAMES = 180;
const SWEEP_FRAMES = 60;
/** Shows only a ring of the given thickness along the edge of its box. */
const ring = (thickness: number): CSSProperties => ({
padding: thickness,
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
});
/**
* The primary button of the website (apps/web Button.tsx with `bit-cta`): dark pill, bold uppercase
* label, the arrow in a circle, a light travelling round the edge with a soft glow behind it, and
* the spot of light that follows the pointer there. Here that spot sweeps across on its own.
*/
export const BrandButton: FC<{
label: string;
/** Font size in px; padding, arrow and light scale with it. */
size?: number;
/** `light` is the white pill the website uses on dark surfaces. */
tone?: "dark" | "light";
}> = ({ label, size = 28, tone = "dark" }) => {
const light = tone === "light";
const frame = useCurrentFrame();
const t = useT();
const circle = size * 2.7;
const angle = (frame / LAP_FRAMES) * 360;
const travelling = `conic-gradient(from ${angle}deg, transparent 0 58%, rgba(${DRAW.light}, 0.95) 76%, #ffffff 86%, rgba(${DRAW.light}, 0.95) 93%, transparent 100%)`;
const sweep = ((frame % SWEEP_FRAMES) / SWEEP_FRAMES) * 140 - 20;
const edge: CSSProperties = { position: "absolute", borderRadius: 9999, background: travelling };
return (
<span style={{ position: "relative", display: "inline-flex", borderRadius: 9999 }}>
<span style={{ position: "absolute", inset: -14, filter: "blur(12px)", opacity: 0.8 }}>
<span style={{ ...edge, inset: 8, ...ring(12) }} />
</span>
<span
style={{
position: "relative",
display: "inline-flex",
alignItems: "center",
gap: size * 1.1,
overflow: "hidden",
borderRadius: 9999,
backgroundColor: light ? COLOR.paper : COLOR.ink,
color: light ? COLOR.ink : COLOR.paper,
padding: `${size * 0.8}px ${size * 0.8}px ${size * 0.8}px ${size * 2.3}px`,
fontFamily: FONT.sans,
fontWeight: 700,
fontSize: size,
letterSpacing: "0.18em",
textTransform: "uppercase",
boxShadow: "0 20px 30px -10px rgba(15, 23, 42, 0.35)",
}}
>
<span
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(${size * 5}px circle at ${sweep}% 50%, rgba(${DRAW.light}, 0.5), transparent 70%)`,
}}
/>
<span style={{ position: "relative" }}>{t(label)}</span>
<span
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: circle,
height: circle,
borderRadius: 9999,
backgroundColor: light ? COLOR.ink : "rgba(255, 255, 255, 0.1)",
color: COLOR.paper,
}}
>
<svg
width={size * 1.2}
height={size * 1.2}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.25}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</span>
</span>
<span style={{ ...edge, inset: -2, ...ring(4) }} />
</span>
);
};
+94
View File
@@ -0,0 +1,94 @@
import type { FC, ReactNode } from "react";
import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { bitString } from "../lib/bits";
import { FONT } from "./fonts";
import { COLOR } from "./tokens";
const LOGO_SIZE = 36;
/** Width of one digit of the texture including its letter spacing, in px. */
const RAIN_COLUMN_WIDTH = 28.6;
const RAIN_ROW_HEIGHT = 40;
const RAIN_EVERY_FRAMES = 6;
const RAIN_OPACITY = 0.03;
/** Slowly changing field of digits behind everything, as quiet texture. Fills its box at any format. */
const BitRain: FC<{ inset: number }> = ({ inset }) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const columns = Math.ceil((width - inset * 2) / RAIN_COLUMN_WIDTH);
const tick = Math.floor(frame / RAIN_EVERY_FRAMES);
const rowCount = Math.ceil((height - inset * 2) / RAIN_ROW_HEIGHT);
const rows = Array.from({ length: rowCount }, (_, row) =>
bitString(columns, tick + row * 7),
);
return (
<div
style={{
position: "absolute",
top: inset,
left: inset,
right: inset,
bottom: inset,
fontFamily: FONT.mono,
fontWeight: 500,
fontSize: 26,
lineHeight: `${RAIN_ROW_HEIGHT}px`,
letterSpacing: "0.5em",
color: COLOR.ink,
opacity: RAIN_OPACITY,
overflow: "hidden",
whiteSpace: "pre",
padding: "0 24px",
}}
>
{rows.join("\n")}
</div>
);
};
/** Space kept free at the sides, the top and (for the footer) the bottom of the frame, in px. */
export const FRAME = { side: 48, top: 48, bottom: 104 } as const;
/**
* What surrounds the drawing: paper, a quiet digit texture, and one small line at the bottom with
* the logo and the address. Deliberately little (owner feedback): the drawing is what matters.
*/
export const BrandFrame: FC<{ children: ReactNode }> = ({ children }) => (
<AbsoluteFill style={{ backgroundColor: COLOR.paper }}>
<BitRain inset={0} />
<div
style={{
position: "absolute",
left: FRAME.side,
right: FRAME.side,
bottom: 36,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Img src={staticFile("logo.svg")} style={{ width: LOGO_SIZE, height: LOGO_SIZE, opacity: 0.55 }} />
<span
style={{
fontFamily: FONT.mono,
fontWeight: 500,
fontSize: 20,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: COLOR.muted,
}}
>
www.mintel.me
</span>
</div>
<AbsoluteFill
style={{
padding: `${FRAME.top}px ${FRAME.side}px ${FRAME.bottom}px`,
justifyContent: "center",
alignItems: "center",
}}
>
{children}
</AbsoluteFill>
</AbsoluteFill>
);
+134
View File
@@ -0,0 +1,134 @@
import type { CSSProperties, FC, ReactNode } from "react";
import { useCurrentFrame, useVideoConfig } from "remotion";
import { cometAt, outlinePoint, sparkAt } from "../lib/aura";
import { FONT } from "./fonts";
import { COLOR, DRAW } from "./tokens";
const RADIUS = 40;
const SPARKS = 150;
const DOT_GRID = 28;
const LIGHT_REACH = 360;
/** How far the light on the edge reaches into the panel and along the edge, in px. */
const EDGE_HAZE = 300;
const EDGE_LINE = 380;
const EDGE_WIDTH = 3;
/** Two lights travel the edge, opposite each other; the second is fainter. */
const COMETS = [
{ offset: 0, power: 1 },
{ offset: 0.5, power: 0.6 },
] as const;
/** Shows only a ring of the given thickness along the edge of its box. */
const ring = (thickness: number): CSSProperties => ({
padding: thickness,
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
});
const fill: CSSProperties = { position: "absolute", inset: 0 };
/**
* The dark panel the drawings sit on, with the website's effects (BitAura, BitGlow, bit-aurora):
* light travelling round its edge, binary digits drifting off it, a soft light wandering over
* the dot grid, and slow indigo light drifting behind the drawing.
*
* All light is drawn with plain gradients that fade to transparent: no blur filters, blend modes
* or dashed strokes, which render with hard edges frame by frame. Everything repeats exactly when
* the video loops. Must be rendered outside any Sequence, so that it sees the frame of the whole video.
*/
export const Panel: FC<{
width: number;
height: number;
/** Corner radius in px; 0 for a panel that fills the whole frame. */
radius?: number;
children: ReactNode;
}> = ({ width, height, radius = RADIUS, children }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const box = { width, height };
const turn = (frame / durationInFrames) * Math.PI * 2;
const lightX = width * (0.5 + 0.34 * Math.sin(turn * 2));
const lightY = height * (0.5 + 0.3 * Math.cos(turn * 3));
const comets = COMETS.map((comet) => ({
...outlinePoint(cometAt(frame, durationInFrames) + comet.offset, box),
power: comet.power,
}));
const sparks = Array.from({ length: SPARKS }, (_, index) => sparkAt(index, frame, durationInFrames, box));
const auroraX = 38 + Math.sin(turn) * 8;
const auroraY = 42 + Math.cos(turn) * 7;
return (
<div style={{ position: "relative", width, height }}>
<div
style={{
...fill,
borderRadius: radius,
overflow: "hidden",
backgroundColor: DRAW.bg,
backgroundImage: `radial-gradient(${DRAW.raised} 2px, transparent 2px)`,
backgroundSize: `${DOT_GRID}px ${DOT_GRID}px`,
boxShadow: "0 40px 80px -30px rgba(15, 23, 42, 0.55)",
}}
>
<div
style={{
...fill,
background: `radial-gradient(55% 65% at ${auroraX}% ${auroraY}%, rgba(${DRAW.glow}, 0.34), transparent 72%), radial-gradient(45% 55% at ${102 - auroraX}% ${112 - auroraY}%, rgba(${DRAW.light}, 0.2), transparent 72%)`,
}}
/>
<div
style={{
...fill,
backgroundImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, rgba(148, 163, 184, 0.2), transparent 70%), radial-gradient(#e2e8f0 1.75px, transparent 1.75px)`,
backgroundSize: `auto, ${DOT_GRID}px ${DOT_GRID}px`,
maskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
WebkitMaskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
}}
/>
{comets.map((comet) => (
<div
key={comet.power}
style={{
...fill,
background: `radial-gradient(${EDGE_HAZE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.light}, ${0.5 * comet.power}), rgba(${DRAW.glow}, ${0.22 * comet.power}) 40%, transparent 72%)`,
}}
/>
))}
<div style={fill}>{children}</div>
{comets.map((comet) => (
<div
key={comet.power}
style={{
...fill,
borderRadius: radius,
...ring(EDGE_WIDTH),
background: `radial-gradient(${EDGE_LINE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.core}, ${comet.power}), rgba(${DRAW.light}, ${0.7 * comet.power}) 35%, transparent 75%)`,
}}
/>
))}
</div>
<svg width={width} height={height} style={{ ...fill, overflow: "visible" }}>
{sparks.map((spark, index) =>
spark === null ? null : (
<text
key={index}
x={spark.x}
y={spark.y}
textAnchor="middle"
dominantBaseline="middle"
fontFamily={FONT.mono}
fontWeight={spark.large ? 700 : 500}
fontSize={spark.large ? 24 : 18}
fill={COLOR.ink}
opacity={spark.opacity * 0.8}
>
{spark.digit}
</text>
),
)}
</svg>
</div>
);
};
+14
View File
@@ -0,0 +1,14 @@
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";
import { loadFont as loadSerif } from "@remotion/google-fonts/Newsreader";
const inter = loadInter("normal", { weights: ["400", "600", "700", "900"], subsets: ["latin"] });
const mono = loadMono("normal", { weights: ["500", "700"], subsets: ["latin"] });
const serif = loadSerif("normal", { weights: ["400"], subsets: ["latin"] });
/** Same three families as the website: Inter for headlines, JetBrains Mono for labels, Newsreader for reading text. */
export const FONT = {
sans: inter.fontFamily,
mono: mono.fontFamily,
serif: serif.fontFamily,
} as const;
+42
View File
@@ -0,0 +1,42 @@
/** Brand tokens, mirrored from apps/web (Tailwind slate scale, white paper, mono labels). */
export const COLOR = {
paper: "#ffffff",
ink: "#0f172a", // slate-900
inkSoft: "#1e293b", // slate-800
body: "#475569", // slate-600
muted: "#64748b", // slate-500
faint: "#94a3b8", // slate-400
line: "#e2e8f0", // slate-200
wash: "#f8fafc", // slate-50
active: "#fbbf24", // amber-400: work in progress
done: "#10b981", // emerald-500: finished
} as const;
/** Colours for drawings, which sit on a dark panel like the animated panels of the website. */
export const DRAW = {
bg: "#0f172a", // slate-900: the panel
raised: "#1e293b", // slate-800: quiet surfaces on the panel
soft: "#334155", // slate-700: placeholder lines and blocks
label: "#94a3b8", // slate-400: mono labels
fg: "#f8fafc", // slate-50: lines, tiles, text
rim: "#64748b", // slate-500: fine outlines of quiet things
tint: "rgba(248, 250, 252, 0.14)", // a lit surface: a light tint instead of a solid block
glass: "rgba(15, 23, 42, 0.55)", // windows and cards: the dot grid shows through
mark: "#fde68a", // amber-200: work that is travelling
glow: "99, 102, 241", // indigo-500 as r, g, b: the light along the edge
light: "129, 140, 248", // indigo-400 as r, g, b: the light on buttons and nodes
core: "224, 231, 255", // indigo-100 as r, g, b: its bright centre
} as const;
/** Output formats. LinkedIn feed default is 4:5; the others are variants of the same video. */
export const FORMATS = {
feed: { width: 1080, height: 1350 },
square: { width: 1080, height: 1080 },
story: { width: 1080, height: 1920 },
/** 16:9 for YouTube and Google Ads video campaigns. */
wide: { width: 1920, height: 1080 },
} as const;
export type FormatName = keyof typeof FORMATS;
export const FPS = 30;
+10
View File
@@ -0,0 +1,10 @@
import { AppDrawing, AppFilm } from "../films/AppFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
export const appRescue: VideoEntry = {
id: "AppRescue",
Film: AppFilm,
Drawing: AppDrawing,
durationInFrames: SHOT_FRAMES,
};
@@ -0,0 +1,10 @@
import { DocumentsDrawing, DocumentsFilm } from "../films/DocumentsFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
export const automationDocuments: VideoEntry = {
id: "AutomationDocuments",
Film: DocumentsFilm,
Drawing: DocumentsDrawing,
durationInFrames: SHOT_FRAMES,
};
@@ -0,0 +1,10 @@
import { EnquiryDrawing, EnquiryFilm } from "../films/EnquiryFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
export const automationEnquiry: VideoEntry = {
id: "AutomationEnquiry",
Film: EnquiryFilm,
Drawing: EnquiryDrawing,
durationInFrames: SHOT_FRAMES,
};
@@ -0,0 +1,9 @@
import { ORDER_FILM_FRAMES, OrderDrawing, OrderFilm } from "../films/OrderFilm";
import type { VideoEntry } from "../slides/ServiceVideo";
export const automationOrder: VideoEntry = {
id: "AutomationOrder",
Film: OrderFilm,
Drawing: OrderDrawing,
durationInFrames: ORDER_FILM_FRAMES,
};
@@ -0,0 +1,10 @@
import { DeveloperDrawing, DeveloperFilm } from "../films/DeveloperFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
export const productDeveloper: VideoEntry = {
id: "ProductDeveloper",
Film: DeveloperFilm,
Drawing: DeveloperDrawing,
durationInFrames: SHOT_FRAMES,
};
@@ -0,0 +1,10 @@
import { WebsiteDrawing, WebsiteFilm } from "../films/WebsiteFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
export const websiteDone: VideoEntry = {
id: "WebsiteDone",
Film: WebsiteFilm,
Drawing: WebsiteDrawing,
durationInFrames: SHOT_FRAMES,
};
+23
View File
@@ -0,0 +1,23 @@
import { createContext, useContext } from "react";
import { translate, type Lang, type VideoCopy } from "./videos";
type CopyValue = { readonly lang: Lang; readonly copy: VideoCopy };
const CopyContext = createContext<CopyValue | null>(null);
export const CopyProvider = CopyContext.Provider;
const useCopyValue = (): CopyValue => {
const value = useContext(CopyContext);
if (!value) throw new Error("This component must be rendered inside a ServiceVideo");
return value;
};
/** The texts of the video that is being rendered, in its language. */
export const useCopy = (): VideoCopy => useCopyValue().copy;
/** Translates a label that is written in English in the drawing code. */
export const useT = (): ((text: string) => string) => {
const { lang } = useCopyValue();
return (text) => translate(lang, text);
};
+236
View File
@@ -0,0 +1,236 @@
/**
* Image posts: every word, in English and German, and for posters which film and which frame they
* show. Two kinds:
*
* - a poster is one picture of a film (the composition is rendered at that frame) with the film's idea in two lines
* - a card answers one question a client asks before the first call, in large type
*
* Everything said here is on the website (`apps/web/src/content/de.ts`, questions and principles).
* This file imports only types, so the export script can read it with plain Node.
*/
import type { Lang, VideoId } from "./videos.ts";
export type ImageCopy = {
/** What the image is about, for the overview page. */
readonly title: string;
/** Cards only: the question, set small above the answer. */
readonly label?: string;
/** The large lines: at most three, as short as possible. */
readonly lines: readonly string[];
/** Text to post with the image. */
readonly post: string;
};
export type ImagePost = {
/** Posters only: the film whose picture is shown, and at which frame. */
readonly poster?: { readonly video: VideoId; readonly frame: number };
readonly copy: Readonly<Record<Lang, ImageCopy>>;
};
export const IMAGE_IDS = [
"PosterOrder",
"PosterDocuments",
"PosterEnquiry",
"PosterWebsite",
"PosterApp",
"PosterDeveloper",
"CardNotTechnical",
"CardOneContact",
"CardOwnership",
"CardHours",
"CardNotice",
"CardNotMyField",
] as const;
export type ImageId = (typeof IMAGE_IDS)[number];
const CALL_EN = "A first call takes about 20 minutes.\n\nwww.mintel.me";
const CALL_DE = "Ein erstes Gespräch dauert etwa 20 Minuten.\n\nwww.mintel.me";
export const IMAGES: Readonly<Record<ImageId, ImagePost>> = {
PosterOrder: {
poster: { video: "AutomationOrder", frame: 285 },
copy: {
en: {
title: "Poster: ping-pong",
lines: ["Not ping-pong.", "Your office."],
post: `Every dot is someone carrying an order from one tool to the next. By hand.\n\nThe tools could pass it on themselves. I connect them.\n\n${CALL_EN}\n\n#automation #processautomation`,
},
de: {
title: "Poster: Pingpong",
lines: ["Kein Pingpong.", "Ihr Büro."],
post: `Jeder Punkt ist jemand, der eine Bestellung von einem Tool ins nächste trägt. Von Hand.\n\nDie Tools könnten das unter sich ausmachen. Ich verbinde sie.\n\n${CALL_DE}\n\n#automatisierung #prozessautomatisierung`,
},
},
},
PosterDocuments: {
poster: { video: "AutomationDocuments", frame: 300 },
copy: {
en: {
title: "Poster: skyscraper",
lines: ["Not a skyscraper.", "Your inbox."],
post: `Every floor is an invoice waiting to be typed in.\n\nIt can be read automatically. Clear cases are booked directly, unclear ones go to a person.\n\n${CALL_EN}\n\n#automation #accounting`,
},
de: {
title: "Poster: Hochhaus",
lines: ["Kein Hochhaus.", "Ihr Posteingang."],
post: `Jedes Stockwerk ist eine Rechnung, die noch jemand abtippen muss.\n\nDas lässt sich automatisch auslesen. Klare Fälle werden direkt gebucht, unklare gehen an einen Menschen.\n\n${CALL_DE}\n\n#automatisierung #buchhaltung`,
},
},
},
PosterEnquiry: {
poster: { video: "AutomationEnquiry", frame: 250 },
copy: {
en: {
title: "Poster: stars",
lines: ["Not a night sky.", "Your enquiries."],
post: `The faint ones are the enquiries nobody followed up on.\n\nThe steps can be connected: the contact is created, the right person is told, and a reminder appears when nobody has replied.\n\n${CALL_EN}\n\n#automation #sales`,
},
de: {
title: "Poster: Sterne",
lines: ["Kein Sternenhimmel.", "Ihre Anfragen."],
post: `Die blassen sind die Anfragen, bei denen niemand nachgefasst hat.\n\nDie Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn niemand geantwortet hat, erscheint eine Erinnerung.\n\n${CALL_DE}\n\n#automatisierung #vertrieb`,
},
},
},
PosterWebsite: {
poster: { video: "WebsiteDone", frame: 260 },
copy: {
en: {
title: "Poster: empty page",
lines: ["Not a beginning.", "Your website."],
post: `The website that has been on the list since last year.\n\nYou tell me what your business does. I design it, build it and put it online. Changes afterwards: you send me an email.\n\n${CALL_EN}\n\n#website #webdesign`,
},
de: {
title: "Poster: leere Seite",
lines: ["Kein Anfang.", "Ihre Website."],
post: `Die Website, die seit letztem Jahr auf der Liste steht.\n\nSie erzählen mir, was Ihr Unternehmen macht. Ich gestalte sie, baue sie und bringe sie online. Änderungen danach: Sie schreiben mir eine E-Mail.\n\n${CALL_DE}\n\n#website #webdesign`,
},
},
},
PosterApp: {
poster: { video: "AppRescue", frame: 285 },
copy: {
en: {
title: "Poster: Jenga",
lines: ["Not Jenga.", "Your app."],
post: `It works. But bugs come back, and small changes take longer than they should.\n\nI take a week to go through the code, fix what is most urgent and write down what I would do next. In plain language.\n\n${CALL_EN}\n\n#webdevelopment #softwarequality`,
},
de: {
title: "Poster: Jenga",
lines: ["Kein Jenga.", "Ihre App."],
post: `Sie läuft. Aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten.\n\nIch nehme mir eine Woche, gehe den Code durch, behebe das Dringendste und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache.\n\n${CALL_DE}\n\n#webentwicklung #softwarequalität`,
},
},
},
PosterDeveloper: {
poster: { video: "ProductDeveloper", frame: 300 },
copy: {
en: {
title: "Poster: loading bars",
lines: ["Not loading bars.", "Your backlog."],
post: `More work than your team can handle.\n\nI join on agreed days of the week and work on what is next on your list. No package to buy, no minimum term.\n\n${CALL_EN}\n\n#webdevelopment #freelance`,
},
de: {
title: "Poster: Ladebalken",
lines: ["Keine Ladebalken.", "Ihr Backlog."],
post: `Mehr Arbeit, als Ihr Team schafft.\n\nIch komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes auf Ihrer Liste steht. Kein Paket, keine Mindestlaufzeit.\n\n${CALL_DE}\n\n#webentwicklung #freelancer`,
},
},
},
CardNotTechnical: {
copy: {
en: {
title: "Card: not technical",
label: "Not a technical person?",
lines: ["Doesn't matter.", "You never have", "to read code."],
post: `I explain what I am doing in plain language, and you can ask at any time.\n\n${CALL_EN}`,
},
de: {
title: "Karte: kein Technikmensch",
label: "Kein Technikmensch?",
lines: ["Macht nichts.", "Code müssen Sie", "nicht lesen."],
post: `Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen.\n\n${CALL_DE}`,
},
},
},
CardOneContact: {
copy: {
en: {
title: "Card: one contact",
label: "Who does the work?",
lines: ["The person", "you talk to."],
post: `Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.\n\n${CALL_EN}`,
},
de: {
title: "Karte: ein Ansprechpartner",
label: "Wer macht die Arbeit?",
lines: ["Der, mit dem", "Sie sprechen."],
post: `Oft geht eine Anfrage durch mehrere Hände, bevor sie beim Entwickler ankommt. Bei mir gibt es eine direkte Leitung zwischen Ihnen und dem, der die Arbeit macht.\n\n${CALL_DE}`,
},
},
},
CardOwnership: {
copy: {
en: {
title: "Card: ownership",
label: "Who owns the code?",
lines: ["You do.", "The design too."],
post: `Code and design belong to you, and you can hand both to another developer.\n\n${CALL_EN}`,
},
de: {
title: "Karte: Eigentum",
label: "Wem gehört der Code?",
lines: ["Ihnen.", "Das Design auch."],
post: `Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.\n\n${CALL_DE}`,
},
},
},
CardHours: {
copy: {
en: {
title: "Card: hours",
label: "What do you pay for?",
lines: ["Hours you", "can check."],
post: `I track my time. The monthly invoice shows what the hours were spent on.\n\n${CALL_EN}`,
},
de: {
title: "Karte: Stunden",
label: "Wofür zahlen Sie?",
lines: ["Für Stunden, die Sie", "nachprüfen können."],
post: `Ich erfasse meine Zeit. Die Monatsrechnung zeigt, wofür die Stunden angefallen sind.\n\n${CALL_DE}`,
},
},
},
CardNotice: {
copy: {
en: {
title: "Card: notice",
label: "How long are you committed?",
lines: ["Two weeks", "notice."],
post: `Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.\n\n${CALL_EN}`,
},
de: {
title: "Karte: Kündigungsfrist",
label: "Wie lange sind Sie gebunden?",
lines: ["Zwei Wochen", "Kündigungsfrist."],
post: `Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.\n\n${CALL_DE}`,
},
},
},
CardNotMyField: {
copy: {
en: {
title: "Card: not my field",
label: "What I do not do",
lines: ["Mobile apps.", "Windows software.", "Running servers."],
post: `If your project needs those, I say so in the first call.\n\nWhat I do: web apps, interfaces, websites, and connecting systems.\n\nwww.mintel.me`,
},
de: {
title: "Karte: nicht mein Feld",
label: "Was ich nicht mache",
lines: ["Mobile Apps.", "Windows-Software.", "Serverbetrieb."],
post: `Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.\n\nWas ich mache: Web-Apps, Oberflächen, Websites und das Verbinden von Systemen.\n\nwww.mintel.me`,
},
},
},
};
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { IMAGES, IMAGE_IDS } from "./images";
import { PLAN } from "./plan";
import { COPY, LANGS, VIDEO_IDS } from "./videos";
/** Longer lines get too small to read on a phone. */
const MAX_IMAGE_LINE = 28;
const MAX_IMAGE_LINES = 3;
describe("image posts", () => {
it.each(IMAGE_IDS)("%s has at most three short lines in every language", (id) => {
for (const lang of LANGS) {
const { lines } = IMAGES[id].copy[lang];
expect(lines.length, `${id} ${lang}`).toBeGreaterThan(0);
expect(lines.length, `${id} ${lang}`).toBeLessThanOrEqual(MAX_IMAGE_LINES);
for (const line of lines) expect(line.length, `${id} ${lang}: "${line}"`).toBeLessThanOrEqual(MAX_IMAGE_LINE);
}
});
it.each(IMAGE_IDS)("%s has a post that names the website and never an email address", (id) => {
for (const lang of LANGS) {
const { post } = IMAGES[id].copy[lang];
expect(post).toContain("www.mintel.me");
expect(post).not.toContain("@");
}
});
it.each(IMAGE_IDS)("%s is a poster of an existing film, or a card with a question", (id) => {
const image = IMAGES[id];
if (image.poster) {
expect(VIDEO_IDS).toContain(image.poster.video);
expect(image.poster.frame).toBeGreaterThanOrEqual(0);
} else {
for (const lang of LANGS) expect(image.copy[lang].label, `${id} ${lang}`).toBeTruthy();
}
});
});
describe("video posts", () => {
it.each(VIDEO_IDS)("%s opens its post with the hook of the film", (id) => {
for (const lang of LANGS) {
const { post, slides } = COPY[id][lang];
const lastHookWord = (slides.hook ?? []).join(" ").split(" ").pop()?.replace(/[.?!]/g, "") ?? "";
expect(post.split("\n")[0], `${id} ${lang}`).toContain(lastHookWord);
}
});
});
describe("posting plan", () => {
const planned = PLAN.flatMap((week) => week.posts);
it("uses every film and every image exactly once", () => {
const videos = planned.filter((post) => post.kind === "video").map((post) => post.id);
const images = planned.filter((post) => post.kind === "image").map((post) => post.id);
expect([...videos].sort()).toEqual([...VIDEO_IDS].sort());
expect([...images].sort()).toEqual([...IMAGE_IDS].sort());
});
it("opens every week with its film", () => {
for (const week of PLAN) expect(week.posts[0].kind, week.topic).toBe("video");
});
it("pairs the film of a week with the poster of the same film", () => {
for (const week of PLAN) {
const film = week.posts[0].id;
const posters = week.posts.filter((post) => post.kind === "image" && IMAGES[post.id].poster?.video === film);
expect(posters, week.topic).toHaveLength(1);
}
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* The posting plan: which film or image goes out in which week, in which order.
* The reasoning is in `apps/web/plans/content-strategy.md`. Each week has one topic and three
* posts: the film, the poster of the same idea, and a card that answers a question clients ask.
*
* This file imports only types, so the export script can read it with plain Node.
*/
import type { ImageId } from "./images.ts";
import type { VideoId } from "./videos.ts";
export type PlannedPost =
| { readonly kind: "video"; readonly id: VideoId }
| { readonly kind: "image"; readonly id: ImageId };
export type PlannedWeek = {
readonly topic: string;
/** In posting order: Tuesday, Thursday, Saturday. */
readonly posts: readonly [PlannedPost, PlannedPost, PlannedPost];
};
export const POSTING_DAYS = ["Tuesday", "Thursday", "Saturday"] as const;
export const PLAN: readonly PlannedWeek[] = [
{
topic: "Automation: orders",
posts: [
{ kind: "video", id: "AutomationOrder" },
{ kind: "image", id: "CardNotTechnical" },
{ kind: "image", id: "PosterOrder" },
],
},
{
topic: "Websites",
posts: [
{ kind: "video", id: "WebsiteDone" },
{ kind: "image", id: "CardOneContact" },
{ kind: "image", id: "PosterWebsite" },
],
},
{
topic: "Automation: invoices",
posts: [
{ kind: "video", id: "AutomationDocuments" },
{ kind: "image", id: "CardHours" },
{ kind: "image", id: "PosterDocuments" },
],
},
{
topic: "Reliable apps",
posts: [
{ kind: "video", id: "AppRescue" },
{ kind: "image", id: "CardOwnership" },
{ kind: "image", id: "PosterApp" },
],
},
{
topic: "Automation: enquiries",
posts: [
{ kind: "video", id: "AutomationEnquiry" },
{ kind: "image", id: "CardNotMyField" },
{ kind: "image", id: "PosterEnquiry" },
],
},
{
topic: "A developer for your product",
posts: [
{ kind: "video", id: "ProductDeveloper" },
{ kind: "image", id: "CardNotice" },
{ kind: "image", id: "PosterDeveloper" },
],
},
];
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { COPY, LANGS, VIDEO_IDS, translate } from "./videos";
const MAX_LINES = 2;
/** Longer lines get too small to read on a phone. */
const MAX_LINE_LENGTH = 24;
describe("video copy", () => {
it.each(VIDEO_IDS)("%s has the same slides in every language", (id) => {
const [first, ...others] = LANGS.map((lang) => Object.keys(COPY[id][lang].slides).sort());
for (const slides of others) expect(slides).toEqual(first);
});
it.each(VIDEO_IDS)("%s keeps every headline to two short lines", (id) => {
for (const lang of LANGS) {
for (const [slide, lines] of Object.entries(COPY[id][lang].slides)) {
expect(lines.length, `${id} ${lang} ${slide}`).toBeGreaterThan(0);
expect(lines.length, `${id} ${lang} ${slide}`).toBeLessThanOrEqual(MAX_LINES);
for (const line of lines) {
expect(line.length, `${id} ${lang} ${slide}: "${line}"`).toBeLessThanOrEqual(MAX_LINE_LENGTH);
}
}
}
});
it.each(VIDEO_IDS)("%s ends with a call to action slide", (id) => {
for (const lang of LANGS) expect(COPY[id][lang].slides).toHaveProperty("cta");
});
it.each(VIDEO_IDS)("%s has a post that names the website and never an email address", (id) => {
for (const lang of LANGS) {
const { post } = COPY[id][lang];
expect(post).toContain("www.mintel.me");
expect(post).not.toContain("@");
}
});
});
describe("translate", () => {
it("leaves English labels as they are", () => {
expect(translate("en", "Invoice")).toBe("Invoice");
});
it("returns the German label when there is one", () => {
expect(translate("de", "Invoice")).toBe("Rechnung");
});
it("falls back to the given text when there is no German label", () => {
expect(translate("de", "Shop")).toBe("Shop");
});
});
+388
View File
@@ -0,0 +1,388 @@
/**
* Everything that is said in and about the videos: headlines per slide, labels inside the drawings,
* and the text to post with each video. English and German.
*
* This file imports nothing, so the export script can read it with plain Node.
* German addresses the viewer formally ("Sie"), as is usual towards business owners.
*/
export const LANGS = ["en", "de"] as const;
export type Lang = (typeof LANGS)[number];
export const VIDEO_IDS = [
"AutomationOrder",
"AutomationDocuments",
"AutomationEnquiry",
"WebsiteDone",
"AppRescue",
"ProductDeveloper",
] as const;
export type VideoId = (typeof VIDEO_IDS)[number];
export type VideoCopy = {
/** What the video is about, for the overview page. */
readonly title: string;
/** Headline per slide id: at most two lines of a few words. */
readonly slides: Readonly<Record<string, readonly string[]>>;
/** Text to post with the video on LinkedIn. */
readonly post: string;
};
/** German for every label that appears inside a drawing or on the frame. Missing entries stay English. */
export const LABELS_DE: Readonly<Record<string, string>> = {
"Freelance developer": "Freelance-Entwickler",
"Get in touch": "Kontakt aufnehmen",
Invoice: "Rechnung",
Customers: "Kunden",
Email: "E-Mail",
"One person": "Eine Person",
Inbox: "Posteingang",
Read: "Lesen",
Check: "Prüfen",
Books: "Buchhaltung",
"Invoice.pdf": "Rechnung.pdf",
Accounting: "Buchhaltung",
Supplier: "Lieferant",
Amount: "Betrag",
Date: "Datum",
"A person": "Ein Mensch",
Contacts: "Kontakte",
Reminder: "Erinnerung",
"Day 1": "Tag 1",
"Day 2": "Tag 2",
"Day 3": "Tag 3",
"Day 4": "Tag 4",
"Website builder": "Baukasten",
"Your website": "Ihre Website",
"Your email": "Ihre E-Mail",
"www.your-name.com": "www.ihr-name.de",
"Your app": "Ihre App",
Mon: "Mo",
Tue: "Di",
Wed: "Mi",
Thu: "Do",
Fri: "Fr",
"To do": "Offen",
Done: "Erledigt",
Product: "Produkt",
Developer: "Entwicklung",
};
export const translate = (lang: Lang, text: string): string =>
lang === "de" ? (LABELS_DE[text] ?? text) : text;
export const COPY: Readonly<Record<VideoId, Readonly<Record<Lang, VideoCopy>>>> = {
AutomationOrder: {
en: {
title: "Automation: from order to invoice",
slides: {
hook: ["Looks like", "ping-pong."],
reveal: ["It's your", "office."],
problem: ["Every order.", "By hand."],
turn: ["Or your tools", "talk to each other."],
calm: ["And your team", "has time."],
cta: ["Done with", "ping-pong?"],
},
post: `Looks like ping-pong. It's your office.
An order comes in. Someone opens it in the shop, writes the invoice, updates the customer record, sends the confirmation. Then the next one. And the next.
Works beautifully, as long as hardly anyone orders.
The four tools can sort this out among themselves: the order triggers invoice, customer record and email on its own. Your team sees the finished result and looks after the cases that really need a person.
I connect the tools you already use. A first call takes about 20 minutes.
www.mintel.me
#automation #processautomation #smallbusiness`,
},
de: {
title: "Automatisierung: von der Bestellung zur Rechnung",
slides: {
hook: ["Sieht aus wie", "Pingpong."],
reveal: ["Ist aber", "Ihr Büro."],
problem: ["Jede Bestellung.", "Von Hand."],
turn: ["Oder Ihre Tools", "reden miteinander."],
calm: ["Und Ihr Team", "hat Zeit."],
cta: ["Schluss mit", "Pingpong?"],
},
post: `Sieht aus wie Pingpong. Ist aber Ihr Büro.
Eine Bestellung kommt rein. Jemand öffnet sie im Shop, schreibt die Rechnung, pflegt den Kunden nach, verschickt die Bestätigung. Dann die nächste. Und die nächste.
Klappt wunderbar, solange kaum jemand bestellt.
Dabei können die vier Tools das längst unter sich ausmachen: Die Bestellung löst Rechnung, Kundendatensatz und E-Mail selbst aus. Ihr Team sieht das fertige Ergebnis und kümmert sich um die Fälle, die wirklich einen Menschen brauchen.
Ich verbinde die Tools, die Sie schon haben. Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#automatisierung #prozessautomatisierung #mittelstand`,
},
},
AutomationDocuments: {
en: {
title: "Automation: incoming invoices",
slides: {
hook: ["Looks like", "a skyscraper."],
reveal: ["It's your", "inbox."],
problem: ["Every invoice.", "Typed in."],
turn: ["Or it simply", "gets read."],
calm: ["Unclear ones go", "to a person."],
cta: ["Done typing", "things in?"],
},
post: `Looks like a skyscraper. It's your inbox.
Invoices arrive as PDF. Someone opens each one, reads supplier, amount and date, and types them into the accounting system. Floor by floor.
Nobody has to do that by hand. The document can be read automatically, a fixed rule checks whether the values make sense, and clear cases are booked directly. Anything unclear goes to a person, with the document attached.
So people decide the exceptions instead of typing the routine.
A first call takes about 20 minutes.
www.mintel.me
#automation #accounting #smallbusiness`,
},
de: {
title: "Automatisierung: Eingangsrechnungen",
slides: {
hook: ["Sieht aus wie", "ein Hochhaus."],
reveal: ["Ist aber Ihr", "Posteingang."],
problem: ["Jede Rechnung.", "Abgetippt."],
turn: ["Oder sie wird", "einfach gelesen."],
calm: ["Unklares geht", "an einen Menschen."],
cta: ["Schluss mit", "Abtippen?"],
},
post: `Sieht aus wie ein Hochhaus. Ist aber Ihr Posteingang.
Rechnungen kommen als PDF. Jemand öffnet jede einzelne, liest Lieferant, Betrag und Datum ab und tippt alles in die Buchhaltung. Stockwerk für Stockwerk.
Das muss niemand von Hand machen. Das Dokument lässt sich automatisch auslesen, eine feste Regel prüft, ob die Werte plausibel sind, und klare Fälle werden direkt gebucht. Alles Unklare geht an einen Menschen, mit dem Dokument im Anhang.
So entscheiden Menschen die Ausnahmen, statt Routine abzutippen.
Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#automatisierung #buchhaltung #mittelstand`,
},
},
AutomationEnquiry: {
en: {
title: "Automation: from enquiry to follow-up",
slides: {
hook: ["Looks like", "a star."],
reveal: ["These are your", "enquiries."],
problem: ["They burn out.", "Unanswered."],
turn: ["Or each one reaches", "the right person."],
calm: ["And follow-ups", "happen by themselves."],
cta: ["Stop losing", "enquiries?"],
},
post: `Looks like a star. It's an enquiry, burning out.
Someone fills in your contact form. The enquiry is copied into the customer list by hand, forwarded by email, and everyone hopes the follow-up is not forgotten.
Hope is not a process.
These steps can be connected: the contact is created, the right person is told, and if nobody has replied after three days, a reminder appears.
A first call takes about 20 minutes.
www.mintel.me
#automation #sales #smallbusiness`,
},
de: {
title: "Automatisierung: von der Anfrage zum Nachfassen",
slides: {
hook: ["Sieht aus wie", "ein Stern."],
reveal: ["Sind aber Ihre", "Anfragen."],
problem: ["Sie verglühen.", "Unbeantwortet."],
turn: ["Oder jede landet", "beim Richtigen."],
calm: ["Und Nachfassen", "passiert von selbst."],
cta: ["Keine Anfrage", "mehr verlieren?"],
},
post: `Sieht aus wie ein Stern. Ist aber eine Anfrage, die gerade verglüht.
Jemand füllt Ihr Kontaktformular aus. Die Anfrage wird von Hand in die Kundenliste kopiert, per E-Mail weitergeleitet, und alle hoffen, dass das Nachfassen nicht vergessen wird.
Hoffen ist kein Prozess.
Diese Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn nach drei Tagen niemand geantwortet hat, erscheint eine Erinnerung.
Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#automatisierung #vertrieb #mittelstand`,
},
},
WebsiteDone: {
en: {
title: "A website, built and looked after",
slides: {
hook: ["Looks like", "a beginning."],
reveal: ["It's been your", "website for months."],
problem: ["Empty.", "Still."],
turn: ["You talk.", "I build."],
calm: ["Fits every", "screen."],
cta: ["Website", "overdue?"],
},
post: `Looks like a beginning. It's been your website for months.
You run a business. Learning a website builder on top of that was never going to happen.
This is how I do it instead: you tell me what your business does and what the website is for. I design it, build it and put it online. It works on phone, tablet and desktop, and you approve it before it goes live.
And afterwards? A new photo or a changed price: you send me an email.
A first call takes about 20 minutes.
www.mintel.me
#website #webdesign #smallbusiness`,
},
de: {
title: "Eine Website, gebaut und betreut",
slides: {
hook: ["Sieht aus wie", "ein Anfang."],
reveal: ["Ist seit Monaten", "Ihre Website."],
problem: ["Leer.", "Immer noch."],
turn: ["Sie erzählen.", "Ich baue."],
calm: ["Passt auf", "jeden Bildschirm."],
cta: ["Website", "fällig?"],
},
post: `Sieht aus wie ein Anfang. Ist seit Monaten Ihre Website.
Sie führen ein Unternehmen. Dass Sie nebenbei noch einen Website-Baukasten lernen, war nie realistisch.
So mache ich es stattdessen: Sie erzählen mir, was Ihr Unternehmen macht und wofür die Website da ist. Ich gestalte sie, baue sie und bringe sie online. Sie funktioniert auf Handy, Tablet und Desktop, und Sie geben sie frei, bevor sie live geht.
Und danach? Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.
Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#website #webdesign #mittelstand`,
},
},
AppRescue: {
en: {
title: "Making an app reliable",
slides: {
hook: ["Looks like", "Jenga."],
reveal: ["It's your", "app."],
problem: ["Every change", "wobbles."],
turn: ["One week.", "I look inside."],
calm: ["After that", "it stands."],
cta: ["App", "wobbling?"],
},
post: `Looks like Jenga. It's your app.
It works. But bugs come back, and every small change feels like pulling out a block.
That usually happens when an app was built quickly and then kept growing.
I take a week to go through it: I look at what is solid and what is fragile, fix the most urgent problems as far as a week allows, and write down what I would do next. In plain language, so you can follow it without being a developer.
After that you decide how to continue: with me, with your own team, or not at all.
A first call takes about 20 minutes.
www.mintel.me
#webdevelopment #softwarequality #startups`,
},
de: {
title: "Eine App zuverlässig machen",
slides: {
hook: ["Sieht aus wie", "Jenga."],
reveal: ["Ist aber", "Ihre App."],
problem: ["Jede Änderung", "wackelt."],
turn: ["Eine Woche.", "Ich sehe hinein."],
calm: ["Danach", "steht sie."],
cta: ["App", "wackelt?"],
},
post: `Sieht aus wie Jenga. Ist aber Ihre App.
Sie läuft. Aber Fehler kommen wieder, und jede kleine Änderung fühlt sich an, als zöge man einen Stein heraus.
Das passiert meist, wenn eine App schnell gebaut wurde und dann immer weiter gewachsen ist.
Ich nehme mir eine Woche dafür: Ich sehe mir an, was solide ist und was wackelt, behebe die dringendsten Probleme, soweit eine Woche reicht, und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache, die Sie auch ohne Entwicklerwissen verstehen.
Danach entscheiden Sie, wie es weitergeht: mit mir, mit Ihrem eigenen Team oder gar nicht.
Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#webentwicklung #softwarequalität #startups`,
},
},
ProductDeveloper: {
en: {
title: "A developer for your product",
slides: {
hook: ["Looks like", "a loading bar."],
reveal: ["It's your", "backlog."],
problem: ["Growing faster", "than your team."],
turn: ["One more", "developer."],
calm: ["Fixed days.", "No minimum term."],
cta: ["Backlog", "too long?"],
},
post: `Looks like a loading bar. It's your backlog.
You have a product, and the list grows faster than your team gets through it.
I join on agreed days of the week and work on what is next: new features, the interface, or the parts nobody gets around to.
Finished parts go to you for review as they are done. I track my hours, and you can see what they were spent on. No package to buy, no minimum term: either side can end it with two weeks notice to the end of a month.
15 years in web development, in agencies and product teams.
A first call takes about 20 minutes.
www.mintel.me
#webdevelopment #freelance #react`,
},
de: {
title: "Ein Entwickler für Ihr Produkt",
slides: {
hook: ["Sieht aus wie", "ein Ladebalken."],
reveal: ["Ist aber", "Ihr Backlog."],
problem: ["Wächst schneller", "als Ihr Team."],
turn: ["Ein Entwickler", "mehr."],
calm: ["Feste Tage.", "Keine Mindestlaufzeit."],
cta: ["Backlog", "zu lang?"],
},
post: `Sieht aus wie ein Ladebalken. Ist aber Ihr Backlog.
Sie haben ein Produkt, und die Liste wächst schneller, als Ihr Team sie abarbeitet.
Ich komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes ansteht: neue Funktionen, die Oberfläche oder die Teile, zu denen niemand kommt.
Fertige Teile gehen zur Durchsicht an Sie, sobald sie fertig sind. Ich erfasse meine Stunden, und Sie sehen, wofür sie verwendet wurden. Kein Paket, keine Mindestlaufzeit: Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.
15 Jahre Webentwicklung, in Agenturen und Produktteams.
Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#webentwicklung #freelancer #react`,
},
},
};
+107
View File
@@ -0,0 +1,107 @@
import type { FC } from "react";
import { useCurrentFrame } from "remotion";
import { COLOR, DRAW } from "../brand/tokens";
import { inOut, mix, out, progress } from "../illustrations/motion";
import { FilmStage } from "./FilmStage";
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
/**
* Close up, a block wobbles on another: Jenga. The camera pulls back: it is a whole leaning tower,
* the app, swaying with every change. It stops, a light rises through it, the blocks line up one
* by one, and two rails keep it straight.
*/
const BLOCK = { width: 230, height: 44, pitch: 52, base: 290 } as const;
/** How far each block sits off centre while the tower is crooked. */
const OFFSETS = [0, 14, -10, 22, -18, 26, -8, 30, -22] as const;
const TOP = OFFSETS.length - 1;
const SWAY_FRAMES = 46;
const SWAY_DEGREES = 2.2;
const ALIGN_EVERY = 8;
const RAIL_GAP = 42;
const blockY = (index: number): number => BLOCK.base - BLOCK.height / 2 - index * BLOCK.pitch;
const alignStart = (index: number): number => T.resolve + index * ALIGN_EVERY;
const LAST_ALIGN = alignStart(TOP) + 22;
/** The picture alone, without stage and statements: also used frozen, as an image post. */
export const AppDrawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 2.4, 1, { x: 0, y: blockY(TOP) + 30 });
const { zoom, hair, others } = camera;
const folded = frame >= T.fold;
/** How much the tower moves: it stops at the freeze and starts again for the loop. */
const restless = folded ? progress(frame, T.fold + 16, SHOT_FRAMES) : 1 - progress(frame, T.freeze, T.turn);
const wave = Math.sin((frame / SWAY_FRAMES) * Math.PI * 2);
const crooked = (index: number): number =>
folded ? inOut(progress(frame, T.fold, T.fold + 40)) : 1 - inOut(progress(frame, alignStart(index), alignStart(index) + 22));
const scan = progress(frame, T.turn, LAST_ALIGN);
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_ALIGN - 10, LAST_ALIGN));
const rails = out(progress(frame, T.calm, T.calm + 40)) * untilFold(frame);
const top = blockY(TOP) - BLOCK.height / 2 - 20;
return (
<g transform={camera.transform}>
<path d={`M-280 ${BLOCK.base}h560`} stroke={DRAW.fg} strokeOpacity={0.5 * others} strokeWidth={hair} strokeLinecap="round" />
{[-1, 1].map((side) => (
<path
key={side}
d={`M${side * (BLOCK.width / 2 + RAIL_GAP)} ${BLOCK.base}V${mix(BLOCK.base, top, rails)}`}
stroke={COLOR.done}
strokeWidth={hair * 1.6}
strokeLinecap="round"
opacity={rails > 0 ? 1 : 0}
style={glow(COLOR.done, 10, zoom)}
/>
))}
<g transform={`rotate(${wave * SWAY_DEGREES * restless} 0 ${BLOCK.base})`}>
{OFFSETS.map((offset, index) => {
const off = crooked(index);
// The top block wobbles on its own: that is the opening picture.
const wobble = index === TOP ? Math.sin((frame / (SWAY_FRAMES / 2)) * Math.PI * 2) * 3.5 * restless : 0;
const justAligned = folded ? 0 : Math.sin(Math.PI * progress(frame, alignStart(index), alignStart(index) + 30));
const y = blockY(index);
return (
<g
key={index}
opacity={index >= TOP - 1 ? 1 : others}
transform={`translate(${offset * off} 0) rotate(${wobble} ${-BLOCK.width / 2} ${y + BLOCK.height / 2})`}
>
<rect x={-BLOCK.width / 2} y={y - BLOCK.height / 2} width={BLOCK.width} height={BLOCK.height} rx={13} fill={DRAW.bg} />
<rect
x={-BLOCK.width / 2}
y={y - BLOCK.height / 2}
width={BLOCK.width}
height={BLOCK.height}
rx={13}
fill={`rgba(248, 250, 252, ${0.1 + justAligned * 0.25})`}
stroke={justAligned > 0.1 ? DRAW.mark : DRAW.fg}
strokeOpacity={SURFACE.strokeOpacity + justAligned * 0.25}
strokeWidth={hair * (1 + justAligned * 0.6)}
/>
</g>
);
})}
</g>
{scanShown > 0 && (
<path
d={`M-190 ${mix(BLOCK.base, top, scan)}h380`}
stroke={DRAW.fg}
strokeWidth={hair * 1.4}
strokeLinecap="round"
opacity={scanShown}
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
/>
)}
</g>
);
};
/** Making an app reliable: from a leaning tower that sways with every change to one that stands. */
export const AppFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<AppDrawing />
</FilmStage>
);
+94
View File
@@ -0,0 +1,94 @@
import type { FC } from "react";
import { useCurrentFrame } from "remotion";
import { COLOR, DRAW } from "../brand/tokens";
import { mix, out, progress } from "../illustrations/motion";
import { FilmStage } from "./FilmStage";
import { CAPTIONS, SURFACE, T, cameraAt, glow } from "./shot";
/**
* Close up, a bar fills slowly: a loading bar. The camera pulls back: it is the first of a long
* list, the backlog, and new entries arrive faster than the team gets through them. Then one more
* developer joins, and the list is worked off, entry after entry.
*/
const ROW = { x: -215, width: 470, height: 26, pitch: 44, top: -270 } as const;
const MAX_ROWS = 13;
const OPENING_ROWS = 4;
const ROW_EVERY = 26;
const TEAM_SIZE = 3;
const WORKER_X = ROW.x - 42;
const DONE_EVERY = 13;
const OPENING_FILL = 0.15;
const rowY = (index: number): number => ROW.top + index * ROW.pitch;
const doneStart = (index: number): number => T.resolve + index * DONE_EVERY;
/** The picture alone, without stage and statements: also used frozen, as an image post. */
export const DeveloperDrawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 1.7, 1, { x: 0, y: rowY(0) });
const { zoom, hair, others } = camera;
const folded = frame >= T.fold;
const rows = folded ? 1 : Math.min(MAX_ROWS, OPENING_ROWS + Math.max(0, frame - T.reveal) / ROW_EVERY);
const dot = 12 / Math.sqrt(zoom);
const joined = out(progress(frame, T.turn, T.turn + 28));
return (
<g transform={camera.transform}>
{Array.from({ length: MAX_ROWS }, (_, index) => {
if (index >= rows) return null;
const y = rowY(index);
const arrived = Math.min(1, rows - index);
const worked = index === 0 ? mix(OPENING_FILL, 0.6, progress(frame, 0, T.turn)) : index < TEAM_SIZE ? mix(0, 0.4, progress(frame, T.reveal, T.turn)) : 0;
const finishing = folded ? 0 : out(progress(frame, doneStart(index), doneStart(index) + 16));
const leaving = folded ? 0 : progress(frame, doneStart(index) + 18, doneStart(index) + 32);
const fill = folded ? OPENING_FILL : mix(worked, 1, finishing);
const shown = folded ? progress(frame, T.fold + 16, T.fold + 44) : arrived * (1 - leaving) * (index === 0 ? 1 : others);
if (shown === 0) return null;
return (
<g key={index} opacity={shown} transform={`translate(${-36 * leaving} ${(1 - out(arrived)) * 26})`}>
<rect
x={ROW.x}
y={y - ROW.height / 2}
width={ROW.width}
height={ROW.height}
rx={ROW.height / 2}
fill={SURFACE.fill}
stroke={DRAW.fg}
strokeOpacity={0.5}
strokeWidth={hair}
/>
{fill > 0 && (
<rect
x={ROW.x}
y={y - ROW.height / 2}
width={Math.max(ROW.height, ROW.width * fill)}
height={ROW.height}
rx={ROW.height / 2}
fill={finishing === 1 ? COLOR.done : DRAW.fg}
/>
)}
</g>
);
})}
{Array.from({ length: TEAM_SIZE }, (_, index) => (
<circle key={index} cx={WORKER_X} cy={rowY(index)} r={dot} fill={DRAW.fg} opacity={index === 0 ? 1 : others} />
))}
<circle
cx={mix(WORKER_X - 150, WORKER_X, joined)}
cy={rowY(TEAM_SIZE)}
r={dot}
fill={DRAW.mark}
opacity={joined * others}
style={glow(DRAW.mark, 14, zoom)}
/>
</g>
);
};
/** A developer for a product: from a backlog that grows faster than the team to a list worked off. */
export const DeveloperFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<DeveloperDrawing />
</FilmStage>
);
+129
View File
@@ -0,0 +1,129 @@
import type { FC } from "react";
import { useCurrentFrame } from "remotion";
import { DRAW } from "../brand/tokens";
import { inOut, mix, out, progress } from "../illustrations/motion";
import { FilmStage } from "./FilmStage";
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
/**
* Close up, bars drop onto a stack: a skyscraper going up. The camera pulls back: it is the pile of
* invoices waiting to be typed in, and it keeps growing. Then a light runs down the pile and the
* sheets leave by themselves: most into the books, the few unclear ones to a person.
*/
const BAR = { x: -170, width: 210, height: 16, pitch: 25, base: 290 } as const;
const MAX_BARS = 24;
const BOOKS = { x: 250, y: -70, size: 130 } as const;
const PERSON = { x: 250, y: 170, r: 44 } as const;
const OPENING_BARS = 3;
const CLOSE_EVERY = 25;
/** The film starts with a bar in mid-fall: the first frame is the preview image. */
const CLOSE_PHASE = 12;
const WIDE_EVERY = 9;
const LEAVE_EVERY = 5;
const LEAVE_FRAMES = 24;
/** Every sixth sheet is unclear and goes to a person. */
const isUnclear = (index: number): boolean => index % 6 === 2;
const barY = (index: number): number => BAR.base - BAR.height / 2 - index * BAR.pitch;
/** How many bars have arrived, as a number with a fraction: the fraction is the one still falling. */
const barsAt = (frame: number): number => {
if (frame >= T.fold) return OPENING_BARS + (frame - SHOT_FRAMES + CLOSE_PHASE) / CLOSE_EVERY;
const close = OPENING_BARS + (Math.min(frame, T.reveal) + CLOSE_PHASE) / CLOSE_EVERY;
return Math.min(MAX_BARS, close + Math.max(0, frame - T.reveal) / WIDE_EVERY);
};
const leaveStart = (index: number): number => T.resolve + (MAX_BARS - 1 - index) * LEAVE_EVERY;
const LAST_LEAVE = T.resolve + MAX_BARS * LEAVE_EVERY;
const UNCLEAR = Array.from({ length: MAX_BARS }, (_, index) => index).filter(isUnclear);
/** Where an unclear sheet waits next to the person. */
const waitingSpot = (index: number): { x: number; y: number } => {
const angle = UNCLEAR.indexOf(index) * 0.62 + 2.2;
return { x: PERSON.x + Math.cos(angle) * 78, y: PERSON.y + Math.sin(angle) * 78 };
};
/** The picture alone, without stage and statements: also used frozen, as an image post. */
export const DocumentsDrawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 2.6, 0.95, { x: BAR.x, y: barY(OPENING_BARS) - 20 });
const { zoom, hair, others } = camera;
const bars = barsAt(frame);
const leaving = frame < T.fold;
const scan = progress(frame, T.turn, LAST_LEAVE);
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_LEAVE - 10, LAST_LEAVE));
const dot = 11 / Math.sqrt(zoom);
return (
<g transform={camera.transform}>
<path d={`M${BAR.x - 170} ${BAR.base}h340`} stroke={DRAW.fg} strokeOpacity={0.5} strokeWidth={hair} strokeLinecap="round" />
<g opacity={others}>
<rect
x={BOOKS.x - BOOKS.size / 2}
y={BOOKS.y - BOOKS.size / 2}
width={BOOKS.size}
height={BOOKS.size}
rx={BOOKS.size * 0.24}
fill={SURFACE.fill}
stroke={DRAW.fg}
strokeOpacity={SURFACE.strokeOpacity}
strokeWidth={hair}
/>
<circle cx={PERSON.x} cy={PERSON.y} r={PERSON.r} fill={SURFACE.fill} stroke={DRAW.fg} strokeOpacity={SURFACE.strokeOpacity} strokeWidth={hair} />
</g>
{Array.from({ length: MAX_BARS }, (_, index) => {
if (index >= bars) return null;
const arrived = Math.min(1, bars - index);
const fadeIn = leaving ? 1 : progress(frame, T.fold + 8, T.fold + 30);
const gone = leaving ? inOut(progress(frame, leaveStart(index), leaveStart(index) + LEAVE_FRAMES)) : 0;
const unclear = isUnclear(index);
const target = unclear ? waitingSpot(index) : BOOKS;
const width = mix(BAR.width, dot * 2, gone);
const height = mix(BAR.height, dot * 2, gone);
const x = mix(BAR.x, target.x, gone);
const y = mix(mix(barY(index) - 150 / zoom, barY(index), out(arrived)), target.y, gone) - Math.sin(Math.PI * gone) * 70;
const opacity = arrived * fadeIn * (unclear ? 1 : 1 - progress(gone, 0.82, 1));
if (opacity === 0) return null;
return (
<rect
key={index}
x={x - width / 2}
y={y - height / 2}
width={width}
height={height}
rx={height / 2}
fill={unclear && gone > 0 ? DRAW.mark : DRAW.fg}
opacity={opacity * (gone > 0 ? 1 : 0.9)}
style={unclear && gone > 0.5 ? glow(DRAW.mark, 10, zoom) : undefined}
/>
);
})}
{!leaving &&
UNCLEAR.map((index) => {
const spot = waitingSpot(index);
return <circle key={index} cx={spot.x} cy={spot.y} r={dot} fill={DRAW.mark} opacity={untilFold(frame) * others} />;
})}
{scanShown > 0 && (
<path
d={`M${BAR.x - 150} ${mix(barY(MAX_BARS - 1), BAR.base, scan)}h300`}
stroke={DRAW.fg}
strokeWidth={hair * 1.4}
strokeLinecap="round"
opacity={scanShown}
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
/>
)}
</g>
);
};
/** Incoming invoices: from a growing pile to documents that are read and booked by themselves. */
export const DocumentsFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<DocumentsDrawing />
</FilmStage>
);
+125
View File
@@ -0,0 +1,125 @@
import type { FC } from "react";
import { useCurrentFrame } from "remotion";
import { DRAW } from "../brand/tokens";
import { mix, out, progress } from "../illustrations/motion";
import type { Point } from "../lib/hop";
import { FilmStage } from "./FilmStage";
import { CAPTIONS, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
/**
* Close up, one point of light: a star. The camera pulls back: a whole sky of them, each one an
* enquiry, and one after the other they burn out unanswered. Then a line reaches every one of them
* from the team in the middle, they light up again, and a quiet ring keeps reminding.
*/
const TEAM = { x: 0, y: 10, size: 120 } as const;
/** The first one is the star of the opening picture. */
const STARS: readonly Point[] = [
{ x: -190, y: -90 },
{ x: -380, y: -210 },
{ x: -60, y: -260 },
{ x: 210, y: -220 },
{ x: 400, y: -130 },
{ x: 320, y: 50 },
{ x: 420, y: 230 },
{ x: 170, y: 250 },
{ x: -90, y: 230 },
{ x: -300, y: 250 },
{ x: -430, y: 50 },
{ x: -250, y: 110 },
{ x: 150, y: -90 },
];
const HERO: Point = STARS[0] ?? { x: 0, y: 0 };
const TWINKLE_FRAMES = 23;
const RING_FRAMES = 60;
const lineStart = (index: number): number => T.resolve + index * 6;
/** How brightly an enquiry still shines: it fades while nobody answers and returns once it is reached. */
const brightness = (index: number, frame: number): number => {
if (frame >= T.fold) return 1;
const fadeFrom = index === 0 ? 0 : 60 + index * 17;
const faded = progress(frame, fadeFrom, fadeFrom + (index === 0 ? 170 : 90));
const reached = out(progress(frame, lineStart(index) + 18, lineStart(index) + 40));
return mix(1 - 0.88 * faded, 1, reached);
};
/** The picture alone, without stage and statements: also used frozen, as an image post. */
export const EnquiryDrawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 3, 0.95, HERO);
const { zoom, hair, others } = camera;
const radius = 11 / Math.sqrt(zoom);
const sinceCalm = frame - T.calm;
return (
<g transform={camera.transform}>
{STARS.map((star, index) => {
const drawn = out(progress(frame, lineStart(index), lineStart(index) + 30)) * untilFold(frame);
return drawn === 0 ? null : (
<path
key={index}
d={`M${TEAM.x} ${TEAM.y}L${mix(TEAM.x, star.x, drawn)} ${mix(TEAM.y, star.y, drawn)}`}
stroke={DRAW.fg}
strokeOpacity={0.6}
strokeWidth={hair}
/>
);
})}
{sinceCalm > 0 &&
[0, RING_FRAMES / 2].map((delay) => {
const t = ((sinceCalm + delay) % RING_FRAMES) / RING_FRAMES;
return (
<circle
key={delay}
cx={TEAM.x}
cy={TEAM.y}
r={mix(TEAM.size * 0.6, TEAM.size * 1.5, t)}
fill="none"
stroke={`rgba(${DRAW.light}, 1)`}
strokeWidth={hair}
opacity={(1 - t) * 0.7 * untilFold(frame) * progress(sinceCalm, 0, 20)}
/>
);
})}
<g opacity={others}>
<rect x={TEAM.x - TEAM.size / 2} y={TEAM.y - TEAM.size / 2} width={TEAM.size} height={TEAM.size} rx={TEAM.size * 0.24} fill={DRAW.bg} />
<rect
x={TEAM.x - TEAM.size / 2}
y={TEAM.y - TEAM.size / 2}
width={TEAM.size}
height={TEAM.size}
rx={TEAM.size * 0.24}
fill={SURFACE.fill}
stroke={DRAW.fg}
strokeOpacity={SURFACE.strokeOpacity}
strokeWidth={hair}
/>
</g>
{STARS.map((star, index) => {
const twinkle = 0.92 + 0.08 * Math.sin((frame / TWINKLE_FRAMES) * Math.PI * 2 + index);
const shine = brightness(index, frame) * twinkle;
return (
<circle
key={index}
cx={star.x}
cy={star.y}
r={radius}
fill={DRAW.mark}
opacity={shine * (index === 0 ? 1 : others)}
style={glow(DRAW.mark, 16 * shine, zoom)}
/>
);
})}
</g>
);
};
/** Enquiries: from stars that burn out unanswered to every one reaching the right person. */
export const EnquiryFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<EnquiryDrawing />
</FilmStage>
);
+200
View File
@@ -0,0 +1,200 @@
import type { FC, ReactNode } from "react";
import { Easing, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { BrandButton } from "../brand/BrandButton";
import { FONT } from "../brand/fonts";
import { DRAW } from "../brand/tokens";
import { useCopy } from "../content/CopyContext";
import { headlineSize } from "../slides/SlideDeck";
/** Films are drawn in this coordinate system, with (0, 0) in the centre; the stage is scaled to fit. */
export const STAGE = { width: 1000, height: 700 } as const;
/** One statement: which text (key in the copy) and while which frames. */
export type Caption = {
readonly id: string;
readonly from: number;
readonly to: number;
/** Show the website's button below this statement. */
readonly button?: boolean;
};
export const SIDE = 60;
const FOOTER_HEIGHT = 130;
const MARK_HEIGHT = 76;
const GAP = 44;
/** Room for two lines and the button below the stage, in px. */
const STATEMENT_HEIGHT = 330;
const MAX_WIDE_FONT = 88;
const IN_FRAMES = 18;
const OUT_FRAMES = 10;
const RISE_PX = 18;
const EDGE_FADE =
"linear-gradient(to bottom, transparent, #000 9%, #000 91%, transparent), linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)";
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
/** Fast start, long soft landing: the easing of a calm presentation. */
const settle = Easing.bezier(0.16, 1, 0.3, 1);
/** Two or three lines in the headline type: the first ones light, the last one quieter. */
export const Lines: FC<{ lines: readonly string[]; fontSize: number }> = ({ lines, fontSize }) => (
<>
{lines.map((line, index) => (
<div
key={line}
style={{
fontFamily: FONT.sans,
fontWeight: 900,
fontSize,
lineHeight: 1.06,
letterSpacing: "-0.04em",
wordSpacing: "0.08em",
color: index > 0 && index === lines.length - 1 ? DRAW.label : DRAW.fg,
}}
>
{line}
</div>
))}
</>
);
const Statement: FC<{ caption: Caption; first: boolean; fontSize: number; total: number }> = ({
caption,
first,
fontSize,
total,
}) => {
const frame = useCurrentFrame();
const lines = useCopy().slides[caption.id] ?? [];
// The first statement stands on frame 0 (the preview image) and returns at the very end.
const back = first ? interpolate(frame, [total - OUT_FRAMES, total - 1], [0, 1], clampOpts) : 0;
const arrive = first ? 1 : settle(interpolate(frame, [caption.from, caption.from + IN_FRAMES], [0, 1], clampOpts));
const leave = interpolate(frame, [caption.to - OUT_FRAMES, caption.to - 1], [1, 0], clampOpts);
const opacity = Math.max(frame < caption.to ? Math.min(arrive, leave) : 0, back);
if (opacity === 0) return null;
return (
<div
style={{
position: "absolute",
inset: 0,
textAlign: "center",
opacity,
transform: `translateY(${(1 - arrive) * RISE_PX}px)`,
}}
>
<Lines lines={lines} fontSize={fontSize} />
{caption.button && (
<div style={{ marginTop: fontSize * 0.55 }}>
<BrandButton label="Get in touch" size={Math.round(fontSize * 0.3)} tone="light" />
</div>
)}
</div>
);
};
/**
* The ground every film and image stands on: a quiet dark surface with one soft light, the
* signature very large and faint behind everything, and a small line with signature and address.
*/
export const Backdrop: FC<{ children: ReactNode }> = ({ children }) => {
const frame = useCurrentFrame();
const { width, height, durationInFrames } = useVideoConfig();
const wide = width > height;
const turn = (frame / durationInFrames) * Math.PI * 2;
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", backgroundColor: DRAW.bg }}>
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(60% 50% at ${50 + Math.sin(turn) * 10}% ${38 + Math.cos(turn) * 6}%, rgba(${DRAW.glow}, 0.3), transparent 75%)`,
}}
/>
<Img
src={staticFile("logo-mark.svg")}
style={{
position: "absolute",
right: -width * 0.12,
bottom: -width * 0.06,
width: width * (wide ? 0.5 : 0.95),
opacity: 0.04,
}}
/>
{children}
<div
style={{
position: "absolute",
left: SIDE,
right: SIDE,
bottom: 44,
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
}}
>
<Img src={staticFile("logo-mark.svg")} style={{ height: MARK_HEIGHT, opacity: 0.92 }} />
<span
style={{
fontFamily: FONT.mono,
fontWeight: 500,
fontSize: 22,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: DRAW.label,
}}
>
www.mintel.me
</span>
</div>
</div>
);
};
/**
* What every film shares: the backdrop, the drawing in the middle, and one statement at a time
* centred below it. Deliberately little (owner feedback: minimal, calm, one idea at a time).
* The film itself is drawn by `children` as SVG content in STAGE coordinates.
*/
export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }> = ({ captions, children }) => {
const { width, height, durationInFrames } = useVideoConfig();
const copy = useCopy();
const wide = width > height;
const scale = Math.min(
(width - SIDE * 2) / STAGE.width,
(height - FOOTER_HEIGHT - GAP - STATEMENT_HEIGHT - 50) / STAGE.height,
);
const stageHeight = STAGE.height * scale;
const stageTop = (height - FOOTER_HEIGHT - stageHeight - GAP - STATEMENT_HEIGHT) / 2 + 20;
const textWidth = width - SIDE * 2;
// One size for the whole film: the largest at which its longest line still fits.
const fontSize = Math.min(
wide ? MAX_WIDE_FONT : Infinity,
headlineSize(captions.flatMap((caption) => copy.slides[caption.id] ?? []), textWidth),
);
return (
<Backdrop>
<svg
width={STAGE.width * scale}
height={stageHeight}
viewBox={`${-STAGE.width / 2} ${-STAGE.height / 2} ${STAGE.width} ${STAGE.height}`}
// While the camera moves, the drawing is larger than the stage: it fades out at the stage's edges.
style={{
position: "absolute",
left: (width - STAGE.width * scale) / 2,
top: stageTop,
maskImage: EDGE_FADE,
WebkitMaskImage: EDGE_FADE,
maskComposite: "intersect",
WebkitMaskComposite: "source-in",
}}
>
{children}
</svg>
<div style={{ position: "absolute", left: SIDE, width: textWidth, top: stageTop + stageHeight + GAP, height: STATEMENT_HEIGHT }}>
{captions.map((caption, index) => (
<Statement key={caption.id} caption={caption} first={index === 0} fontSize={fontSize} total={durationInFrames} />
))}
</div>
</Backdrop>
);
};
+196
View File
@@ -0,0 +1,196 @@
import type { FC } from "react";
import { useCurrentFrame } from "remotion";
import { DRAW } from "../brand/tokens";
import { inOut, mix, out, progress } from "../illustrations/motion";
import { hopAt, type Point } from "../lib/hop";
import { FilmStage, type Caption } from "./FilmStage";
export const ORDER_FILM_FRAMES = 690;
/**
* One continuous shot, told by the camera. Close up, a dot drifts between two squares: ping-pong.
* The camera pulls back: it is one of many, a whole office carrying things from tool to tool.
* Everything stops, the dots sink onto a line, and the lines carry the work by themselves.
* The camera returns to the two squares, so the film loops.
*/
const T = {
reveal: 75,
wide: 195,
freeze: 310,
sink: 345,
lines: 365,
flow: 440,
cta: 560,
fold: 630,
} as const;
const CAPTIONS: readonly Caption[] = [
{ id: "hook", from: 0, to: T.reveal + 20 },
{ id: "reveal", from: T.reveal + 30, to: T.wide + 30 },
{ id: "problem", from: T.wide + 30, to: T.freeze + 20 },
{ id: "turn", from: T.sink, to: T.flow + 20 },
{ id: "calm", from: T.flow + 20, to: T.cta },
{ id: "cta", from: T.cta, to: ORDER_FILM_FRAMES, button: true },
];
const COLS = 4;
const HERO_ROW = 2;
const PITCH = { x: 220, y: 190 } as const;
const TILE = 96;
const ZOOM_IN = 2.6;
const ZOOM_OUT = 0.8;
/** Height of a hop as a share of its length. */
const ARC = 0.22;
const CLOSE_HOP_FRAMES = 30;
/** The film starts in mid-flight: the first frame is the preview image. */
const CLOSE_PHASE = 15;
const colX = (col: number): number => (col - (COLS - 1) / 2) * PITCH.x;
const rowY = (row: number): number => (row - HERO_ROW) * PITCH.y;
const stop = (row: number, col: number): Point => ({ x: colX(col), y: rowY(row) });
/** Each row has someone carrying things round its four tools, in their own order and pace. */
const WALKERS = [
{ route: [0, 1, 2, 3], hopFrames: 27, offset: 7 },
{ route: [2, 0, 3, 1], hopFrames: 32, offset: 40 },
{ route: [2, 3, 0, 1], hopFrames: 29, offset: 0 },
{ route: [3, 2, 1, 0], hopFrames: 25, offset: 19 },
{ route: [1, 3, 0, 2], hopFrames: 30, offset: 55 },
] as const;
/** 0 while the camera is close, 1 when it has pulled back. */
const pulledBack = (frame: number): number =>
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, ORDER_FILM_FRAMES)));
/** Time as the walkers feel it: it runs, then slows to a halt at the freeze. */
const walkTime = (frame: number): number => {
if (frame < T.freeze) return frame;
const u = progress(frame, T.freeze, T.freeze + 30);
return T.freeze + 15 * (1 - (1 - u) ** 2);
};
const walkerAt = (row: number, frame: number): Point => {
const walker = WALKERS[row];
if (!walker) return stop(row, 0);
const time = walkTime(frame);
// The one in the middle row plays ping-pong between the two inner squares while the camera is close.
if (row === HERO_ROW && (time < T.reveal || frame >= T.fold)) {
const loopTime = frame >= T.fold ? frame - ORDER_FILM_FRAMES : time;
return hopAt(loopTime + CLOSE_PHASE, [stop(row, 1), stop(row, 2)], CLOSE_HOP_FRAMES, ARC * 1.4);
}
const start = row === HERO_ROW ? T.reveal : -walker.offset;
return hopAt(time - start, walker.route.map((col) => stop(row, col)), walker.hopFrames, ARC);
};
/** The picture alone, without stage and statements: also used frozen, as an image post. */
export const OrderDrawing: FC = () => {
const frame = useCurrentFrame();
const back = pulledBack(frame);
const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back;
/** Everything outside the opening picture fades in as the camera pulls back. */
const others = progress(back, 0.05, 0.5);
const hair = 2.2 / zoom;
// Back in the air for the return to the opening picture.
const sink = frame >= T.fold ? 0 : inOut(progress(frame, T.sink, T.sink + 30));
const folding = 1 - progress(frame, T.fold, T.fold + 24);
return (
<g transform={`scale(${zoom})`}>
{WALKERS.map((_, row) => {
const hero = row === HERO_ROW;
const y = rowY(row);
const away = Math.abs(row - HERO_ROW);
// The line that replaces the walking.
const lineStart = T.lines + away * 12;
const drawn = out(progress(frame, lineStart, lineStart + 45)) * folding;
const lineEnd = mix(colX(0), colX(COLS - 1), drawn);
// Work travelling along the line by itself.
const sincePulse = frame - (T.flow + away * 10);
const pulseT = sincePulse < 0 ? -1 : (sincePulse % 80) / 55;
const pulseX = pulseT >= 0 && pulseT <= 1 ? mix(colX(0), colX(COLS - 1), inOut(pulseT)) : null;
// The walker, and the light it trails.
const walkerShown =
(hero ? 1 : others) * (1 - progress(frame, lineStart, lineStart + 18)) +
(hero ? progress(frame, T.fold + 28, ORDER_FILM_FRAMES - 6) : 0);
const place = (at: number): Point => {
const point = walkerAt(row, at);
return { x: point.x, y: mix(point.y, y, sink) };
};
const walker = place(frame);
const radius = 12 / Math.sqrt(zoom);
return (
<g key={row}>
{drawn > 0 && (
<path
d={`M${colX(0)} ${y}H${lineEnd}`}
stroke={DRAW.fg}
strokeWidth={hair * 1.3}
strokeLinecap="round"
opacity={hero ? 1 : others}
style={{ filter: `drop-shadow(0 0 ${6 / zoom}px rgba(${DRAW.light}, 0.9))` }}
/>
)}
{Array.from({ length: COLS }, (_unused, col) => {
const x = colX(col);
const opening = hero && (col === 1 || col === 2);
const touched = walkerShown > 0.5 && Math.hypot(walker.x - x, walker.y - y) < TILE * 0.42;
const lit = pulseX === null ? 0 : Math.max(0, 1 - Math.abs(pulseX - x) / 110) * folding;
return (
<g key={col} opacity={opening ? 1 : others}>
<rect x={x - TILE / 2} y={y - TILE / 2} width={TILE} height={TILE} rx={TILE * 0.24} fill={DRAW.bg} />
<rect
x={x - TILE / 2}
y={y - TILE / 2}
width={TILE}
height={TILE}
rx={TILE * 0.24}
fill={`rgba(248, 250, 252, ${0.1 + lit * 0.22})`}
stroke={touched ? DRAW.mark : DRAW.fg}
strokeOpacity={touched ? 1 : 0.75 + lit * 0.25}
strokeWidth={touched ? hair * 1.6 : hair}
/>
</g>
);
})}
{pulseX !== null && folding > 0 && (
<circle
cx={pulseX}
cy={y}
r={radius * 0.8}
fill={DRAW.mark}
opacity={(hero ? 1 : others) * folding}
style={{ filter: `drop-shadow(0 0 ${10 / zoom}px ${DRAW.mark})` }}
/>
)}
{walkerShown > 0 && (
<g opacity={Math.min(1, walkerShown)}>
{[1, 2, 3, 4, 5].map((k) => {
const ghost = place(frame - k * 2.2);
return <circle key={k} cx={ghost.x} cy={ghost.y} r={radius * (1 - k * 0.13)} fill={DRAW.mark} opacity={0.3 - k * 0.05} />;
})}
<circle
cx={walker.x}
cy={walker.y}
r={radius}
fill={DRAW.mark}
style={{ filter: `drop-shadow(0 0 ${12 / zoom}px ${DRAW.mark})` }}
/>
</g>
)}
</g>
);
})}
</g>
);
};
/** Automation, as one continuous shot: from ping-pong to tools that talk to each other. */
export const OrderFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<OrderDrawing />
</FilmStage>
);
+133
View File
@@ -0,0 +1,133 @@
import type { FC } from "react";
import { Easing, useCurrentFrame } from "remotion";
import { DRAW } from "../brand/tokens";
import { inOut, mix, progress } from "../illustrations/motion";
import { FilmStage } from "./FilmStage";
import { CAPTIONS, SURFACE, T, cameraAt, untilFold } from "./shot";
/**
* Close up, a blinking cursor: a beginning. The camera pulls back: it is an empty page, and it has
* been empty for months. Then the page builds itself, block by block, and becomes three screens.
*/
const WINDOW = { width: 640, height: 440 } as const;
const BAR_HEIGHT = 50;
const CURSOR = { x: -250, y: -110, width: 9, height: 62 } as const;
const BLINK_FRAMES = 30;
const settle = Easing.bezier(0.16, 1, 0.3, 1);
/** The blocks of a simple page, in the coordinates of a screen of the given size. */
const Page: FC<{ width: number; height: number; columns: number; shown: (block: number) => number }> = ({
width,
height,
columns,
shown,
}) => {
const pad = width * 0.07;
const inner = width - pad * 2;
const top = -height / 2 + pad;
const heroHeight = height * 0.42;
const cardsTop = top + heroHeight + pad * 0.6;
const cardHeight = height / 2 - pad - cardsTop;
const gap = pad * 0.5;
const cardWidth = (inner - gap * (columns - 1)) / columns;
const block = (index: number): { opacity: number; transform: string } => ({
opacity: shown(index),
transform: `translate(0 ${(1 - shown(index)) * 22})`,
});
return (
<>
<g {...block(0)}>
<rect x={-inner / 2} y={top} width={inner} height={heroHeight} rx={pad * 0.45} fill={DRAW.fg} />
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.34} width={inner * 0.5} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} />
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.58} width={inner * 0.3} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} opacity={0.45} />
</g>
{Array.from({ length: columns }, (_, column) => (
<rect
key={column}
{...block(1 + column)}
x={-inner / 2 + column * (cardWidth + gap)}
y={cardsTop}
width={cardWidth}
height={cardHeight}
rx={pad * 0.45}
fill="rgba(248, 250, 252, 0.22)"
/>
))}
</>
);
};
const Screen: FC<{ width: number; height: number; hair: number; radius: number }> = ({ width, height, hair, radius }) => (
<>
<rect x={-width / 2} y={-height / 2} width={width} height={height} rx={radius} fill={DRAW.bg} />
<rect
x={-width / 2}
y={-height / 2}
width={width}
height={height}
rx={radius}
fill={SURFACE.fill}
stroke={DRAW.fg}
strokeOpacity={SURFACE.strokeOpacity}
strokeWidth={hair}
/>
</>
);
const DEVICES = [
{ x: 195, y: 20, width: 190, height: 270, columns: 2 },
{ x: 380, y: 50, width: 112, height: 210, columns: 1 },
] as const;
/** The picture alone, without stage and statements: also used frozen, as an image post. */
export const WebsiteDrawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 3, 1, CURSOR);
const { hair, others } = camera;
const blink = Math.cos((frame / BLINK_FRAMES) * Math.PI * 2) > -0.2 ? 1 : 0.12;
const cursorShown = 1 - progress(frame, T.turn, T.turn + 12) + progress(frame, T.fold + 24, T.fold + 44);
const built = (block: number): number =>
settle(progress(frame, T.resolve - 12 + block * 12, T.resolve + 22 + block * 12)) * untilFold(frame);
const split = inOut(progress(frame, T.calm, T.calm + 50)) * (1 - inOut(progress(frame, T.fold, T.fold + 30)));
const contentHeight = WINDOW.height - BAR_HEIGHT;
return (
<g transform={camera.transform}>
{DEVICES.map((device) => (
<g key={device.x} opacity={split} transform={`translate(${mix(-120, device.x, split)} ${device.y})`}>
<Screen width={device.width} height={device.height} hair={hair} radius={22} />
<Page width={device.width} height={device.height} columns={device.columns} shown={() => 1} />
</g>
))}
<g transform={`translate(${mix(0, -170, split)} 0) scale(${mix(1, 0.72, split)})`}>
<g opacity={others}>
<Screen width={WINDOW.width} height={WINDOW.height} hair={hair} radius={30} />
<path d={`M${-WINDOW.width / 2} ${-WINDOW.height / 2 + BAR_HEIGHT}h${WINDOW.width}`} stroke={DRAW.fg} strokeOpacity={0.4} strokeWidth={hair} />
{[0, 1, 2].map((n) => (
<circle key={n} cx={-WINDOW.width / 2 + 30 + n * 24} cy={-WINDOW.height / 2 + BAR_HEIGHT / 2} r={7} fill={DRAW.fg} opacity={0.3} />
))}
</g>
<g transform={`translate(0 ${BAR_HEIGHT / 2})`}>
<Page width={WINDOW.width} height={contentHeight} columns={3} shown={built} />
</g>
<rect
x={CURSOR.x - CURSOR.width / 2}
y={CURSOR.y - CURSOR.height / 2}
width={CURSOR.width}
height={CURSOR.height}
rx={CURSOR.width / 2}
fill={DRAW.mark}
opacity={Math.min(1, cursorShown) * blink}
/>
</g>
</g>
);
};
/** A website: from a page that has been empty for months to one that is built and fits every screen. */
export const WebsiteFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<WebsiteDrawing />
</FilmStage>
);
+75
View File
@@ -0,0 +1,75 @@
import { inOut, progress } from "../illustrations/motion";
import type { Point } from "../lib/hop";
import type { Caption } from "./FilmStage";
/**
* What the films share: one timeline and one camera move. Every film is a single shot:
* close on something small, pull back to see what it really is, stop, resolve, rest,
* and return to the opening picture so that it loops.
*/
export const SHOT_FRAMES = 690;
export const T = {
/** The camera starts to pull back. */
reveal: 75,
/** The whole picture is in view. */
wide: 195,
/** Everything slows to a halt. */
freeze: 310,
/** The turn begins. */
turn: 345,
/** The problem resolves. */
resolve: 365,
/** The calm result. */
calm: 440,
cta: 560,
/** The picture folds back into its opening. */
fold: 630,
} as const;
/** The six statements of a film, in order: hook, reveal, problem, turn, calm, call to action. */
export const CAPTIONS: readonly Caption[] = [
{ id: "hook", from: 0, to: T.reveal + 20 },
{ id: "reveal", from: T.reveal + 30, to: T.wide + 30 },
{ id: "problem", from: T.wide + 30, to: T.freeze + 20 },
{ id: "turn", from: T.turn, to: T.calm + 20 },
{ id: "calm", from: T.calm + 20, to: T.cta },
{ id: "cta", from: T.cta, to: SHOT_FRAMES, button: true },
];
/** 0 while the camera is close, 1 when it has pulled back. */
export const pulledBack = (frame: number): number =>
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, SHOT_FRAMES)));
/** 1 until the fold, then quickly 0: for everything that has to be gone before the loop closes. */
export const untilFold = (frame: number): number => 1 - progress(frame, T.fold, T.fold + 24);
export type Camera = {
readonly zoom: number;
/** Opacity of everything outside the opening picture: it fades in as the camera pulls back. */
readonly others: number;
/** Width of a hairline that looks the same at every zoom. */
readonly hair: number;
/** SVG transform for the group that holds the drawing. */
readonly transform: string;
};
/** The camera at a frame: close on `focus` at `zoomIn`, then the whole stage at `zoomOut`. */
export const cameraAt = (frame: number, zoomIn: number, zoomOut: number, focus: Point): Camera => {
const back = pulledBack(frame);
const zoom = zoomIn * (zoomOut / zoomIn) ** back;
return {
zoom,
others: progress(back, 0.05, 0.5),
hair: 2.2 / zoom,
transform: `scale(${zoom}) translate(${-focus.x * (1 - back)} ${-focus.y * (1 - back)})`,
};
};
/** A soft light around a shape, the same size on screen at every zoom. */
export const glow = (color: string, px: number, zoom: number): { filter: string } => ({
filter: `drop-shadow(0 0 ${px / zoom}px ${color})`,
});
/** The quiet look of every surface: a light tint and a fine light outline. */
export const SURFACE = { fill: "rgba(248, 250, 252, 0.1)", strokeOpacity: 0.75 } as const;
+20
View File
@@ -0,0 +1,20 @@
import { Easing, interpolate } from "remotion";
/** Small motion helpers shared by all illustrations. All of them are pure functions of the frame. */
const CLAMP = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
/** 0 before `from`, 1 after `to`, linear in between. */
export const progress = (frame: number, from: number, to: number): number =>
interpolate(frame, [from, to], [0, 1], CLAMP);
export const mix = (from: number, to: number, t: number): number => from + (to - from) * t;
export const inOut = Easing.inOut(Easing.cubic);
export const out = Easing.out(Easing.cubic);
/** Overshoots slightly, for things that pop in. */
export const pop = Easing.out(Easing.back(2.2));
/** `pop` between two frames: 0 before, 1 after. */
export const popAt = (frame: number, from: number, length = 8): number =>
pop(progress(frame, from, from + length));
+311
View File
@@ -0,0 +1,311 @@
import type { FC, ReactNode } from "react";
import { FONT } from "../brand/fonts";
import { useT } from "../content/CopyContext";
import { COLOR, DRAW } from "../brand/tokens";
/** Shared drawing parts for illustrations. Everything is SVG, drawn in stage coordinates. */
export type IconName =
| "cart"
| "invoice"
| "people"
| "mail"
| "check"
| "book"
| "form"
| "chat"
| "bell"
| "tasks";
const ICON_PATHS: Readonly<Record<IconName, ReactNode>> = {
cart: (
<>
<path d="M3 4h2l2.4 10.2a1 1 0 0 0 1 .8h8.9a1 1 0 0 0 1-.8L20 7H6.2" />
<circle cx="9.5" cy="19" r="1.3" />
<circle cx="17" cy="19" r="1.3" />
</>
),
invoice: (
<>
<path d="M7 3h7l4 4v14H7z" />
<path d="M14 3v4h4" />
<path d="M10 12h5" />
<path d="M10 16h5" />
</>
),
people: (
<>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
</>
),
mail: (
<>
<rect x="3" y="6" width="18" height="13" rx="2" />
<path d="m3.5 7.5 8.5 6 8.5-6" />
</>
),
check: (
<>
<circle cx="12" cy="12" r="9" />
<path d="m8 12.5 3 3 5-6" />
</>
),
book: (
<>
<path d="M5 4h11a2 2 0 0 1 2 2v14H7a2 2 0 0 1-2-2z" />
<path d="M5 18a2 2 0 0 1 2-2h11" />
<path d="M9 8h6" />
</>
),
form: (
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M8 9h8" />
<path d="M8 13h8" />
<path d="M8 17h4" />
</>
),
chat: <path d="M4 5h16v11H10l-4 4v-4H4z" />,
bell: (
<>
<path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z" />
<path d="M10 20a2 2 0 0 0 4 0" />
</>
),
tasks: (
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="m8 12 3 3 5-6" />
</>
),
};
/** A line icon from a 24 x 24 grid, centred on (cx, cy). */
export const Icon: FC<{ name: IconName; cx: number; cy: number; size: number; color: string }> = ({
name,
cx,
cy,
size,
color,
}) => (
<g
transform={`translate(${cx - size / 2} ${cy - size / 2}) scale(${size / 24})`}
fill="none"
stroke={color}
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
>
{ICON_PATHS[name]}
</g>
);
export const TILE_SIZE = 150;
/** One tool: a dark rounded tile with an icon and a mono label below. */
export const Tile: FC<{
x: number;
y: number;
icon: IconName;
label: string;
/** Coloured outline, e.g. while someone works in this tool. */
ring?: string;
scale?: number;
opacity?: number;
}> = ({ x, y, icon, label, ring, scale = 1, opacity = 1 }) => {
const centre = TILE_SIZE / 2;
return (
<g transform={`translate(${x} ${y})`} opacity={opacity}>
<g transform={`translate(${centre} ${centre}) scale(${scale}) translate(${-centre} ${-centre})`}>
{ring && (
<rect
x={-10}
y={-10}
width={TILE_SIZE + 20}
height={TILE_SIZE + 20}
rx={42}
fill="none"
stroke={ring}
strokeWidth={4}
style={{ filter: `drop-shadow(0 0 12px ${ring})` }}
/>
)}
<rect width={TILE_SIZE} height={TILE_SIZE} rx={34} fill={DRAW.tint} stroke={DRAW.fg} strokeWidth={2.5} />
<Icon name={icon} cx={centre} cy={centre} size={78} color={DRAW.fg} />
</g>
<text
x={centre}
y={TILE_SIZE + 44}
textAnchor="middle"
fontFamily={FONT.mono}
fontWeight={500}
fontSize={19}
letterSpacing="0.16em"
fill={DRAW.label}
>
{useT()(label).toUpperCase()}
</text>
</g>
);
};
/** Round badge with a tick, centred on (cx, cy). */
export const DoneBadge: FC<{ cx: number; cy: number; r: number; scale?: number }> = ({ cx, cy, r, scale = 1 }) => (
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
<circle r={r} fill={COLOR.done} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${COLOR.done})` }} />
<path
d={`M${-r * 0.42} ${r * 0.04} ${-r * 0.1} ${r * 0.36} ${r * 0.44} ${-r * 0.3}`}
fill="none"
stroke={DRAW.bg}
strokeWidth={r * 0.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
);
/** Round amber badge with one character: something needs attention. */
export const WarnBadge: FC<{ cx: number; cy: number; r: number; scale?: number; char?: string }> = ({
cx,
cy,
r,
scale = 1,
char = "!",
}) => (
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
<circle r={r} fill={DRAW.mark} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${DRAW.mark})` }} />
<text y={r * 0.42} textAnchor="middle" fontFamily={FONT.sans} fontWeight={900} fontSize={r * 1.2} fill={DRAW.bg}>
{char}
</text>
</g>
);
/** Mono caption inside a drawing. */
export const Label: FC<{ x: number; y: number; text: string; anchor?: "start" | "middle" | "end"; color?: string; size?: number }> = ({
x,
y,
text,
anchor = "middle",
color = DRAW.label,
size = 19,
}) => (
<text
x={x}
y={y}
textAnchor={anchor}
fontFamily={FONT.mono}
fontWeight={500}
fontSize={size}
letterSpacing="0.16em"
fill={color}
>
{useT()(text).toUpperCase()}
</text>
);
/** Mouse pointer with its tip at (x, y): stands for a person doing the step by hand. */
export const Cursor: FC<{ x: number; y: number; scale?: number }> = ({ x, y, scale = 1.5 }) => (
<path
transform={`translate(${x} ${y}) scale(${scale})`}
d="M0 0 0 30 8 23 14 36 19 34 13 21 24 21Z"
fill={DRAW.fg}
stroke={DRAW.bg}
strokeWidth={2.5}
strokeLinejoin="round"
/>
);
/** A piece of data: dark pill with binary digits. */
export const DataChip: FC<{ x: number; y: number; width?: number; digits?: string; color?: string }> = ({
x,
y,
width = 150,
digits = "0110",
color = DRAW.fg,
}) => (
<g transform={`translate(${x} ${y})`}>
<rect width={width} height={44} rx={22} fill={color} />
<text
x={width / 2}
y={30}
textAnchor="middle"
fontFamily={FONT.mono}
fontWeight={700}
fontSize={22}
letterSpacing="0.2em"
fill={DRAW.bg}
>
{digits}
</text>
</g>
);
/** An app window: white card with a title bar. Children are drawn in its coordinates. */
export const AppWindow: FC<{
x: number;
y: number;
width: number;
height: number;
title: string;
children?: ReactNode;
}> = ({ x, y, width, height, title, children }) => (
<g transform={`translate(${x} ${y})`}>
<rect width={width} height={height} rx={30} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
<path d={`M0 64H${width}`} stroke={DRAW.rim} strokeWidth={2} />
{[30, 56, 82].map((cx) => (
<circle key={cx} cx={cx} cy={33} r={8} fill={DRAW.soft} />
))}
<text
x={width - 28}
y={41}
textAnchor="end"
fontFamily={FONT.mono}
fontWeight={500}
fontSize={19}
letterSpacing="0.16em"
fill={DRAW.label}
>
{useT()(title).toUpperCase()}
</text>
{children}
</g>
);
/** A box for one day of a week or a countdown, centred on cx. */
export const DayBox: FC<{
cx: number;
y: number;
width: number;
label: string;
filled: boolean;
scale?: number;
}> = ({ cx, y, width, label, filled, scale = 1 }) => (
<g transform={`translate(${cx} ${y + 55}) scale(${scale})`}>
<rect
x={-width / 2}
y={-55}
width={width}
height={110}
rx={26}
fill={filled ? DRAW.fg : DRAW.glass}
stroke={filled ? DRAW.fg : DRAW.rim}
strokeWidth={2.5}
/>
<Label x={0} y={8} text={label} color={filled ? DRAW.bg : DRAW.fg} size={24} />
</g>
);
/** A sheet of paper. Children are drawn in its coordinates. */
export const Page: FC<{ x: number; y: number; width: number; height: number; children?: ReactNode }> = ({
x,
y,
width,
height,
children,
}) => (
<g transform={`translate(${x} ${y})`}>
<rect width={width} height={height} rx={24} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
{children}
</g>
);
+259
View File
@@ -0,0 +1,259 @@
import type { FC, ReactNode } from "react";
import { useCurrentFrame } from "remotion";
import { COLOR, DRAW } from "../brand/tokens";
import { STAGE_WIDTH } from "../slides/SlideDeck";
import { inOut, mix, out, popAt, progress } from "./motion";
import { AppWindow, Cursor, DataChip, DoneBadge, Icon, TILE_SIZE, Tile, type IconName } from "./parts";
/** Scenes that several videos share. Each is one slide's illustration, drawn from its frame 0. */
export const SCENE_HEIGHT = 520;
export const CTA_SCENE_HEIGHT = 300;
export type Tool = { readonly icon: IconName; readonly label: string };
/** Scenes with a row of tools expect exactly four of them. */
export type Tools = readonly [Tool, Tool, Tool, Tool];
const TOOL_COUNT = 4;
const TOOL_GAP = (STAGE_WIDTH - TOOL_COUNT * TILE_SIZE) / (TOOL_COUNT - 1);
export const toolX = (index: number): number => index * (TILE_SIZE + TOOL_GAP);
export const ROW_Y = 185;
export const Canvas: FC<{ height?: number; children: ReactNode }> = ({ height = SCENE_HEIGHT, children }) => (
<svg width={STAGE_WIDTH} height={height} viewBox={`0 0 ${STAGE_WIDTH} ${height}`} style={{ overflow: "visible" }}>
{children}
</svg>
);
const CHIP_DIGITS = ["0110", "1001", "1101"] as const;
/** Values carried by hand from one window into another, again and again. */
export const CarryScene: FC<{ fromTitle: string; toTitle: string }> = ({ fromTitle, toTitle }) => {
const frame = useCurrentFrame();
const perChip = 25;
const rowY = (row: number): number => 166 + row * 92;
const current = Math.min(CHIP_DIGITS.length - 1, Math.floor(frame / perChip));
const chipAt = (row: number): { x: number; y: number } => {
const t = row < current ? 1 : row > current ? 0 : inOut(progress(frame, row * perChip + 4, row * perChip + 20));
return { x: mix(34, 472 + 38, t), y: rowY(row) - Math.sin(Math.PI * t) * 46 };
};
const held = chipAt(current);
return (
<Canvas>
<AppWindow x={0} y={60} width={400} height={400} title={fromTitle}>
{CHIP_DIGITS.map((digits, row) => (
<rect key={digits} x={200} y={rowY(row) - 46} width={160} height={16} rx={8} fill={DRAW.soft} />
))}
</AppWindow>
<AppWindow x={472} y={60} width={400} height={400} title={toTitle}>
{CHIP_DIGITS.map((digits, row) => (
<rect
key={digits}
x={30}
y={rowY(row) - 66}
width={340}
height={56}
rx={28}
fill="none"
stroke={DRAW.rim}
strokeWidth={2}
strokeDasharray="10 10"
/>
))}
</AppWindow>
{CHIP_DIGITS.map((digits, row) => {
const chip = chipAt(row);
return <DataChip key={digits} x={chip.x} y={chip.y} digits={digits} />;
})}
<Cursor x={held.x + 112} y={held.y + 26} />
</Canvas>
);
};
/** Four separate tools, and a person hopping between them with the data. */
export const HopScene: FC<{ tools: Tools }> = ({ tools }) => {
const frame = useCurrentFrame();
const perTool = 15;
const active = Math.min(TOOL_COUNT - 1, Math.floor(frame / perTool));
const hop = active === 0 ? 1 : inOut(progress(frame, active * perTool, active * perTool + 9));
const x = mix(toolX(Math.max(0, active - 1)), toolX(active), hop) + 78;
const y = ROW_Y + TILE_SIZE + 70 - Math.sin(Math.PI * hop) * 40;
return (
<Canvas>
{tools.map((tool, index) => (
<Tile key={tool.label} x={toolX(index)} y={ROW_Y} {...tool} ring={index === active ? COLOR.active : undefined} />
))}
<DataChip x={x + 34} y={y + 26} width={110} />
<Cursor x={x} y={y} />
</Canvas>
);
};
const CARD = { width: 300, height: 66, step: 46, count: 9, x: 60, base: 440 } as const;
/** One item of work waiting to be handled. */
export const WorkCard: FC<{
x: number;
y: number;
icon: IconName;
tilt?: number;
opacity?: number;
done?: boolean;
width?: number;
}> = ({ x, y, icon, tilt = 0, opacity = 1, done = false, width = CARD.width }) => (
<g transform={`translate(${x} ${y}) rotate(${tilt} ${width / 2} ${CARD.height / 2})`} opacity={opacity}>
<rect width={width} height={CARD.height} rx={20} fill={DRAW.glass} stroke={done ? COLOR.done : DRAW.fg} strokeWidth={2.5} />
<Icon name={icon} cx={40} cy={CARD.height / 2} size={34} color={done ? COLOR.done : DRAW.fg} />
<rect x={76} y={CARD.height / 2 - 8} width={width / 2} height={16} rx={8} fill={DRAW.soft} />
</g>
);
const cardTilt = (index: number): number => (index % 2 === 0 ? -2.5 : 2);
const cardY = (index: number): number => CARD.base - index * CARD.step;
/** Work arrives faster than one person can handle it. */
export const PileScene: FC<{ icon: IconName }> = ({ icon }) => {
const frame = useCurrentFrame();
const every = 5;
return (
<Canvas>
{Array.from({ length: CARD.count }, (_, index) => {
const fall = out(progress(frame, index * every, index * every + 8));
return fall === 0 ? null : (
<WorkCard key={index} x={CARD.x} y={mix(-120, cardY(index), fall)} icon={icon} tilt={cardTilt(index)} opacity={fall} />
);
})}
<Tile x={600} y={ROW_Y} icon="people" label="One person" ring={COLOR.active} />
<Cursor x={690 + Math.cos(frame / 5) * 14} y={ROW_Y + TILE_SIZE / 2 + 40 + Math.sin(frame / 5) * 14} />
</Canvas>
);
};
/** The pile from PileScene clears itself. */
export const ClearedScene: FC<{ icon: IconName }> = ({ icon }) => {
const frame = useCurrentFrame();
const every = 4;
return (
<Canvas>
<DoneBadge cx={STAGE_WIDTH / 2} cy={250} r={120} scale={popAt(frame, 36, 14)} />
{Array.from({ length: CARD.count }, (_, index) => {
const order = CARD.count - 1 - index;
const leave = inOut(progress(frame, 4 + order * every, 14 + order * every));
return leave === 1 ? null : (
<WorkCard
key={index}
x={CARD.x + leave * 620}
y={cardY(index)}
icon={icon}
tilt={cardTilt(index)}
opacity={1 - leave}
done={leave > 0}
/>
);
})}
</Canvas>
);
};
const PACKET_TRAVEL_FRAMES = 27;
const PACKET_EVERY_FRAMES = 9;
/** Four tools in a row, connected, with work travelling through on its own. */
export const FlowRow: FC<{
tools: Tools;
y: number;
/** 0..1: how far the link after tool `index` is drawn. */
connected: (index: number) => number;
/** Frame at which work starts to flow, or null while the tools are only being connected. */
flowFrom: number | null;
}> = ({ tools, y, connected, flowFrom }) => {
const frame = useCurrentFrame();
const centreY = y + TILE_SIZE / 2;
const firstCentre = toolX(0) + TILE_SIZE / 2;
const lastCentre = toolX(TOOL_COUNT - 1) + TILE_SIZE / 2;
const flowing = flowFrom === null ? -1 : frame - flowFrom;
const packets =
flowing < 0 ? [] : Array.from({ length: Math.floor(flowing / PACKET_EVERY_FRAMES) + 1 }, (_, n) => n);
const passFrame = (index: number): number => (PACKET_TRAVEL_FRAMES * index) / (TOOL_COUNT - 1);
return (
<>
{tools.slice(1).map((tool, index) => {
const from = toolX(index) + TILE_SIZE;
const drawn = connected(index);
const tip = mix(from, toolX(index + 1), drawn);
return drawn === 0 ? null : (
<g key={tool.label}>
<path d={`M${from} ${centreY}H${tip}`} stroke={DRAW.fg} strokeWidth={2.5} />
<circle cx={tip} cy={centreY} r={6} fill={DRAW.fg} />
</g>
);
})}
{packets.map((n) => {
const t = (flowing - n * PACKET_EVERY_FRAMES) / PACKET_TRAVEL_FRAMES;
return t > 1 ? null : (
<circle key={n} cx={mix(firstCentre, lastCentre, t)} cy={centreY} r={11}
fill={DRAW.mark}
stroke={DRAW.mark}
strokeOpacity={0.28}
strokeWidth={16}
style={{ filter: `drop-shadow(0 0 10px ${DRAW.mark})` }}
/>
);
})}
{tools.map((tool, index) => {
const since = flowing - passFrame(index);
const pulse = since < 0 ? 0 : 1 - (since % PACKET_EVERY_FRAMES) / PACKET_EVERY_FRAMES;
return (
<g key={tool.label}>
<Tile x={toolX(index)} y={y} {...tool} scale={1 + pulse * 0.05} />
{since >= 0 && (
<DoneBadge cx={toolX(index) + TILE_SIZE - 8} cy={y + 8} r={26} scale={popAt(since, 0) * (1 + pulse * 0.12)} />
)}
</g>
);
})}
</>
);
};
/** The tools get connected, one link after the other. */
export const ConnectScene: FC<{ tools: Tools }> = ({ tools }) => {
const frame = useCurrentFrame();
return (
<Canvas>
<FlowRow
tools={tools}
y={ROW_Y}
connected={(index) => inOut(progress(frame, 8 + index * 12, 20 + index * 12))}
flowFrom={null}
/>
</Canvas>
);
};
const RUNS_FLOW_FROM = 4;
const FINISHED_SLOTS = 9;
/** Work runs through all four tools by itself; finished items collect below. */
export const RunsScene: FC<{ tools: Tools }> = ({ tools }) => {
const frame = useCurrentFrame();
const firstArrival = RUNS_FLOW_FROM + PACKET_TRAVEL_FRAMES;
return (
<Canvas>
<FlowRow tools={tools} y={110} connected={() => 1} flowFrom={RUNS_FLOW_FROM} />
{Array.from({ length: FINISHED_SLOTS }, (_, n) => {
const arrival = firstArrival + n * PACKET_EVERY_FRAMES;
return frame < arrival ? null : (
<DoneBadge key={n} cx={36 + n * 100} cy={420} r={30} scale={popAt(frame, arrival)} />
);
})}
</Canvas>
);
};
/** The connected tools, still running: sits above the call to action. */
export const RunningScene: FC<{ tools: Tools }> = ({ tools }) => (
<Canvas height={CTA_SCENE_HEIGHT}>
<FlowRow tools={tools} y={64} connected={() => 1} flowFrom={-60} />
</Canvas>
);
+87
View File
@@ -0,0 +1,87 @@
import type { FC } from "react";
import { useVideoConfig } from "remotion";
import { FONT } from "../brand/fonts";
import { DRAW } from "../brand/tokens";
import { CopyProvider } from "../content/CopyContext";
import { IMAGES, type ImageCopy, type ImageId } from "../content/images";
import type { Lang } from "../content/videos";
import { Backdrop, FilmStage, Lines, SIDE, type Caption } from "../films/FilmStage";
import { VIDEOS } from "../registry";
import { headlineSize } from "../slides/SlideDeck";
const MAX_CARD_FONT = 132;
/** The statement of a poster stands for good: an image has one frame. */
const ALWAYS: readonly Caption[] = [{ id: "poster", from: 0, to: 1_000_000 }];
/**
* One picture of a film, with the film's idea in two lines below it. Which moment is shown is
* decided by the frame the composition is rendered at (see Root and the export script).
*/
const Poster: FC<{ video: string; copy: ImageCopy; lang: Lang }> = ({ video, copy, lang }) => {
const entry = VIDEOS.find((candidate) => candidate.id === video);
if (!entry || !("Drawing" in entry)) throw new Error(`No film with a drawing is registered as "${video}"`);
return (
<CopyProvider value={{ lang, copy: { title: copy.title, slides: { poster: copy.lines }, post: copy.post } }}>
<FilmStage captions={ALWAYS}>
<entry.Drawing />
</FilmStage>
</CopyProvider>
);
};
/** A question clients ask, small, and its answer in very large type. Nothing else. */
const Card: FC<{ copy: ImageCopy }> = ({ copy }) => {
const { width } = useVideoConfig();
const fontSize = Math.min(MAX_CARD_FONT, headlineSize(copy.lines, width - SIDE * 2));
return (
<Backdrop>
<div
style={{
position: "absolute",
inset: `0 ${SIDE}px 150px`,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
}}
>
<div
style={{
width: 22,
height: 22,
borderRadius: 22,
backgroundColor: DRAW.mark,
boxShadow: `0 0 28px 6px ${DRAW.mark}`,
marginBottom: fontSize * 0.5,
}}
/>
<div
style={{
fontFamily: FONT.mono,
fontWeight: 500,
fontSize: Math.round(fontSize * 0.28),
letterSpacing: "0.18em",
textTransform: "uppercase",
color: DRAW.mark,
marginBottom: fontSize * 0.45,
}}
>
{copy.label}
</div>
<Lines lines={copy.lines} fontSize={fontSize} />
</div>
</Backdrop>
);
};
/** One image post in one language: a poster of a film, or a card. */
export const ImagePost: FC<{ id: ImageId; lang: Lang }> = ({ id, lang }) => {
const image = IMAGES[id];
const copy = image.copy[lang];
return image.poster ? (
<Poster video={image.poster.video} copy={copy} lang={lang} />
) : (
<Card copy={copy} />
);
};
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { Root } from "./Root";
registerRoot(Root);
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { outlinePoint, sparkAt, type Box } from "./aura";
const BOX: Box = { width: 400, height: 200 };
const LOOP = 300;
describe("outlinePoint", () => {
it("starts at the top left corner and walks clockwise", () => {
expect(outlinePoint(0, BOX)).toEqual({ x: 0, y: 0, nx: 0, ny: -1 });
// Perimeter is 1200: a quarter of it is 300 px along the top edge.
expect(outlinePoint(0.25, BOX)).toEqual({ x: 300, y: 0, nx: 0, ny: -1 });
});
it("gives the right edge an outward normal pointing right", () => {
// 450 px: 400 along the top, 50 down the right edge.
expect(outlinePoint(450 / 1200, BOX)).toEqual({ x: 400, y: expect.closeTo(50, 6), nx: 1, ny: 0 });
});
it("walks the bottom edge from right to left and the left edge upwards", () => {
expect(outlinePoint(700 / 1200, BOX)).toEqual({ x: expect.closeTo(300, 6), y: 200, nx: 0, ny: 1 });
expect(outlinePoint(1100 / 1200, BOX)).toEqual({ x: 0, y: expect.closeTo(100, 6), nx: -1, ny: 0 });
});
it("wraps positions outside 0..1", () => {
expect(outlinePoint(1.25, BOX).x).toBeCloseTo(300, 6);
expect(outlinePoint(-0.75, BOX).x).toBeCloseTo(300, 6);
});
});
describe("sparkAt", () => {
it("is deterministic", () => {
expect(sparkAt(7, 120, LOOP, BOX)).toEqual(sparkAt(7, 120, LOOP, BOX));
});
it("is the same on the frame after the loop ends as on frame 0", () => {
for (const index of [0, 1, 2, 17, 40]) {
expect(sparkAt(index, LOOP, LOOP, BOX)).toEqual(sparkAt(index, 0, LOOP, BOX));
}
});
it("hides a spark outside its life and shows it inside", () => {
const visibleFrames = Array.from({ length: LOOP }, (_, frame) => sparkAt(3, frame, LOOP, BOX)).filter(
(spark) => spark !== null,
);
expect(visibleFrames.length).toBeGreaterThan(0);
expect(visibleFrames.length).toBeLessThan(LOOP);
});
it("fades in and out: opacity stays within 0..1 and digits are 0 or 1", () => {
for (let frame = 0; frame < LOOP; frame++) {
const spark = sparkAt(5, frame, LOOP, BOX);
if (spark === null) continue;
expect(spark.opacity).toBeGreaterThanOrEqual(0);
expect(spark.opacity).toBeLessThanOrEqual(1);
expect(["0", "1"]).toContain(spark.digit);
}
});
it("moves away from the outline as it ages", () => {
const frames = Array.from({ length: LOOP }, (_, frame) => sparkAt(9, frame, LOOP, BOX));
const first = frames.findIndex((spark) => spark !== null);
const distance = (frame: number): number => {
const spark = frames[frame];
if (!spark) throw new Error("spark expected");
const outsideX = Math.max(0, -spark.x, spark.x - BOX.width);
const outsideY = Math.max(0, -spark.y, spark.y - BOX.height);
return Math.hypot(outsideX, outsideY);
};
// Frame `first` can be mid-life if the spark was born before the loop wrapped; pick a run of two frames.
const young = frames.findIndex((spark, frame) => spark !== null && frames[frame + 6] !== null && frame >= first);
expect(distance(young + 6)).toBeGreaterThan(distance(young));
});
it("gives different sparks different places", () => {
const places = new Set(
Array.from({ length: 60 }, (_, index) => sparkAt(index, 150, LOOP, BOX))
.filter((spark) => spark !== null)
.map((spark) => `${Math.round(spark.x)}:${Math.round(spark.y)}`),
);
expect(places.size).toBeGreaterThan(3);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* The website's BitAura (energy along the outline of an element, digits drifting off it), as pure
* functions of the frame number. The website simulates it with random particles; here every spark's
* whole life is computed from its index, so each frame can be rendered on its own and the motion
* repeats exactly when a video loops.
*/
export type Box = { readonly width: number; readonly height: number };
export type OutlinePoint = {
readonly x: number;
readonly y: number;
/** Outward normal of the edge the point lies on. */
readonly nx: number;
readonly ny: number;
};
export type Spark = {
readonly x: number;
readonly y: number;
readonly opacity: number;
readonly digit: "0" | "1";
readonly large: boolean;
};
/** How often the light travels round the element during one loop of the video. */
export const AURA_LAPS = 2;
const MIN_LIFE_FRAMES = 50;
const EXTRA_LIFE_FRAMES = 60;
const MIN_SPEED = 0.35;
const EXTRA_SPEED = 0.75;
const MAX_SLIDE = 0.3;
const SCATTER = 0.14;
const LARGE_SHARE = 0.22;
const FLIP_EVERY_FRAMES = 33;
const fraction = (value: number): number => value - Math.floor(value);
/** Point on the outline of a box, walking clockwise from the top left corner. `at` wraps at 1. */
export const outlinePoint = (at: number, { width, height }: Box): OutlinePoint => {
const distance = fraction(at) * 2 * (width + height);
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
if (distance < width + height) return { x: width, y: distance - width, nx: 1, ny: 0 };
if (distance < 2 * width + height) return { x: 2 * width + height - distance, y: height, nx: 0, ny: 1 };
return { x: 0, y: 2 * (width + height) - distance, nx: -1, ny: 0 };
};
/** A stable pseudo-random number in 0..1 for a spark and one of its properties. */
const random = (index: number, property: number): number => {
const hash = Math.imul(index + 1, 2654435761) ^ Math.imul(property + 1, 2246822519);
return ((Math.imul(hash ^ (hash >>> 15), 3266489917) >>> 0) % 100000) / 100000;
};
/** Where the light is on the outline at a frame: it circles AURA_LAPS times per loop. */
export const cometAt = (frame: number, loopFrames: number): number =>
fraction((frame / loopFrames) * AURA_LAPS);
/**
* Spark number `index` at `frame`, or null while it is not alive. It is born where the light is at
* that moment, drifts away from the edge and fades. `loopFrames` is the length of the video.
*/
export const sparkAt = (index: number, frame: number, loopFrames: number, box: Box): Spark | null => {
const birth = Math.floor(random(index, 0) * loopFrames);
const life = Math.min(loopFrames, MIN_LIFE_FRAMES + random(index, 1) * EXTRA_LIFE_FRAMES);
const age = (((frame - birth) % loopFrames) + loopFrames) % loopFrames;
if (age >= life) return null;
const second = index % 2 === 1 ? 0.5 : 0;
const origin = outlinePoint(cometAt(birth, loopFrames) + second + (random(index, 2) - 0.5) * SCATTER, box);
const travelled = (MIN_SPEED + random(index, 3) * EXTRA_SPEED) * age;
const slid = (random(index, 4) - 0.5) * 2 * MAX_SLIDE * age;
const seed = random(index, 5);
return {
x: origin.x + origin.nx * travelled - origin.ny * slid,
y: origin.y + origin.ny * travelled + origin.nx * slid,
opacity: Math.sin((Math.PI * age) / life),
digit: Math.floor(age / FLIP_EVERY_FRAMES + seed * 10) % 2 === 0 ? "1" : "0",
large: random(index, 6) < LARGE_SHARE,
};
};
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { bitString, decodeText } from "./bits";
const isBinary = (s: string): boolean => /^[01]*$/.test(s);
describe("decodeText", () => {
it("shows only digits for letters and numbers at progress 0, keeps punctuation and spaces", () => {
const out = decodeText("Hi, 2 you!", 0, 0);
expect(out).toHaveLength("Hi, 2 you!".length);
expect(out.replace(/[^A-Za-z2-9]/g, "")).toBe("");
expect(out[2]).toBe(",");
expect(out[3]).toBe(" ");
expect(out[9]).toBe("!");
});
it("returns the original text at progress 1", () => {
expect(decodeText("Concept to code", 1, 7)).toBe("Concept to code");
});
it("resolves left to right: at 0.5 the first half is readable, the rest still digits", () => {
const out = decodeText("ABCDEFGH", 0.5, 3);
expect(out.slice(0, 4)).toBe("ABCD");
expect(isBinary(out.slice(4))).toBe(true);
});
it("clamps progress outside 0..1", () => {
expect(decodeText("Abc", 2, 0)).toBe("Abc");
expect(isBinary(decodeText("Abc", -1, 0))).toBe(true);
});
it("is deterministic for the same tick and varies with the tick", () => {
const text = "WEB APPS & WEBSITES";
expect(decodeText(text, 0, 5)).toBe(decodeText(text, 0, 5));
const ticks = new Set(
Array.from({ length: 10 }, (_, tick) => decodeText(text, 0, tick)),
);
expect(ticks.size).toBeGreaterThan(1);
});
it("handles the empty string", () => {
expect(decodeText("", 0.5, 0)).toBe("");
});
});
describe("bitString", () => {
it("has the requested length and only 0 and 1", () => {
const out = bitString(40, 3);
expect(out).toHaveLength(40);
expect(isBinary(out)).toBe(true);
});
it("is deterministic and changes with the tick", () => {
expect(bitString(40, 3)).toBe(bitString(40, 3));
expect(bitString(40, 3)).not.toBe(bitString(40, 4));
});
it("is empty for length 0", () => {
expect(bitString(0, 1)).toBe("");
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Pure, frame-driven binary effects. Remotion renders frame by frame, so nothing here
* uses timers or randomness: the same inputs always give the same output.
*/
const HASH_MULTIPLIER = 2654435761;
const TICK_MULTIPLIER = 40503;
const LETTER_OR_DIGIT = /[A-Za-z0-9]/;
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
/** A stable pseudo-random bit for a position at a given tick. */
const bitAt = (index: number, tick: number): "0" | "1" => {
const hash = Math.imul(index + 1, HASH_MULTIPLIER) ^ Math.imul(tick + 1, TICK_MULTIPLIER);
return ((hash >>> 7) & 1) === 0 ? "0" : "1";
};
/**
* Text resolving from digits, left to right. Letters and numbers not yet reached show
* as 0 or 1; spaces and punctuation stay put so the shape of the text is readable.
*/
export const decodeText = (text: string, progress: number, tick: number): string => {
const chars = Array.from(text);
const revealed = Math.floor(clamp01(progress) * chars.length);
return chars
.map((char, index) =>
index < revealed || !LETTER_OR_DIGIT.test(char) ? char : bitAt(index, tick),
)
.join("");
};
/** A string of `length` binary digits for a given tick. */
export const bitString = (length: number, tick: number): string =>
Array.from({ length }, (_, index) => bitAt(index, tick)).join("");
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { curtainEdge, curtainPolygon } from "./curtain";
const ROWS = Array.from({ length: 30 }, (_, row) => row);
describe("curtainEdge", () => {
it("is fully closed at progress 0 and fully open at progress 1, in every row", () => {
for (const row of ROWS) {
expect(curtainEdge(row, 0)).toBe(0);
expect(curtainEdge(row, 1)).toBe(1);
}
});
it("never moves backwards as progress grows", () => {
for (const row of ROWS) {
let last = 0;
for (let step = 0; step <= 20; step++) {
const edge = curtainEdge(row, step / 20);
expect(edge).toBeGreaterThanOrEqual(last);
last = edge;
}
}
});
it("is ragged: rows are at different places half way through", () => {
const places = new Set(ROWS.map((row) => curtainEdge(row, 0.5).toFixed(3)));
expect(places.size).toBeGreaterThan(10);
});
it("stays within 0..1 and clamps progress outside 0..1", () => {
expect(curtainEdge(3, -1)).toBe(0);
expect(curtainEdge(3, 2)).toBe(1);
});
it("is deterministic per row", () => {
expect(curtainEdge(7, 0.4)).toBe(curtainEdge(7, 0.4));
});
});
describe("curtainPolygon", () => {
const box = { width: 200, height: 40, cell: 20 };
it("covers nothing at progress 0 and the whole box at progress 1 (behind the edge)", () => {
expect(curtainPolygon("behind", 0, box)).toBe("polygon(0px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 0px 40px)");
expect(curtainPolygon("behind", 1, box)).toBe(
"polygon(0px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 0px 40px)",
);
});
it("is the complement ahead of the edge", () => {
expect(curtainPolygon("ahead", 1, box)).toBe(
"polygon(200px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 200px 40px)",
);
expect(curtainPolygon("ahead", 0, box)).toBe(
"polygon(200px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 200px 40px)",
);
});
it("adds a row for a height that is not a multiple of the cell", () => {
const points = curtainPolygon("behind", 1, { width: 100, height: 50, cell: 20 }).split(",");
// Two corner points plus two per row, for three rows.
expect(points).toHaveLength(8);
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* The website's BitCurtain (the language switch transition): a front of binary digits with a ragged
* edge that sweeps from left to right. Here it changes slides. Pure functions of the progress.
*/
/** How ragged the edge is, as a share of the width. */
const SPREAD = 0.24;
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
/** A fixed pseudo-random number in 0..1 per row, so the edge keeps its shape while it moves. */
const rowLag = (row: number): number => {
const x = Math.sin((row + 1) * 12.9898) * 43758.5453;
return x - Math.floor(x);
};
/** Where the edge is in a row, as a share of the width: 0 before the sweep, 1 after it. */
export const curtainEdge = (row: number, progress: number): number =>
clamp01(clamp01(progress) * (1 + SPREAD) - rowLag(row) * SPREAD);
export type CurtainBox = { readonly width: number; readonly height: number; readonly cell: number };
/**
* CSS `clip-path` for one side of the edge: `behind` is what the front has already passed
* (left of it), `ahead` is what it has not reached yet.
*/
export const curtainPolygon = (side: "behind" | "ahead", progress: number, box: CurtainBox): string => {
const outer = side === "behind" ? 0 : box.width;
const rows = Math.ceil(box.height / box.cell);
const steps = Array.from({ length: rows }, (_, row) => {
const x = curtainEdge(row, progress) * box.width;
return [`${x}px ${row * box.cell}px`, `${x}px ${Math.min(box.height, (row + 1) * box.cell)}px`];
}).flat();
return `polygon(${[`${outer}px 0px`, ...steps, `${outer}px ${box.height}px`].join(", ")})`;
};
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { hopAt } from "./hop";
const A = { x: 100, y: 300 };
const B = { x: 300, y: 300 };
const C = { x: 700, y: 300 };
const FRAMES = 10;
const ARC = 0.5;
describe("hopAt", () => {
it("rests on the first stop at time 0 and on the next one a hop later", () => {
expect(hopAt(0, [A, B, C], FRAMES, ARC)).toEqual(A);
expect(hopAt(10, [A, B, C], FRAMES, ARC)).toEqual(B);
expect(hopAt(20, [A, B, C], FRAMES, ARC)).toEqual(C);
});
it("is half way across and highest in the middle of a hop", () => {
// A to B is 200 px: the arc rises by half of that.
const mid = hopAt(5, [A, B, C], FRAMES, ARC);
expect(mid.x).toBeCloseTo(200, 6);
expect(mid.y).toBeCloseTo(200, 6);
});
it("jumps higher on a longer hop", () => {
// B to C is 400 px.
expect(hopAt(15, [A, B, C], FRAMES, ARC).y).toBeCloseTo(100, 6);
});
it("returns from the last stop to the first and starts again", () => {
expect(hopAt(30, [A, B, C], FRAMES, ARC)).toEqual(A);
expect(hopAt(40, [A, B, C], FRAMES, ARC)).toEqual(B);
expect(hopAt(25, [A, B, C], FRAMES, ARC).x).toBeCloseTo(400, 6);
});
it("works backwards in time, so a loop can end where it starts", () => {
expect(hopAt(-10, [A, B, C], FRAMES, ARC)).toEqual(C);
expect(hopAt(-30, [A, B, C], FRAMES, ARC)).toEqual(A);
});
it("eases: slower near a stop than in the middle of a hop", () => {
const nearStop = hopAt(1, [A, B], FRAMES, ARC).x - A.x;
const inFlight = hopAt(5, [A, B], FRAMES, ARC).x - hopAt(4, [A, B], FRAMES, ARC).x;
expect(nearStop).toBeLessThan(inFlight);
});
it("stays put with a single stop", () => {
expect(hopAt(7, [A], FRAMES, ARC)).toEqual(A);
});
});
+24
View File
@@ -0,0 +1,24 @@
/** Something that hops from stop to stop in an arc, round and round. A pure function of time. */
export type Point = { readonly x: number; readonly y: number };
const easeInOut = (t: number): number => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
const wrap = (index: number, length: number): number => ((index % length) + length) % length;
/**
* Where the hopper is at `time`. It rests on `stops[0]` at time 0, reaches the next stop every
* `framesPerHop`, and returns from the last stop to the first. The arc of a hop rises by
* `arcRatio` times the distance covered. Negative times are fine.
*/
export const hopAt = (time: number, stops: readonly Point[], framesPerHop: number, arcRatio: number): Point => {
const hop = Math.floor(time / framesPerHop);
const from = stops[wrap(hop, stops.length)];
const to = stops[wrap(hop + 1, stops.length)];
if (!from || !to) throw new Error("hopAt needs at least one stop");
const t = easeInOut(time / framesPerHop - hop);
const distance = Math.hypot(to.x - from.x, to.y - from.y);
return {
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t - Math.sin(Math.PI * t) * distance * arcRatio,
};
};
+17
View File
@@ -0,0 +1,17 @@
import { appRescue } from "./compositions/AppRescue";
import { automationDocuments } from "./compositions/AutomationDocuments";
import { automationEnquiry } from "./compositions/AutomationEnquiry";
import { automationOrder } from "./compositions/AutomationOrder";
import { productDeveloper } from "./compositions/ProductDeveloper";
import { websiteDone } from "./compositions/WebsiteDone";
import type { VideoEntry } from "./slides/ServiceVideo";
/** Register new videos here. Each one is offered as `<id>-<language>-<format>`. */
export const VIDEOS: readonly VideoEntry[] = [
automationOrder,
automationDocuments,
automationEnquiry,
websiteDone,
appRescue,
productDeveloper,
];
+38
View File
@@ -0,0 +1,38 @@
import type { FC } from "react";
import { BrandFrame } from "../brand/BrandFrame";
import { CopyProvider } from "../content/CopyContext";
import { COPY, type Lang, type VideoId } from "../content/videos";
import { SlideDeck, deckDuration, type Slide } from "./SlideDeck";
/**
* A video about one service, and the id under which its texts are kept. It is either a deck of
* slides or a film: one continuous shot that brings its own stage.
*/
export type VideoEntry =
| { readonly id: VideoId; readonly slides: readonly Slide[] }
| {
readonly id: VideoId;
readonly Film: FC;
/** The picture of the film alone, in stage coordinates. */
readonly Drawing: FC;
readonly durationInFrames: number;
};
export const videoDuration = (video: VideoEntry): number =>
"Film" in video ? video.durationInFrames : deckDuration(video.slides);
/** One service video in one language: brand frame, looping slide deck, texts from the copy file. */
export const ServiceVideo: FC<{ video: VideoEntry; lang: Lang }> = ({ video, lang }) => {
const copy = COPY[video.id][lang];
return (
<CopyProvider value={{ lang, copy }}>
{"Film" in video ? (
<video.Film />
) : (
<BrandFrame>
<SlideDeck slides={video.slides} />
</BrandFrame>
)}
</CopyProvider>
);
};
+306
View File
@@ -0,0 +1,306 @@
import type { CSSProperties, FC, ReactNode } from "react";
import { Easing, Freeze, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
import { BitWipe } from "../bits/BitWipe";
import { FRAME } from "../brand/BrandFrame";
import { FONT } from "../brand/fonts";
import { Panel } from "../brand/Panel";
import { COLOR, DRAW } from "../brand/tokens";
import { useCopy } from "../content/CopyContext";
import { bitString } from "../lib/bits";
import { curtainEdge, curtainPolygon } from "../lib/curtain";
/** Illustrations are drawn in a box this wide; it is scaled down to fit into the panel. */
export const STAGE_WIDTH = 872;
const DEFAULT_ILLUSTRATION_HEIGHT = 520;
const ILLUSTRATION_SCALE = 0.9;
const PANEL_HEIGHT = 600;
/** Height the panel shrinks to on a slide with extra content below the headline (the button). */
const PANEL_HEIGHT_WITH_AFTER = 454;
const GAP = 60;
const TEXT_HEIGHT = 200;
const TALL_STAGE_HEIGHT = PANEL_HEIGHT + GAP + TEXT_HEIGHT;
const WIDE_TEXT_WIDTH = 620;
const WIDE_GAP = 110;
const WIDE_STAGE = { width: STAGE_WIDTH + WIDE_GAP + WIDE_TEXT_WIDTH, height: PANEL_HEIGHT };
const ENTER_FRAMES = 10;
const EXIT_FRAMES = 8;
/** Length of the digit curtain that changes the picture. */
const WIPE_FRAMES = 16;
const CURTAIN_CELL = 24;
const CURTAIN_TRAIL = 9;
const CURTAIN_LEAD = 2;
const MAX_HEADLINE_SIZE = 100;
/** Average width of a character of the headline font, as a share of the font size. */
const CHAR_WIDTH = 0.53;
export type Slide = {
/** Also the key of the slide's headline in the video's copy. */
readonly id: string;
readonly durationInFrames: number;
/** Draws into a box of STAGE_WIDTH x illustrationHeight. Its frame 0 is the slide's first frame. */
readonly Illustration: FC;
readonly illustrationHeight?: number;
/** Extra content below the headline, e.g. the button on the last slide. */
readonly after?: ReactNode;
};
type Box = { readonly width: number; readonly height: number };
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
const easeOut = Easing.out(Easing.cubic);
const easeInOut = Easing.inOut(Easing.cubic);
/** How far the curtain is, `since` frames into a wipe. Complete on the wipe's last frame. */
const wipeProgress = (since: number): number =>
easeInOut(interpolate(since, [-1, WIPE_FRAMES - 1], [0, 1], clampOpts));
/** Largest headline size at which the longest line still fits the given width. */
export const headlineSize = (lines: readonly string[], width: number): number => {
const longest = Math.max(1, ...lines.map((line) => line.length));
return Math.min(MAX_HEADLINE_SIZE, Math.floor(width / (longest * CHAR_WIDTH)));
};
/**
* The drawing of one slide, centred in the panel. The curtain reveals it from the left at its
* start (`wipeIn`) and takes it away from the left from frame `wipeOutAt` on.
*/
const Picture: FC<{ slide: Slide; box: Box; wipeIn: boolean; wipeOutAt: number | null }> = ({
slide,
box,
wipeIn,
wipeOutAt,
}) => {
const frame = useCurrentFrame();
const { Illustration } = slide;
const curtain = { ...box, cell: CURTAIN_CELL };
const clipPath =
wipeOutAt !== null && frame >= wipeOutAt
? curtainPolygon("ahead", wipeProgress(frame - wipeOutAt), curtain)
: wipeIn && frame < WIPE_FRAMES
? curtainPolygon("behind", wipeProgress(frame), curtain)
: undefined;
return (
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", clipPath }}>
<div
style={{
width: STAGE_WIDTH,
height: slide.illustrationHeight ?? DEFAULT_ILLUSTRATION_HEIGHT,
flexShrink: 0,
transform: `scale(${ILLUSTRATION_SCALE})`,
}}
>
<Illustration />
</div>
</div>
);
};
/** The moving edge of the curtain: bright digits at the front, a fading trail behind it. */
const CurtainFront: FC<{ box: Box }> = ({ box }) => {
const frame = useCurrentFrame();
const progress = wipeProgress(frame);
const rows = Math.ceil(box.height / CURTAIN_CELL);
return (
<svg width={box.width} height={box.height} style={{ position: "absolute", inset: 0 }}>
{Array.from({ length: rows }, (_, row) => {
const x = curtainEdge(row, progress) * box.width;
if (x <= 0 || x >= box.width) return null;
const digits = bitString(CURTAIN_TRAIL + CURTAIN_LEAD, frame * 7 + row * 13);
return Array.from(digits, (digit, index) => {
// k counts cells from the edge back into what is already revealed; negative is ahead of it.
const k = index - CURTAIN_LEAD;
return (
<text
key={`${row}-${index}`}
x={x - (k + 0.5) * CURTAIN_CELL}
y={row * CURTAIN_CELL + CURTAIN_CELL / 2}
textAnchor="middle"
dominantBaseline="middle"
fontFamily={FONT.mono}
fontWeight={700}
fontSize={18}
fill={k < 0 ? DRAW.label : k < 2 ? DRAW.mark : DRAW.fg}
opacity={k < 0 ? 0.5 : (1 - k / CURTAIN_TRAIL) * 0.9}
>
{digit}
</text>
);
});
})}
</svg>
);
};
/** All pictures in turn, changed by the curtain, and the first one again at the very end. */
const Pictures: FC<{ slides: readonly Slide[]; starts: readonly number[]; total: number; box: Box }> = ({
slides,
starts,
total,
box,
}) => {
const first = slides[0];
const last = slides.length - 1;
return (
<>
{slides.map((slide, index) => {
// Every picture keeps playing while the next one is wiped in; the last one is wiped out within its time.
const wipeOutAt = index === last ? slide.durationInFrames - WIPE_FRAMES : slide.durationInFrames;
return (
<Sequence
key={slide.id}
from={starts[index]}
durationInFrames={index === last ? slide.durationInFrames : slide.durationInFrames + WIPE_FRAMES}
layout="none"
>
<Picture slide={slide} box={box} wipeIn={index > 0} wipeOutAt={wipeOutAt} />
</Sequence>
);
})}
{first && (
<Sequence from={total - WIPE_FRAMES} durationInFrames={WIPE_FRAMES} layout="none">
<LoopPicture slide={first} box={box} />
</Sequence>
)}
{[...starts.slice(1), total - WIPE_FRAMES].map((from) => (
<Sequence key={from} from={from} durationInFrames={WIPE_FRAMES} layout="none">
<CurtainFront box={box} />
</Sequence>
))}
</>
);
};
/** The first picture as it looks on frame 0, wiped back in at the end of the video. */
const LoopPicture: FC<{ slide: Slide; box: Box }> = ({ slide, box }) => {
const frame = useCurrentFrame();
return (
<div
style={{
position: "absolute",
inset: 0,
clipPath: curtainPolygon("behind", wipeProgress(frame), { ...box, cell: CURTAIN_CELL }),
}}
>
<Freeze frame={0}>
<Picture slide={slide} box={box} wipeIn={false} wipeOutAt={null} />
</Freeze>
</div>
);
};
/** The headline of one slide, and what follows it. `settled` shows it complete from its first frame. */
const Words: FC<{ slide: Slide; settled: boolean; width: number }> = ({ slide, settled, width }) => {
const frame = useCurrentFrame();
const enter = settled ? 1 : interpolate(frame, [0, ENTER_FRAMES], [0, 1], clampOpts);
const exit = interpolate(
frame,
[slide.durationInFrames - EXIT_FRAMES, slide.durationInFrames - 1],
[1, 0],
clampOpts,
);
const lines = useCopy().slides[slide.id] ?? [];
const style: CSSProperties = {
fontFamily: FONT.sans,
fontWeight: 900,
fontSize: headlineSize(lines, width),
lineHeight: 0.98,
letterSpacing: "-0.04em",
wordSpacing: "0.08em",
};
return (
<div style={{ position: "absolute", inset: 0, opacity: Math.min(enter, exit) }}>
{lines.map((line, index) => (
<div key={line} style={{ ...style, color: index > 0 && index === lines.length - 1 ? COLOR.faint : COLOR.ink }}>
<BitWipe from={settled ? -1000 : 3 + index * 4} durationInFrames={16} digitSize={18}>
{line}
</BitWipe>
</div>
))}
{slide.after}
</div>
);
};
/** Fades its children in over the exit of the last slide, so the last frame equals the first. */
const LoopBack: FC<{ children: ReactNode }> = ({ children }) => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, EXIT_FRAMES - 1], [0, 1], clampOpts);
return <div style={{ position: "absolute", inset: 0, opacity }}>{children}</div>;
};
/**
* A looping sequence of slides: big picture, few words. The pictures share one dark panel and are
* changed by a curtain of digits; the headlines sit below the panel (portrait and square) or beside
* it (16:9). The first slide is complete on frame 0, which is the preview image, and returns at the end.
*/
export const SlideDeck: FC<{ slides: readonly Slide[] }> = ({ slides }) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const wide = width > height;
const stage = wide ? WIDE_STAGE : { width: STAGE_WIDTH, height: TALL_STAGE_HEIGHT };
// As large as the frame allows: the drawing is what matters.
const scale = Math.min(
(height - FRAME.top - FRAME.bottom) / stage.height,
(width - FRAME.side * 2) / stage.width,
);
const starts = slides.map((_, index) =>
slides.slice(0, index).reduce((sum, slide) => sum + slide.durationInFrames, 0),
);
const total = deckDuration(slides);
const first = slides[0];
// In portrait the panel makes room for a slide that has more below its headline.
const shrink = wide
? 0
: Math.max(
0,
...slides.map((slide, index) => {
if (!slide.after) return 0;
const start = starts[index] ?? 0;
const end = start + slide.durationInFrames;
return Math.min(
interpolate(frame, [start, start + ENTER_FRAMES], [0, 1], clampOpts),
interpolate(frame, [end - WIPE_FRAMES, end - 1], [1, 0], clampOpts),
);
}),
);
const panel = { width: STAGE_WIDTH, height: PANEL_HEIGHT - (PANEL_HEIGHT - PANEL_HEIGHT_WITH_AFTER) * easeOut(shrink) };
const textWidth = wide ? WIDE_TEXT_WIDTH : STAGE_WIDTH;
return (
<div style={{ position: "relative", ...stage, flexShrink: 0, transform: `scale(${scale})` }}>
<div style={{ position: "absolute", top: 0, left: 0 }}>
<Panel {...panel}>
<Pictures slides={slides} starts={starts} total={total} box={panel} />
</Panel>
</div>
<div
style={
wide
? { position: "absolute", top: 200, left: STAGE_WIDTH + WIDE_GAP, width: WIDE_TEXT_WIDTH, height: TEXT_HEIGHT }
: { position: "absolute", top: panel.height + GAP, left: 0, width: STAGE_WIDTH, height: TEXT_HEIGHT }
}
>
{slides.map((slide, index) => (
<Sequence key={slide.id} from={starts[index]} durationInFrames={slide.durationInFrames} layout="none">
<Words slide={slide} settled={index === 0} width={textWidth} />
</Sequence>
))}
{first && (
<Sequence from={total - EXIT_FRAMES} durationInFrames={EXIT_FRAMES} layout="none">
<LoopBack>
<Freeze frame={0}>
<Words slide={first} settled width={textWidth} />
</Freeze>
</LoopBack>
</Sequence>
)}
</div>
</div>
);
};
export const deckDuration = (slides: readonly Slide[]): number =>
slides.reduce((sum, slide) => sum + slide.durationInFrames, 0);
+19
View File
@@ -0,0 +1,19 @@
import type { FC } from "react";
import { BrandButton } from "../brand/BrandButton";
import { CTA_SCENE_HEIGHT } from "../illustrations/scenes";
import type { Slide } from "./SlideDeck";
const CTA_FRAMES = 75;
/** The closing slide of every video: a smaller picture, the last statement and the website's button. */
export const ctaSlide = (Illustration: FC): Slide => ({
id: "cta",
durationInFrames: CTA_FRAMES,
Illustration,
illustrationHeight: CTA_SCENE_HEIGHT,
after: (
<div style={{ marginTop: 48 }}>
<BrandButton label="Get in touch" size={28} />
</div>
),
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"noEmit": true,
"types": [
"node"
],
"allowImportingTsExtensions": true
},
"include": [
"src",
"scripts",
"remotion.config.ts"
]
}
@@ -1,23 +0,0 @@
import { ImageResponse } from "next/og";
import { OGImageTemplate } from "../../../src/components/OGImageTemplate";
import { getOgFonts, OG_IMAGE_SIZE } from "../../../src/lib/og-helper";
export const size = OG_IMAGE_SIZE;
export const contentType = "image/png";
export const runtime = "nodejs";
export default async function Image() {
const fonts = await getOgFonts();
return new ImageResponse(
<OGImageTemplate
title="Über mich."
description="15 Jahre Erfahrung. Ein Ziel: Websites, die ihre Versprechen halten."
label="Marc Mintel"
/>,
{
...OG_IMAGE_SIZE,
fonts: fonts as any,
},
);
}
-230
View File
@@ -1,230 +0,0 @@
"use client";
import { Reveal } from "@/src/components/Reveal";
import { Section } from "@/src/components/Section";
import { Signature } from "@/src/components/Signature";
import {
H1,
H3,
LeadText,
BodyText,
Label,
MonoLabel,
} from "@/src/components/Typography";
import { Button } from "@/src/components/Button";
import { AbstractCircuit, GradientMesh } from "@/src/components/Effects";
import { GlitchText } from "@/src/components/GlitchText";
export default function AboutPage() {
return (
<div className="flex flex-col bg-white overflow-hidden relative">
<AbstractCircuit />
{/* Hero Section */}
<section className="relative pt-24 md:pt-40 pb-12 md:pb-24 overflow-hidden border-b border-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full relative z-10">
<div className="flex flex-col items-center text-center space-y-12">
<Reveal width="fit-content">
<div className="w-40 h-40 md:w-56 md:h-56 rounded-full overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
<img
src="/marc-mintel.png"
alt="Marc Mintel"
className="object-cover w-full h-full"
/>
</div>
</Reveal>
<div className="space-y-6 max-w-4xl">
<Reveal delay={0.1}>
<MonoLabel className="text-slate-400 tracking-[0.2em] text-xs">
WEBENTWICKLER
</MonoLabel>
</Reveal>
<Reveal delay={0.2}>
<H1 className="text-6xl md:text-8xl lg:text-9xl leading-none tracking-tighter">
Über <span className="text-slate-400">mich.</span>
</H1>
</Reveal>
<Reveal delay={0.3}>
<p className="text-slate-500 max-w-2xl mx-auto text-xl md:text-3xl leading-snug">
15 Jahre Erfahrung. Ein Ziel: Ihnen den Frust mit Websites zu
ersparen.
</p>
</Reveal>
</div>
</div>
</div>
</section>
{/* Section 01: Story */}
<Section number="01" title="Erfahrung" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
Aus der Praxis. <br />
<span className="text-slate-400">Für die Praxis.</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
<Reveal delay={0.1}>
<LeadText className="text-2xl md:text-4xl text-slate-900 leading-snug">
Ich habe in Agenturen, Konzernen und Startups gearbeitet. Dabei
habe ich gesehen, woran Projekte scheitern: zu viele Leute, zu
viel Blabla.
</LeadText>
</Reveal>
<Reveal delay={0.2}>
<div className="space-y-12">
<BodyText className="text-slate-500 text-lg md:text-xl">
Das mache ich anders. Keine Hierarchien, kein Fachchinesisch,
sondern klare Kommunikation. Fokus auf Ergebnisse statt auf
endlose Meetings.
</BodyText>
<div className="space-y-6 border-l border-slate-100 pl-8">
<div>
<Label className="text-slate-900 text-lg">Direkt</Label>
<BodyText className="text-slate-500">
Sie sprechen immer mit mir.
</BodyText>
</div>
<div>
<Label className="text-slate-900 text-lg">
Unkompliziert
</Label>
<BodyText className="text-slate-500">
Wir beschränken uns auf das Wesentliche.
</BodyText>
</div>
</div>
</div>
</Reveal>
</div>
</div>
</Section>
{/* Section 02: Ablauf */}
<Section number="02" title="Ablauf" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
So einfach läuft <br />
<span className="text-slate-400">ein Projekt ab.</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 md:gap-12">
{[
{
step: "01",
title: "Kurzes Gespräch",
desc: "Kein Workshop, nur die Fakten.",
},
{
step: "02",
title: "Klares Angebot",
desc: "Fixpreis ohne Sternchen.",
},
{
step: "03",
title: "Umsetzung",
desc: "Ich baue, Sie entspannen.",
},
{
step: "04",
title: "Go-Live & Support",
desc: "Ich übernehme die Wartung.",
},
].map((item, i) => (
<Reveal key={i} delay={0.1 * i}>
<div className="space-y-6 pt-6 border-t border-slate-900">
<Label className="text-slate-400 font-mono text-sm">
{item.step}
</Label>
<div className="space-y-2">
<Label className="text-slate-900 text-xl">
{item.title}
</Label>
<BodyText className="text-slate-500 text-lg">
{item.desc}
</BodyText>
</div>
</div>
</Reveal>
))}
</div>
</div>
</Section>
{/* Section 03: Garantie */}
<Section number="03" title="Garantie" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
Ich stehe für <br />
<span className="text-slate-400">meine Arbeit gerade.</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24 items-end">
<Reveal delay={0.1}>
<div className="space-y-8">
<LeadText className="text-2xl md:text-4xl text-slate-900 leading-snug">
Wenn etwas nicht passt, liegt die Verantwortung allein bei
mir.
</LeadText>
<BodyText className="text-slate-500 text-lg md:text-xl">
Sie beauftragen mich, damit Sie Ruhe haben. Fixpreis bedeutet
Fixpreis. Wir gehen erst live, wenn Sie zu 100% zufrieden
sind.
</BodyText>
</div>
</Reveal>
<Reveal delay={0.2}>
<div className="pb-8">
<Signature />
</div>
</Reveal>
</div>
</div>
</Section>
{/* Section 04: CTA */}
<Section
number="04"
title="Kontakt"
borderTop
effects={<GradientMesh variant="metallic" className="opacity-60" />}
>
<div className="py-12 md:py-24 relative z-10">
<div className="space-y-16 md:space-y-24">
<Reveal>
<H3 className="text-5xl md:text-8xl lg:text-9xl leading-none tracking-tighter">
Bereit, das Thema <br />
<span className="text-slate-400">
<GlitchText delay={0.5} duration={1}>
abzuhaken?
</GlitchText>
</span>
</H3>
</Reveal>
<Reveal delay={0.2}>
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-12">
<LeadText className="text-xl md:text-3xl text-slate-500 max-w-xl">
Schreiben Sie mir einfach – wir finden die beste Lösung für
Ihre Website.
</LeadText>
<Button
href="/contact"
className="w-full md:w-auto px-12 py-6 text-xl"
>
Sagen Sie Hallo
</Button>
</div>
</Reveal>
</div>
</div>
</Section>
</div>
);
}
@@ -1,70 +0,0 @@
import { ImageResponse } from "next/og";
import { getAllPosts } from "../../../../src/lib/posts";
import { blogThumbnails } from "../../../../src/components/blog/blogThumbnails";
import { BlogOGImageTemplate } from "../../../../src/components/BlogOGImageTemplate";
import { getOgFonts, OG_IMAGE_SIZE } from "../../../../src/lib/og-helper";
import * as fs from "node:fs/promises";
import * as path from "node:path";
export const size = OG_IMAGE_SIZE;
export const contentType = "image/png";
export const runtime = "nodejs";
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const allPosts = await getAllPosts();
const post = allPosts.find((p) => p.slug === slug);
let backgroundImageSrc: string | undefined = undefined;
// If we have a custom generated thumbnail, convert it to a data URI for Satori
if (post?.thumbnail) {
try {
const filePath = path.join(process.cwd(), "public", post.thumbnail);
const fileBuffer = await fs.readFile(filePath);
const ext = path.extname(post.thumbnail).substring(1).toLowerCase();
const mimeType =
ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
backgroundImageSrc = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
} catch (err) {
console.warn(
`[OG Image Generator] Could not read thumbnail file for ${slug} to use as background:`,
err,
);
// Fall through to standard plain background
}
}
const thumbnail = blogThumbnails[slug];
const title = post?.title || "Marc Mintel";
const description =
post?.description ||
"Technical problem solver's blog - practical insights and learning notes";
const label = post ? "Blog Post" : "Engineering";
const accentColor = thumbnail?.accent;
const keyword = thumbnail?.keyword;
const fonts = await getOgFonts();
return new ImageResponse(
<BlogOGImageTemplate
title={title}
description={description}
label={label}
accentColor={accentColor}
keyword={keyword}
backgroundImageSrc={backgroundImageSrc}
/>,
{
...OG_IMAGE_SIZE,
fonts: fonts as any,
},
);
}
-119
View File
@@ -1,119 +0,0 @@
import * as React from "react";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { getAllPosts } from "@/src/lib/posts";
import { BlogPostHeader } from "@/src/components/blog/BlogPostHeader";
import { Section } from "@/src/components/Section";
import { Reveal } from "@/src/components/Reveal";
import { BlogPostClient } from "@/src/components/BlogPostClient";
import { TextSelectionShare } from "@/src/components/TextSelectionShare";
import { BlogPostStickyBar } from "@/src/components/blog/BlogPostStickyBar";
import { MDXContent } from "@/src/components/MDXContent";
import { TableOfContents } from "@/src/components/TableOfContents";
export async function generateStaticParams() {
const allPosts = await getAllPosts();
return allPosts.map((post) => ({
slug: post.slug,
}));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const allPosts = await getAllPosts();
const post = allPosts.find((p) => p.slug === slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: "article",
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.description,
},
};
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const allPosts = await getAllPosts();
const post = allPosts.find((p) => p.slug === slug);
if (!post) {
notFound();
}
const formattedDate = new Date(post.date).toLocaleDateString("de-DE", {
month: "long",
day: "numeric",
year: "numeric",
});
const wordCount = post.description.split(/\s+/).length + 300;
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
return (
<div className="flex flex-col gap-8 md:gap-12 py-8 md:py-24 overflow-hidden">
<BlogPostClient readingTime={readingTime} title={post.title} />
<BlogPostHeader
title={post.title}
description={post.description}
date={formattedDate}
readingTime={readingTime}
slug={slug}
thumbnail={post.thumbnail}
/>
<main id="post-content">
<BlogPostStickyBar
title={post.title}
url={`https://mintel.me/blog/${slug}`}
/>
<Section containerVariant="wide" className="pt-0 md:pt-0">
<div className="max-w-4xl mx-auto px-5 md:px-0">
<Reveal delay={0.4} width="100%">
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-10 md:mb-12">
{post.tags?.map((tag) => (
<span
key={tag}
className="px-2.5 py-1 bg-slate-50 border border-slate-100 rounded text-[9px] md:text-[10px] font-mono text-slate-500 uppercase tracking-widest"
>
#{tag}
</span>
))}
</div>
)}
<div className="article-content max-w-none">
<TableOfContents />
<MDXContent code={post.mdxContent || ""} />
</div>
</Reveal>
</div>
</Section>
</main>
<TextSelectionShare />
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
import { getAllPosts } from "@/src/lib/posts";
import { BlogClient } from "@/src/components/blog/BlogClient";
import type { Metadata } from "next";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Blog | Mintel.me",
description:
"Gedanken über Engineering, Design und die Architektur der Zukunft.",
};
export default async function BlogPage() {
const posts = await getAllPosts();
return <BlogClient allPosts={posts as any} />;
}
@@ -1,640 +0,0 @@
"use client";
import React from "react";
import { motion, useScroll, useTransform } from "framer-motion";
import { Section } from "@/src/components/Section";
import { Reveal } from "@/src/components/Reveal";
import {
H1,
H2,
H3,
LeadText,
Label,
MonoLabel,
BodyText,
} from "@/src/components/Typography";
import { BackgroundGrid, Container } from "@/src/components/Layout";
import Link from "next/link";
import { Button } from "@/src/components/Button";
import { IframeSection } from "@/src/components/IframeSection";
import {
Activity,
ArrowRight,
ArrowLeft,
ShieldCheck,
Cpu,
Server,
Layers,
} from "lucide-react";
import { Marker } from "@/src/components/Marker";
import { GlitchText } from "@/src/components/GlitchText";
export default function KLZCablesCaseStudy() {
const { scrollYProgress } = useScroll();
const heroY = useTransform(scrollYProgress, [0, 0.2], [0, -20]);
const heroOpacity = useTransform(scrollYProgress, [0, 0.15], [1, 0]);
const gridRotate = useTransform(scrollYProgress, [0, 1], [0, 2]);
return (
<div className="flex flex-col bg-white relative min-h-screen selection:bg-slate-900 selection:text-white overflow-hidden">
<motion.div
style={{ opacity: heroOpacity }}
className="fixed inset-0 z-0 pointer-events-none"
>
<BackgroundGrid />
</motion.div>
{/* --- HERO: INDUSTRIAL INFRASTRUCTURE --- */}
<section className="relative min-h-[30vh] md:min-h-[40vh] py-10 md:py-20 overflow-hidden border-b border-slate-100 bg-white">
<motion.div
style={{ y: heroY, rotate: gridRotate }}
className="absolute inset-0 bg-[linear-gradient(to_right,#f1f5f9_1px,transparent_1px),linear-gradient(to_bottom,#f1f5f9_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_50%,#000_20%,transparent_100%)] pointer-events-none opacity-40"
/>
<Container variant="narrow" className="relative z-10">
<Reveal>
<Link
href="/case-studies"
className="inline-flex items-center gap-2 text-slate-400 hover:text-slate-900 mb-8 md:mb-12 transition-colors font-bold text-[10px] uppercase tracking-[0.4em] group"
>
<ArrowLeft className="w-3 h-3 group-hover:-translate-x-1 transition-transform" />{" "}
Zurück
</Link>
</Reveal>
<div className="space-y-12">
<Reveal direction="down" blur>
<div className="inline-flex items-center gap-6">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: 48 }}
transition={{ duration: 1, ease: "circOut" }}
className="h-px bg-slate-900"
/>
<div className="space-y-1">
<MonoLabel className="text-slate-900 tracking-[0.4em]">
SYSTEM-ARCHITEKTUR // 2025
</MonoLabel>
<Label className="text-[10px] text-slate-400 font-mono">
HARDENED WORDPRESS // VARNISH STACK
</Label>
</div>
</div>
</Reveal>
<div className="space-y-8 md:space-y-12">
<GlitchText
as="h1"
className="text-4xl md:text-8xl tracking-tighter leading-[0.9] font-bold text-slate-900"
>
KLZ Cables
</GlitchText>
<br className="hidden md:block" />
<span className="text-slate-100 text-3xl md:text-6xl font-bold tracking-tighter">
Case Study.
</span>
<Reveal delay={0.2} direction="right" blur>
<div className="max-w-3xl border-l-[3px] border-slate-900 pl-6 md:pl-12">
<LeadText className="text-lg md:text-4xl leading-tight text-slate-900 font-medium">
Engineering eines <br className="hidden md:block" />
<Marker delay={0.2}>Systems.</Marker>
</LeadText>
<BodyText className="mt-4 md:mt-6 text-base md:text-xl text-slate-500 max-w-xl leading-relaxed font-serif italic">
Vom statischen Altsystem zum industriellen Standard. Ich
habe das KLZ-System auf das Wesentliche reduziert: Hardened
Infrastructure, parametrische Datenpflege und zero
maintenance.
</BodyText>
</div>
</Reveal>
</div>
<Reveal delay={0.4} direction="up" scale={0.98} blur>
<div className="flex flex-wrap gap-8 md:gap-24 pt-6 md:pt-12 border-t border-slate-100">
<div className="space-y-1 md:space-y-2">
<Label className="text-slate-400 text-[10px] md:text-xs">
Data Integrity
</Label>
<div className="flex items-center gap-2 md:gap-3">
<div className="w-2 h-2 md:w-2.5 md:h-2.5 bg-[rgba(129,199,132,1)] rounded-full animate-pulse" />
<span className="text-base md:text-2xl font-bold font-mono text-slate-900 tracking-tight">
Relational Data
</span>
</div>
</div>
<div className="space-y-1 md:space-y-2">
<Label className="text-slate-400 text-[10px] md:text-xs">
Security Layer
</Label>
<div className="flex items-center gap-2 md:gap-3 text-base md:text-2xl font-bold font-mono text-slate-900">
<ShieldCheck className="w-5 h-5 md:w-6 md:h-6 text-[rgba(129,199,132,1)]" />
<span>WP + Varnish</span>
</div>
</div>
</div>
</Reveal>
</div>
</Container>
</section>
{/* --- SECTION 01: ARCHITECTURE --- */}
<Section
number="01"
title="System-Hardening & Logic"
borderBottom
containerVariant="normal"
>
{/* Binary overlay background */}
<div className="absolute top-0 right-0 p-8 opacity-[0.03] select-none pointer-events-none font-mono text-[10px] hidden md:block">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i}>01001101 01001001 01001110 01010100</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 lg:gap-24 items-start">
<div className="md:col-span-12 mb-4 md:mb-12">
<H2 className="text-3xl md:text-8xl tracking-tighter mb-4 md:mb-12">
<GlitchText>Architektur-</GlitchText> <br />
Refactor.
</H2>
</div>
<div className="md:col-span-7 space-y-8 md:space-y-12">
<Reveal delay={0.1} direction="up" blur>
<div className="space-y-6 md:space-y-10">
<BodyText className="text-xl md:text-2xl leading-relaxed font-serif italic text-slate-500">
Vom statischen HTML zur zentralen Daten-Instanz.
</BodyText>
<BodyText className="text-lg md:text-xl text-slate-600 leading-relaxed">
Ich habe die KLZ-Architektur radikal auf einen entkoppelten
High-Performance-Stack umgestellt. WordPress fungiert hier
nicht als CMS-Baukasten, sondern speichert alle technischen
Attribute in einer zentralen relationalen Instanz. Durch die
Implementierung nativer PHP-Services und den Verzicht auf
volatile Drittanbieter-Plugins wurde ein System geschaffen,
das keine technologischen Überraschungen zulässt. Stability by{" "}
<Marker delay={0.5}>Design.</Marker>
</BodyText>
</div>
</Reveal>
</div>
<div className="md:col-span-5 relative mt-8 md:mt-0">
<Reveal delay={0.3} direction="right" scale={0.98} blur>
<motion.div
whileHover={{ y: -5, scale: 1.01 }}
transition={{ type: "spring", stiffness: 200, damping: 25 }}
className="p-6 md:p-12 bg-slate-50 rounded-[2.5rem] md:rounded-[3rem] border border-slate-100 space-y-6 md:space-y-12 relative overflow-hidden group shadow-sm text-left"
>
<div className="space-y-6 md:space-y-8 relative z-10">
<Label className="text-slate-900">System Metriken</Label>
<div className="space-y-6 md:space-y-8">
{[
{
label: "Edge Caching",
desc: "Varnish + W3TC Object Cache",
icon: <Server className="w-5 h-5 text-slate-400" />,
},
{
label: "Analytics",
desc: "Independent (Global Data Compliance)",
icon: <Activity className="w-5 h-5 text-slate-400" />,
},
{
label: "Custom Core",
desc: "REST via Native Services",
icon: <Cpu className="w-5 h-5 text-slate-400" />,
},
].map((item, i) => (
<Reveal
key={i}
direction="right"
delay={0.5 + i * 0.1}
width="100%"
className="flex gap-4 md:gap-6 border-b border-slate-200/50 pb-6 last:border-0 last:pb-0"
>
<div className="shrink-0 mt-1">{item.icon}</div>
<div className="space-y-1">
<MonoLabel className="text-[9px] md:text-[10px] text-slate-400">
{item.label}
</MonoLabel>
<BodyText className="text-base font-bold text-slate-900">
{item.desc}
</BodyText>
</div>
</Reveal>
))}
</div>
</div>
</motion.div>
</Reveal>
</div>
</div>
</Section>
{/* --- SHOWCASE: LANDING --- */}
<section className="py-12 md:py-32 bg-slate-50 border-y border-slate-100 overflow-hidden relative">
<div className="w-full max-w-[1920px] mx-auto px-4 md:px-12 relative z-10">
<Reveal direction="none" blur>
<div className="relative mb-8 md:mb-16 flex justify-between items-end">
<div className="space-y-6">
<Label className="text-slate-500">
Infrastructure Validation
</Label>
<H3 className="text-3xl md:text-8xl tracking-tighter">
<GlitchText>Global Hub.</GlitchText>
</H3>
</div>
{/* Binary overlay left */}
<div className="absolute left-0 bottom-0 p-8 opacity-[0.03] select-none pointer-events-none font-mono text-[10px] hidden md:block group-hover:opacity-10 transition-opacity duration-1000">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>HANDSHAKE_0x00{i}A // SYNC_ACTIVE</div>
))}
</div>
</div>
</Reveal>
<Reveal delay={0.2} width="100%" direction="up" scale={0.98} blur>
<motion.div
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
>
<IframeSection
src="/showcase/klz-cables.com/index.html"
height="400px"
mobileHeight="350px"
desktopHeight="850px"
desktopWidth={1920}
allowScroll
browserFrame
className="w-full h-full transition-all duration-1000 ease-in-out no-scrollbar"
/>
</motion.div>
</Reveal>
</div>
</section>
{/* --- SECTION 02: TECHNICAL DETAIL --- */}
<Section
number="02"
title="Asset Management"
variant="white"
borderBottom
containerVariant="wide"
>
<div className="grid grid-cols-1 gap-8 md:gap-16">
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-24 items-end">
<div className="md:col-span-8">
<Reveal direction="left" blur>
<div className="space-y-6 md:space-y-12">
<Label className="text-slate-400 text-xs md:text-sm">
Asset Pipelines
</Label>
<H3 className="text-2xl md:text-6xl tracking-tighter">
Automated Documentation.
</H3>
</div>
</Reveal>
</div>
<Reveal delay={0.1} direction="right" blur>
<BodyText className="text-lg md:text-xl text-slate-500 pb-2 font-serif italic leading-relaxed">
Für Hochspannungs-N2XS(F)2Y Kabel ist Datentreue eine
Sicherheitsanforderung. Ich habe eine automatisierte
Asset-Pipeline entwickelt, die technische Datenblätter
serverseitig generiert und validiert.
</BodyText>
</Reveal>
</div>
<Reveal delay={0.2} width="100%" direction="up" scale={0.98} blur>
<motion.div
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="relative w-full group"
>
<div className="relative w-full overflow-visible">
<IframeSection
src="/showcase/klz-cables.com/power-cables-medium-voltage-cables.html"
height="450px"
mobileHeight="400px"
desktopHeight="1000px"
desktopWidth={1920}
allowScroll
offsetY={100}
browserFrame
className="w-full transition-all duration-1000 no-scrollbar"
/>
</div>
</motion.div>
</Reveal>
</div>
</Section>
<Section
number="03"
title="Katalog-Architektur"
borderBottom
containerVariant="wide"
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
<div className="lg:col-span-12 mb-4 md:mb-12 text-center lg:text-left relative z-10">
<H3 className="text-3xl md:text-6xl max-w-4xl tracking-tighter">
Fokus auf <br />
<Marker delay={0.2}>Spezifikationen.</Marker>
</H3>
</div>
<div className="lg:col-span-8 relative group">
<Reveal width="100%" direction="left" scale={0.98} blur>
<motion.div
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="relative h-[400px] md:h-[650px] w-full overflow-hidden group"
>
<IframeSection
src="/showcase/klz-cables.com/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2.html"
height="100%"
desktopWidth={1920}
allowScroll
browserFrame
className="h-full w-full transition-all duration-700 no-scrollbar"
/>
</motion.div>
</Reveal>
</div>
<div className="lg:col-span-4 space-y-10 relative z-10">
<Reveal delay={0.2} direction="right" blur>
<div className="space-y-4 md:space-y-6">
<Label className="text-slate-400">Katalog-Struktur</Label>
<LeadText className="text-base md:text-lg leading-relaxed">
Der Produktbereich wurde konsequent auf die Bedürfnisse
technischer Planer optimiert. Klare Hierarchien und der
Verzicht auf E-Commerce-Rauschen ermöglichen einen direkten
Zugriff auf Kabel-Parameter und Datenblätter.
</LeadText>
<motion.div
whileHover={{ x: 10 }}
className="p-5 md:p-8 bg-white border border-slate-200 rounded-2xl md:rounded-3xl shadow-sm"
>
<Layers className="w-5 h-5 md:w-6 md:h-6 text-slate-400 mb-3 md:mb-4" />
<BodyText className="text-sm font-medium">
Strukturierte Aufbereitung technischer Produktdaten.
</BodyText>
</motion.div>
</div>
</Reveal>
</div>
</div>
</Section>
{/* --- SECTION 04: CONTENT ENGINE --- */}
<Section
number="04"
title="Content Strategy"
variant="white"
borderBottom
containerVariant="wide"
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-center">
<div className="lg:col-span-4 space-y-10 order-2 lg:order-1">
<Reveal direction="left" blur>
<div className="space-y-6">
<Label className="text-slate-400">Knowledge Transfer</Label>
<H3 className="text-3xl md:text-6xl tracking-tighter">
Insights & News.
</H3>
<BodyText className="text-xl text-slate-500 font-serif italic">
Die News-Engine dient als technischer Hub für
Industrie-Standards. Durch die Implementierung eines
performanten Blog-Systems wird Fachwissen direkt an die
Zielgruppe kommuniziert.
</BodyText>
</div>
</Reveal>
</div>
<div className="lg:col-span-8 order-1 lg:order-2">
<Reveal width="100%" direction="right" scale={0.98} blur>
<motion.div
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="relative h-[450px] md:h-[700px] w-full"
>
<IframeSection
src="/showcase/klz-cables.com/blog.html"
height="100%"
desktopWidth={1600}
allowScroll
browserFrame
className="h-full w-full no-scrollbar"
/>
</motion.div>
</Reveal>
</div>
</div>
</Section>
{/* --- SECTION 05: TEAM & TRUST --- */}
<Section
number="05"
title="Ergebnis"
borderBottom
containerVariant="wide"
>
<div className="space-y-10 md:space-y-16 text-center">
<Reveal direction="up" blur>
<H3 className="text-3xl md:text-8xl tracking-tighter">
System-Lifecycle.
</H3>
<LeadText className="mx-auto max-w-2xl pt-2 md:pt-6 text-lg md:text-xl leading-relaxed">
Die Migration von einer statischen Datei-Struktur zu einer
zentralisierten Daten-Instanz eliminiert technische Schulden und
manuelle Fehlerquellen. Das Ergebnis ist eine wartungsfreie
Architektur, die technische Datentreue über den gesamten
Produkt-Lifecycle sicherstellt.
</LeadText>
</Reveal>
<Reveal delay={0.2} width="100%" direction="up" scale={0.98} blur>
<div className="relative group w-full text-left">
<div className="relative block w-full overflow-visible">
<IframeSection
src="/showcase/klz-cables.com/team.html"
height="450px"
mobileHeight="400px"
desktopHeight="1100px"
desktopWidth={1440}
allowScroll
browserFrame
className="w-full h-full no-scrollbar"
/>
</div>
</div>
</Reveal>
</div>
</Section>
{/* --- SECTION 06: CONVERSION --- */}
<Section
number="06"
title="Lead Engineering"
variant="white"
containerVariant="wide"
className="!pb-32"
>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-24 items-center">
<div className="md:col-span-6 md:order-2">
<Reveal direction="left" blur>
<div className="space-y-6">
<Label className="text-slate-400">Interaction Layer</Label>
<H3 className="text-3xl md:text-7xl tracking-tighter text-slate-900 leading-[1.1]">
Direkter Draht.
</H3>
<BodyText className="text-lg md:text-xl text-slate-500 font-serif italic leading-relaxed">
Das Kontakt-System wurde auf maximale Reduktion getrimmt. Ein
deterministischer Kanal zwischen technischem Bedarf und
individueller Beratung – ohne Umwege, ohne Rauschen.
</BodyText>
</div>
</Reveal>
<Reveal delay={0.2} direction="left" blur>
<div className="grid grid-cols-2 gap-8 border-t border-slate-100 pt-10">
<div className="space-y-2">
<MonoLabel className="text-slate-400 text-[9px]">
RESPONSE_TIME
</MonoLabel>
<div className="text-xl font-bold text-slate-900 font-mono">
&lt; 120ms
</div>
</div>
<div className="space-y-2">
<MonoLabel className="text-slate-400 text-[9px]">
PROTOCOL
</MonoLabel>
<div className="text-xl font-bold text-slate-900 font-mono">
mTLS
</div>
</div>
<div className="space-y-2">
<MonoLabel className="text-slate-400 text-[9px]">
AVAILABILITY
</MonoLabel>
<div className="text-xl font-bold text-[rgba(129,199,132,1)] font-mono">
99.9%
</div>
</div>
<div className="space-y-2">
<MonoLabel className="text-slate-400 text-[9px]">
ENCRYPTION
</MonoLabel>
<div className="text-xl font-bold text-slate-900 font-mono">
AES-256
</div>
</div>
</div>
</Reveal>
</div>
<div className="lg:col-span-7">
<Reveal direction="right" scale={0.98} blur>
<motion.div
whileHover={{ scale: 1.002 }}
transition={{ type: "spring", stiffness: 100, damping: 30 }}
className="relative rounded-[2.5rem] overflow-hidden shadow-2xl shadow-slate-200/50 ring-1 ring-slate-100"
>
<IframeSection
src="/showcase/klz-cables.com/contact.html"
height="450px"
mobileHeight="400px"
desktopHeight="750px"
desktopWidth={1200}
mobileWidth={390}
allowScroll
browserFrame
className="w-full no-scrollbar"
/>
</motion.div>
</Reveal>
</div>
</div>
</Section>
{/* --- FINAL CTA: ARCHITECTURE & VALUE --- */}
<section className="py-40 md:py-64 bg-white relative overflow-hidden border-t border-slate-100">
<BackgroundGrid />
<Container variant="normal" className="relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center text-left">
<div className="space-y-8 md:space-y-12">
<Reveal direction="left" blur>
<div className="space-y-3 md:space-y-6">
<MonoLabel className="text-slate-400 tracking-[0.2em] md:tracking-[0.4em]">
CONSULTING // ENGINEERING
</MonoLabel>
<H2 className="text-4xl md:text-8xl tracking-tighter leading-none font-bold">
Architektur <br />
<span className="text-slate-100">ohne Altlasten.</span>
</H2>
</div>
</Reveal>
<Reveal delay={0.2} direction="left" blur>
<BodyText className="text-xl md:text-2xl text-slate-500 max-w-xl font-serif italic leading-relaxed">
Vom Prototyp zum industriellen Standard. Ich entwickle
digitale Infrastrukturen, die technische Freiheit und
operative Stabilität garantieren – wartungsfrei und
skalierbar.
</BodyText>
</Reveal>
</div>
<div className="bg-slate-50/80 backdrop-blur-sm border border-slate-100 p-6 md:p-14 rounded-[3.5rem] space-y-10 md:space-y-12 relative group shadow-sm">
<Reveal direction="right" blur>
<div className="inline-flex items-center gap-2 md:gap-3 px-3 py-1.5 md:px-4 md:py-2 bg-white rounded-full border border-slate-200 mb-4 md:mb-8 font-mono text-[9px] md:text-[10px] tracking-widest text-slate-500 uppercase">
<div className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
Operational Excellence
</div>
<div className="space-y-8 md:space-y-10">
{[
{
title: "Hardened Infrastructure",
desc: "Zentralisierte Datenpflege und entkoppelte WordPress-Instanzen.",
},
{
title: "Automated Data Pipelines",
desc: "Validierung technischer Spezifikationen ohne manuelle Eingriffe.",
},
{
title: "Maintenance-Free Core",
desc: "Plugin-freie Logik für deterministische System-Sicherheit.",
},
].map((item, i) => (
<div
key={i}
className="space-y-2 md:space-y-3 group/item text-left"
>
<MonoLabel className="text-[9px] md:text-[10px] text-slate-400 group-hover/item:text-slate-900 transition-colors duration-500">
{item.title}
</MonoLabel>
<BodyText className="text-base md:text-lg font-bold text-slate-900 leading-tight">
{item.desc}
</BodyText>
</div>
))}
</div>
</Reveal>
<Reveal delay={0.5} direction="up" blur className="pt-6">
<Button
href="/contact"
variant="outline"
showArrow={false}
className="w-full py-6 md:py-8 text-base md:text-lg group border-2 border-slate-900 rounded-full bg-white hover:bg-slate-900 hover:text-white transition-all duration-700"
>
Jetzt anfragen
<ArrowRight className="inline-block ml-3 md:ml-4 w-5 h-5 md:w-6 md:h-6 group-hover:translate-x-4 transition-transform duration-700" />
</Button>
</Reveal>
</div>
</div>
</Container>
</section>
</div>
);
}
-178
View File
@@ -1,178 +0,0 @@
"use client";
import Image from "next/image";
import { Section } from "@/src/components/Section";
import { Reveal } from "@/src/components/Reveal";
import { H3, LeadText, Label, BodyText } from "@/src/components/Typography";
import { Card } from "@/src/components/Layout";
import { Button } from "@/src/components/Button";
import { AbstractCircuit } from "@/src/components/Effects";
import { ArrowRight } from "lucide-react";
import { motion } from "framer-motion";
export default function CaseStudiesPage() {
return (
<div className="flex flex-col bg-white overflow-hidden relative">
<AbstractCircuit />
{/* Featured Case Study Hero */}
<Section className="pt-24 pb-12 md:pt-40 md:pb-24">
<div className="space-y-12 md:space-y-24">
<Reveal>
<div className="space-y-6 max-w-4xl">
<H3 className="text-4xl md:text-8xl tracking-tighter leading-none">
Case <span className="text-slate-400">Studies.</span>
</H3>
<LeadText className="text-lg md:text-2xl text-slate-400 max-w-2xl">
Ergebnisse statt Versprechen. Dokumentierte Architektur-Lösungen
für komplexe Anforderungen.
</LeadText>
</div>
</Reveal>
<Reveal>
<a href="/case-studies/klz-cables" className="block group">
<Card
variant="glass"
padding="none"
techBorder
className="overflow-hidden relative group min-h-[400px] md:min-h-[500px] flex flex-col md:flex-row"
>
{/* Brand Gradient Background */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_30%,rgba(14,165,233,0.08)_0%,transparent_50%),radial-gradient(circle_at_70%_70%,rgba(99,102,241,0.05)_0%,transparent_50%)]" />
{/* Left Column: Content */}
<div className="flex-1 p-4 md:p-12 relative z-10 flex flex-col justify-between">
<div className="space-y-4 md:space-y-8">
<div className="flex items-center gap-3 md:gap-4">
<Image
src="/showcase/klz-cables.com/assets/klz-cables.com/wp-content/uploads/2024/11/white_logo_transparent_background.svg"
alt="KLZ Logo"
width={32}
height={32}
className="h-6 md:h-8 w-auto invert opacity-80 group-hover:opacity-100 transition-opacity duration-500"
/>
<div className="h-px w-8 md:w-12 bg-slate-100" />
<Label className="text-slate-400 text-[9px] md:text-[10px]">
Case Study 2025
</Label>
</div>
<div className="space-y-3 md:space-y-4">
<H3 className="text-3xl md:text-6xl tracking-tighter">
KLZ <span className="text-slate-300">Cables</span>
</H3>
<LeadText className="text-slate-500 text-base md:text-xl max-w-xl leading-relaxed">
Engineering eines industriellen B2B-Systems mit
<span className="text-slate-900 font-medium">
{" "}
automatisierter Asset-Pipeline
</span>{" "}
und hochperformantem Headless-Stack.
</LeadText>
</div>
<div className="flex flex-wrap gap-2 pt-1 md:pt-2">
{["Next.js", "Varnish", "Asset Pipeline", "B2B DB"].map(
(tag, i) => (
<span
key={i}
className="px-2 py-0.5 md:px-2.5 md:py-1 border border-slate-100 bg-white/50 rounded-md text-[8px] md:text-[9px] font-mono text-slate-400 uppercase tracking-widest group-hover:border-slate-300 transition-colors duration-500"
>
{tag}
</span>
),
)}
</div>
</div>
<div className="pt-8 md:pt-12">
<div className="inline-flex items-center gap-2 md:gap-3 text-[10px] md:text-sm font-bold text-slate-400 group-hover:text-slate-900 transition-all duration-500">
<span>EXPLORE PROJECT</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-2 transition-transform duration-500" />
</div>
</div>
</div>
{/* Right Column: Visual/Technical Decor */}
<div className="w-full md:w-1/3 min-h-[150px] md:min-h-0 bg-slate-50 relative overflow-hidden border-t md:border-t-0 md:border-l border-slate-100">
<div className="absolute inset-0 opacity-[0.03] select-none pointer-events-none font-mono text-[6px] md:text-[8px] p-4 flex flex-col gap-1 overflow-hidden">
{Array.from({ length: 40 }).map((_, i) => (
<div key={i} className="whitespace-nowrap">
{Array.from({ length: 15 })
.map((_, j) => (
<span
key={j}
className={
Math.random() > 0.5
? "text-slate-900"
: "text-slate-400"
}
>
{Math.floor(Math.random() * 2)}
</span>
))
.join(" ")}
</div>
))}
</div>
{/* Abstract "Cable" lines */}
<div className="absolute inset-0 flex items-center justify-center p-8 md:p-12">
<div className="w-full h-full relative">
{[1, 2, 3].map((v) => (
<motion.div
key={v}
initial={{ scaleY: 0 }}
whileInView={{ scaleY: 1 }}
transition={{ duration: 2, delay: 0.5 + v * 0.2 }}
className="absolute inset-y-0 border-r border-slate-200 origin-top"
style={{ right: `${v * 25}%` }}
/>
))}
</div>
</div>
<div className="absolute bottom-4 right-4 md:bottom-8 md:right-8 text-[8px] md:text-[10px] font-mono text-slate-300 rotate-90 origin-right uppercase tracking-[0.3em]">
Industrial Grade
</div>
</div>
</Card>
</a>
</Reveal>
</div>
</Section>
{/* Coming Soon */}
<Section number="02" title="Kommt bald" borderTop>
<Reveal>
<Card
variant="glass"
padding="large"
techBorder
className="text-center relative overflow-hidden group"
>
<div className="relative z-10 space-y-4 py-4 md:py-8">
<div className="flex items-center justify-center gap-2 md:gap-3">
<div className="w-2 h-2 rounded-full bg-slate-300 animate-pulse" />
<Label className="text-slate-400">In Arbeit</Label>
</div>
<H3 className="text-2xl md:text-3xl text-slate-400">
Weitere Case Studies in Kürze.
</H3>
<BodyText className="text-slate-400 max-w-md mx-auto">
Ich dokumentiere laufende Projekte – schauen Sie bald wieder
vorbei oder kontaktieren Sie mich direkt.
</BodyText>
<div className="pt-4">
<Button href="/contact" variant="outline">
Kontakt aufnehmen
</Button>
</div>
</div>
</Card>
</Reveal>
</Section>
</div>
);
}
@@ -1,23 +0,0 @@
import { ImageResponse } from "next/og";
import { OGImageTemplate } from "../../../src/components/OGImageTemplate";
import { getOgFonts, OG_IMAGE_SIZE } from "../../../src/lib/og-helper";
export const size = OG_IMAGE_SIZE;
export const contentType = "image/png";
export const runtime = "nodejs";
export default async function Image() {
const fonts = await getOgFonts();
return new ImageResponse(
<OGImageTemplate
title="Kontakt."
description="Bereit für eine Zusammenarbeit? Lassen Sie uns gemeinsam etwas bauen, das wirklich funktioniert."
label="Get in touch"
/>,
{
...OG_IMAGE_SIZE,
fonts: fonts as any,
},
);
}
-20
View File
@@ -1,20 +0,0 @@
import { Section } from "@/src/components/Section";
import { ContactForm } from "@/src/components/ContactForm";
import { AbstractCircuit } from "@/src/components/Effects";
export default function ContactPage() {
return (
<div className="flex flex-col bg-white min-h-screen overflow-hidden relative">
<AbstractCircuit />
<Section
containerVariant="wide"
effects={<></>}
className="pt-24 pb-12 md:pt-32 md:pb-20"
>
{/* Full-width Form */}
<ContactForm />
</Section>
</div>
);
}
-46
View File
@@ -1,46 +0,0 @@
import type { Metadata } from "next";
import { Inter, Newsreader } from "next/font/google";
import { Analytics } from "@/src/components/Analytics";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: "italic",
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "Marc Mintel — Professionelle Websites für Unternehmen",
template: "%s | Marc Mintel",
},
description:
"Professionelle Websites zum Fixpreis. Ein Entwickler, ein Ansprechpartner — von der Idee bis zum fertigen Ergebnis. Inklusive Sorglos-Paket: Änderungen, Wartung und Sicherheit komplett abgedeckt.",
metadataBase: new URL("https://mintel.me"),
icons: {
icon: "/favicon.svg",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de" className={`${inter.variable} ${newsreader.variable}`}>
<body className="min-h-screen bg-white">
<Header />
<main>{children}</main>
<Footer />
<InteractiveElements />
<Analytics />
</body>
</html>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { ImageResponse } from "next/og";
import { OGImageTemplate } from "../../src/components/OGImageTemplate";
import { getOgFonts, OG_IMAGE_SIZE } from "../../src/lib/og-helper";
export const size = OG_IMAGE_SIZE;
export const contentType = "image/png";
export const runtime = "nodejs";
export default async function Image() {
const fonts = await getOgFonts();
return new ImageResponse(
<OGImageTemplate
title="Marc Mintel"
description="Technical problem solver's blog - practical insights and learning notes"
label="Engineering"
/>,
{
...OG_IMAGE_SIZE,
fonts: fonts as any,
},
);
}
-311
View File
@@ -1,311 +0,0 @@
"use client";
import { Reveal } from "@/src/components/Reveal";
import { Section } from "@/src/components/Section";
import { H1, H3, LeadText, BodyText, Label } from "@/src/components/Typography";
import { Button } from "@/src/components/Button";
import { HeroSection } from "@/src/components/HeroSection";
import { Availability } from "@/src/components/Availability";
import { AbstractCircuit, GradientMesh } from "@/src/components/Effects";
import { Marker } from "@/src/components/Marker";
import { GlitchText } from "@/src/components/GlitchText";
export default function LandingPage() {
return (
<div className="flex flex-col bg-white overflow-hidden relative">
<AbstractCircuit />
<HeroSection />
{/* Section 02: Ihr Vorteil */}
<Section number="02" title="Ihr Vorteil" borderTop>
<div className="space-y-16 md:space-y-32 relative z-10">
<Reveal>
<H3 className="text-3xl md:text-6xl lg:text-8xl leading-none tracking-tighter">
Sie kümmern sich um Ihr Geschäft.{" "}
<span className="text-slate-400">
Ich mich um Ihre <Marker delay={0.3}>Website.</Marker>
</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
{[
{
title: "Ein Ansprechpartner",
text: "Direkte Kommunikation. Keine Agentur, keine Projektmanager, kein Stille-Post-Effekt.",
},
{
title: "Fixpreis",
text: "Keine versteckten Kosten, keine Stundenzettel. Sie wissen exakt, was es kostet.",
},
{
title: "Schnelle Ergebnisse",
text: "Kein monatelanges Warten. Erste Entwürfe innerhalb weniger Tage.",
},
{
title: "Null Arbeit für Sie",
text: "Keine CMS-Einarbeitung. Änderungen oder Updates? Eine kurze E-Mail genügt.",
},
].map((item, i) => (
<Reveal key={i} delay={0.1 * i}>
<div className="space-y-4">
<div className="h-px w-full bg-slate-100 mb-6" />
<Label className="text-slate-900 text-lg md:text-xl font-medium tracking-tight">
{item.title}
</Label>
<BodyText className="text-slate-500 text-base md:text-lg">
{item.text}
</BodyText>
</div>
</Reveal>
))}
</div>
</div>
</Section>
{/* Section 03: Der Unterschied */}
<Section number="03" title="Der Unterschied" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-3xl md:text-6xl lg:text-8xl leading-none tracking-tighter">
Warum nicht einfach{" "}
<span className="text-slate-400">eine Agentur?</span>
</H3>
</Reveal>
<div className="space-y-12 md:space-y-0">
{[
{
label: "Agentur",
text: "Projektmanager, Designer, Entwickler — Sie erklären alles drei Mal. Stundenabrechnungen, unklare Endkosten.",
},
{
label: "Baukasten (Wix, etc.)",
text: "Sie bauen selbst, kämpfen mit Templates und sind auf sich allein gestellt, wenn etwas nicht funktioniert.",
},
{
label: "Bei mir",
text: "Ein Ansprechpartner. Fixpreis. Ich baue, Sie lehnen sich zurück. Sie brauchen eine Änderung? Kurze Nachricht genügt.",
highlight: true,
},
].map((item, i) => (
<Reveal key={i} delay={0.1 * i}>
<div
className={`grid grid-cols-1 md:grid-cols-4 gap-4 md:gap-8 py-8 md:py-16 border-t ${item.highlight ? "border-slate-900" : "border-slate-100"}`}
>
<div className="md:col-span-1">
<Label
className={`text-lg md:text-xl ${item.highlight ? "text-slate-900 font-bold" : "text-slate-400"}`}
>
{item.label}
</Label>
</div>
<div className="md:col-span-3">
<LeadText
className={`text-xl md:text-3xl leading-snug ${item.highlight ? "text-slate-900" : "text-slate-500"}`}
>
{item.text}
</LeadText>
</div>
</div>
</Reveal>
))}
</div>
</div>
</Section>
{/* Section 04: Zielgruppe */}
<Section number="04" title="Für wen" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-3xl md:text-6xl lg:text-8xl leading-none tracking-tighter">
Fokus auf das <span className="text-slate-400">Wesentliche.</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
<Reveal>
<div className="space-y-8">
<H3 className="text-2xl md:text-4xl">
Unternehmer & Geschäftsführer
</H3>
<LeadText className="text-slate-500">
Sie brauchen eine professionelle Website, die Ihr Unternehmen
repräsentiert — ohne sich selbst mit Technik beschäftigen zu
müssen. Ich übernehme alles.
</LeadText>
</div>
</Reveal>
<Reveal delay={0.2}>
<div className="space-y-8">
<H3 className="text-2xl md:text-4xl">
Selbstständige & kleine Teams
</H3>
<LeadText className="text-slate-500">
Sie wollen online sichtbar sein, aber keine Zeit in eine
Website stecken. Ich liefere eine fertige Lösung, damit Sie
sich auf Ihre Kunden konzentrieren können.
</LeadText>
</div>
</Reveal>
</div>
</div>
</Section>
{/* Section 05: Leistungen */}
<Section number="05" title="Leistungen" borderTop>
<div className="space-y-0">
{[
{
title: "Websites",
text: "Professionelle, schnelle Websites — individuell für Ihr Unternehmen gestaltet. Ohne Overhead.",
href: "/websites",
},
{
title: "Sorglos-Paket",
text: "Änderungen, Wartung, Sicherheit — alles inklusive. Sie sagen Bescheid, ich erledige das.",
href: "/contact",
},
{
title: "Web-Anwendungen",
text: "Kundenportale, interne Tools oder individuelle Lösungen für Ihr Business.",
href: "/showcase",
},
].map((service, i) => (
<Reveal key={i} delay={0.1 * i}>
<a href={service.href} className="group block">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 py-12 md:py-20 border-b border-slate-100 transition-colors hover:border-slate-300">
<div className="space-y-4 max-w-2xl">
<H3 className="text-3xl md:text-5xl group-hover:translate-x-2 transition-transform duration-500">
{service.title}
</H3>
<LeadText className="text-slate-400 group-hover:text-slate-600 transition-colors">
{service.text}
</LeadText>
</div>
<div className="text-slate-300 group-hover:text-slate-900 transition-colors pb-1">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</div>
</div>
</a>
</Reveal>
))}
</div>
</Section>
{/* Section 06: Blog / Insights */}
<Section number="06" title="Einblicke" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-3xl md:text-6xl lg:text-8xl leading-none tracking-tighter">
Wissen statt <span className="text-slate-400">Werbung.</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
<Reveal>
<div className="space-y-8">
<Label className="text-slate-400">Aktueller Artikel</Label>
<H3 className="text-2xl md:text-4xl leading-tight">
Warum Websites nach Updates oft brechen – und wie man das
verhindert.
</H3>
<div>
<Button
href="/blog/why-websites-break-after-updates"
variant="outline"
>
Artikel lesen
</Button>
</div>
</div>
</Reveal>
<Reveal delay={0.2}>
<div className="space-y-8 border-l border-slate-100 pl-8 md:pl-12 flex flex-col justify-end">
<LeadText className="text-slate-500">
Ich teile meine Gedanken zu digitaler Architektur, Fixpreisen
und warum weniger oft mehr ist.
</LeadText>
<a
href="/blog"
className="text-slate-900 font-bold hover:underline underline-offset-4"
>
Alle Artikel ansehen →
</a>
</div>
</Reveal>
</div>
</div>
</Section>
{/* Section 07: Contact */}
<Section
number="07"
title="Kontakt"
borderTop
effects={<GradientMesh variant="metallic" className="opacity-60" />}
>
<div className="relative py-12 md:py-32" id="contact">
<Reveal>
<div className="space-y-12 md:space-y-24 relative z-10">
<H1 className="text-5xl md:text-8xl lg:text-9xl leading-none tracking-tighter">
Bereit für den Start? <br />
<span className="text-slate-400">
<GlitchText delay={0.5} duration={1}>
Sagen Sie Hallo.
</GlitchText>
</span>
</H1>
<div className="flex flex-col md:flex-row gap-12 md:gap-24 items-start">
<div className="space-y-8 flex-1">
<LeadText className="text-2xl md:text-4xl text-slate-500 leading-snug">
Schreiben Sie mir kurz, was Sie vorhaben. Ich melde mich
innerhalb von 24 Stunden bei Ihnen.
</LeadText>
<div className="pt-4">
<Button
href="/contact"
size="large"
className="text-lg px-8 py-4"
>
Jetzt Nachricht schreiben
</Button>
</div>
</div>
<div className="w-full md:w-80 space-y-4 pt-4 md:pt-0">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-slate-900 rounded-full"></div>
<Label className="text-slate-900 text-sm">
Verfügbarkeit
</Label>
</div>
<BodyText className="text-slate-500">
Aktuell nehme ich Projekte für{" "}
<span className="font-bold text-slate-900">
<Availability />
</span>{" "}
an.
</BodyText>
</div>
</div>
</div>
</Reveal>
</div>
</Section>
</div>
);
}
-57
View File
@@ -1,57 +0,0 @@
import { MetadataRoute } from "next";
import { getAllPosts } from "@/src/lib/posts";
import { technologies } from "./technologies/[slug]/data";
/**
* Sitemap Generator
*
* Standard Next.js 15 App Router sitemap generation.
* This file dynamically generates /sitemap.xml
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const allPosts = await getAllPosts();
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://mintel.me";
// 1. Core Pages
const routes = [
"",
"/about",
"/blog",
"/case-studies",
"/case-studies/klz-cables",
"/contact",
"/websites",
].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: route === "" ? 1.0 : 0.8,
}));
// 2. Dynamic Blog Posts
const blogRoutes = allPosts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: "monthly" as const,
priority: 0.7,
}));
// 3. Technology Detail Pages
const techRoutes = Object.keys(technologies).map((slug) => ({
url: `${baseUrl}/technologies/${slug}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.6,
}));
// 4. Tag Pages
const allTags = [...new Set(allPosts.flatMap((post) => post.tags))];
const tagRoutes = allTags.map((tag) => ({
url: `${baseUrl}/tags/${tag}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.3,
}));
return [...routes, ...blogRoutes, ...techRoutes, ...tagRoutes];
}
@@ -1,77 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { env } from "@/lib/env";
/**
* Smart Proxy for Umami Analytics.
*
* This Route Handler receives tracking events from the browser,
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
* internal Umami API endpoint.
*
* This ensures:
* 1. The Website ID is NOT leaked to the client bundle.
* 2. The Umami API endpoint is hidden behind our domain.
* 3. We have full control over the tracking data.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { type, payload } = body;
// Inject the secret websiteId from server config
const websiteId = env.UMAMI_WEBSITE_ID || env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
if (!websiteId) {
console.warn(
"Umami tracking received but no Website ID configured on server",
);
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
// Prepare the enhanced payload with the secret ID
const enhancedPayload = {
...payload,
website: websiteId,
};
const umamiEndpoint = env.UMAMI_API_ENDPOINT;
// Log the event (debug only)
if (process.env.NODE_ENV === "development") {
console.debug("Forwarding analytics event", {
type,
url: payload.url,
website: websiteId.slice(0, 8) + "...",
});
}
const response = await fetch(`${umamiEndpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": request.headers.get("user-agent") || "Mintel-Smart-Proxy",
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
},
body: JSON.stringify({ type, payload: enhancedPayload }),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Umami API responded with error", {
status: response.status,
error: errorText.slice(0, 100),
});
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: "ok" });
} catch (error) {
console.error("Failed to proxy analytics request", {
error: (error as Error).message,
});
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
-61
View File
@@ -1,61 +0,0 @@
import Link from "next/link";
import { getAllPosts } from "@/src/lib/posts";
import { MediumCard } from "@/src/components/MediumCard";
import { Reveal } from "@/src/components/Reveal";
export async function generateStaticParams() {
const allPosts = await getAllPosts();
const allTags = Array.from(
new Set(allPosts.flatMap((post) => post.tags || [])),
);
return allTags.map((tag) => ({
tag,
}));
}
export default async function TagPage({
params,
}: {
params: Promise<{ tag: string }>;
}) {
const { tag } = await params;
const allPosts = await getAllPosts();
const posts = allPosts.filter((post) => post.tags?.includes(tag));
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<header className="mb-8">
<Reveal>
<h1 className="text-3xl font-bold text-slate-900 mb-2">
Posts tagged{" "}
<span className="highlighter-yellow px-2 rounded">{tag}</span>
</h1>
</Reveal>
<Reveal delay={0.1}>
<p className="text-slate-600">
{posts.length} post{posts.length === 1 ? "" : "s"}
</p>
</Reveal>
</header>
<div className="space-y-4">
{posts.map((post, i) => (
<Reveal key={post.slug} delay={0.1 + i * 0.05} width="100%">
<MediumCard post={post} />
</Reveal>
))}
</div>
<Reveal delay={0.3}>
<div className="mt-8 pt-6 border-t border-slate-200">
<Link
href="/blog"
className="text-slate-600 hover:text-slate-900 inline-flex items-center"
>
← Back to blog
</Link>
</div>
</Reveal>
</div>
);
}
@@ -1,141 +0,0 @@
"use client";
import React from "react";
import Link from "next/link";
import { Container } from "@/src/components/Layout";
import { Label } from "@/src/components/Typography";
import { Check, ArrowLeft, Zap, ExternalLink } from "lucide-react";
import { technologies } from "./data";
import { Reveal } from "@/src/components/Reveal";
export default function TechnologyContent({ slug }: { slug: string }) {
const tech = technologies[slug];
if (!tech) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Technology Not Found</h1>
<Link href="/" className="text-blue-600 hover:underline">
Return Home
</Link>
</div>
</div>
);
}
const Icon = tech.icon;
return (
<div className="bg-white min-h-screen text-slate-900 pb-24">
<div className="bg-slate-50 border-b border-slate-200">
<Container className="py-24">
<Reveal>
<Link
href="/case-studies/klz-cables"
className="inline-flex items-center text-sm font-bold text-slate-500 hover:text-slate-900 mb-8 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Case Study
</Link>
</Reveal>
<div className="flex flex-col md:flex-row items-start gap-8">
<Reveal>
<div className={`p-6 rounded-2xl shadow-lg ${tech.color}`}>
<Icon className="w-12 h-12" />
</div>
</Reveal>
<div className="flex-1">
<Reveal delay={0.1}>
<Label className="text-slate-400 mb-2">
TECHNOLOGY DEEP DIVE
</Label>
</Reveal>
<Reveal delay={0.2}>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
{tech.title}
</h1>
</Reveal>
<Reveal delay={0.3}>
<p className="text-xl text-slate-500 font-medium">
{tech.subtitle}
</p>
</Reveal>
</div>
</div>
</Container>
</div>
<Container className="py-16">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16">
<div className="lg:col-span-8 space-y-12">
<section>
<Reveal>
<h2 className="text-2xl font-bold mb-4">What is it?</h2>
<p className="text-xl leading-relaxed text-slate-700">
{tech.description}
</p>
</Reveal>
</section>
<section>
<Reveal>
<h2 className="text-2xl font-bold mb-6 flex items-center gap-3">
<Zap className="w-6 h-6 text-amber-500" /> Why I use it
</h2>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{tech.benefits.map((benefit, i) => (
<Reveal key={i} delay={0.1 + i * 0.05}>
<div className="flex gap-3 p-4 bg-slate-50 rounded-xl border border-slate-100 h-full">
<Check className="w-5 h-5 text-green-600 shrink-0" />
<span className="font-medium text-slate-700">
{benefit}
</span>
</div>
</Reveal>
))}
</div>
</section>
<Reveal delay={0.4}>
<section className="bg-blue-50 border border-blue-100 rounded-2xl p-8">
<Label className="text-blue-600 mb-2">CUSTOMER IMPACT</Label>
<h3 className="text-2xl font-bold text-slate-900 mb-4">
What does this mean for you?
</h3>
<p className="text-lg text-slate-700 leading-relaxed">
{tech.customerValue}
</p>
</section>
</Reveal>
</div>
<div className="lg:col-span-4 space-y-8">
<Reveal delay={0.5}>
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-6 sticky top-24">
<h3 className="font-bold text-slate-900 mb-4">
Related Technologies
</h3>
<div className="space-y-2">
{tech.related.map((item) => (
<Link
key={item.slug}
href={`/technologies/${item.slug}`}
className="flex items-center justify-between p-3 bg-white border border-slate-200 rounded-lg hover:border-slate-400 hover:shadow-sm transition-all group"
>
<span className="font-medium text-slate-700 group-hover:text-slate-900">
{item.name}
</span>
<ExternalLink className="w-4 h-4 text-slate-400 group-hover:text-slate-600" />
</Link>
))}
</div>
</div>
</Reveal>
</div>
</div>
</Container>
</div>
);
}
@@ -1,95 +0,0 @@
import { Layers, Code, Database, Palette, Terminal } from "lucide-react";
export interface TechInfo {
title: string;
subtitle: string;
description: string;
icon: any; // React.ElementType
benefits: string[];
customerValue: string;
color: string;
related: { name: string; slug: string }[];
}
export const technologies: Record<string, TechInfo> = {
"next-js-14": {
title: "Next.js 14",
subtitle: "The React Framework for the Web",
description:
"Next.js 14 is the latest version of the industry-leading framework for building high-performance web applications. It allows me to create fast, scalable, and search-engine-friendly websites by rendering content on the server before sending it to your users.",
icon: Layers,
benefits: [
"Lightning-fast page loads with Server Components",
"Automatic image optimization",
"Instant navigation between pages",
"Top-tier SEO (Search Engine Optimization) out of the box",
],
customerValue:
"For my clients, Next.js means a website that ranks higher on Google, keeps visitors engaged with instant interactions, and scales effortlessly as your traffic grows.",
color: "bg-black text-white",
related: [
{ name: "TypeScript", slug: "typescript" },
{ name: "React", slug: "react" },
],
},
typescript: {
title: "TypeScript",
subtitle: "JavaScript with Syntax for Types",
description:
"TypeScript adds a powerful type system to JavaScript, catching errors before they ever reach production. It acts as a safety net for your code, ensuring that data flows exactly as expected through your entire application.",
icon: Code,
benefits: [
"Eliminates whole categories of common bugs",
"Makes large codebases easier to maintain",
"Improves developer productivity and code confidence",
"Ensures critical data integrity",
],
customerValue:
'Using TypeScript means your application is robust and reliable from day one. It dramatically reduces the risk of "runtime errors" that could crash your site, saving time and money on bug fixes down the line.',
color: "bg-blue-600 text-white",
related: [
{ name: "Next.js 14", slug: "next-js-14" },
],
},
"tailwind-css": {
title: "Tailwind CSS",
subtitle: "Utility-First CSS Framework",
description:
"Tailwind CSS is a utility-first framework that allows me to build custom designs directly in markup. It eliminates the need for bulky, overriding stylesheets and ensures a consistent design system across every page.",
icon: Palette,
benefits: [
"Rapid UI development and prototyping",
"Consistent spacing, colors, and typography",
"Highly optimized final bundle size (only includes used styles)",
"Responsive design made simple",
],
customerValue:
"Tailwind ensures your brand looks pixel-perfect on every device. It also results in incredibly small CSS files, meaning your site loads faster for users on mobile networks.",
color: "bg-cyan-500 text-white",
related: [
{ name: "React", slug: "react" },
{ name: "Next.js 14", slug: "next-js-14" },
],
},
react: {
title: "React",
subtitle: "The Library for Web and Native User Interfaces",
description:
"React is the core library powering Next.js. It lets me build encapsulated components that manage their own state, then compose them to make complex UIs.",
icon: Terminal,
benefits: [
"Component-based architecture for reuse",
"Efficient updates and rendering",
"Rich ecosystem of libraries and tools",
"Strong community support",
],
customerValue:
"React enables rich, app-like interactions on your website. Whether it's a complex dashboard or a smooth animation, React makes it possible to build dynamic experiences that feel instantaneous.",
color: "bg-blue-400 text-white",
related: [
{ name: "Next.js 14", slug: "next-js-14" },
{ name: "Tailwind CSS", slug: "tailwind-css" },
],
},
};
@@ -1,19 +0,0 @@
import React from "react";
import { technologies } from "./data";
import TechnologyContent from "./content";
export default async function TechnologyPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <TechnologyContent slug={slug} />;
}
// Generate static params for these dynamic routes
export async function generateStaticParams() {
return Object.keys(technologies).map((slug) => ({
slug,
}));
}
-245
View File
@@ -1,245 +0,0 @@
"use client";
import { Reveal } from "@/src/components/Reveal";
import { Section } from "@/src/components/Section";
import {
H1,
H3,
LeadText,
BodyText,
Label,
MonoLabel,
} from "@/src/components/Typography";
import { Button } from "@/src/components/Button";
import { Marker } from "@/src/components/Marker";
import { AbstractCircuit, GradientMesh } from "@/src/components/Effects";
import { GlitchText } from "@/src/components/GlitchText";
export default function WebsitesPage() {
return (
<div className="flex flex-col bg-white overflow-hidden relative">
<AbstractCircuit />
<Section className="pt-24 pb-12 md:pt-40 md:pb-24">
<div className="space-y-16 md:space-y-32">
<div className="space-y-10 max-w-4xl">
<Reveal>
<div className="space-y-6">
<MonoLabel className="text-slate-400 tracking-[0.2em] text-xs">
PROFESSIONELLE WEBSITES
</MonoLabel>
<H1 className="text-5xl md:text-8xl lg:text-9xl leading-[0.9] tracking-tighter">
Ihre Website. <br />
<span className="text-slate-400">Komplett sorgenfrei.</span>
</H1>
</div>
</Reveal>
<Reveal delay={0.2}>
<LeadText className="text-xl md:text-3xl text-slate-500 leading-snug">
Kein WordPress-Chaos. Kein Baukasten-Frust. Ich baue Ihnen eine
maßgeschneiderte Website, mit der Sie{" "}
<span className="text-slate-900 font-bold underline decoration-slate-200 underline-offset-8">
null Arbeit
</span>{" "}
haben.
</LeadText>
</Reveal>
</div>
<Reveal delay={0.3}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 md:gap-12 py-12 md:py-20 border-y border-slate-100">
{[
{
label: "Kein CMS-Zwang",
desc: "Sie müssen kein System lernen. Ich übernehme alles.",
},
{
label: "Fixpreis",
desc: "Klare Kosten von Anfang an. Keine versteckten Gebühren.",
},
{
label: "Pfeilschnell",
desc: "Keine langen Ladezeiten. Kunden bleiben auf der Seite.",
},
{
label: "Sorglos",
desc: "Hosting, Sicherheit, Backups? Darum kümmere ich mich.",
},
].map((item, i) => (
<div key={i} className="space-y-4">
<Label className="text-slate-900 text-lg">{item.label}</Label>
<BodyText className="text-slate-500">{item.desc}</BodyText>
</div>
))}
</div>
</Reveal>
</div>
</Section>
{/* 02: Performance */}
<Section number="02" title="Schnelligkeit" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
Niemand wartet gerne. <br />
<span className="text-slate-400">
Ihre Kunden erst recht nicht.
</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
<Reveal delay={0.2}>
<LeadText className="text-xl md:text-2xl text-slate-500">
Langsame Websites kosten bares Geld. Wenn Ihre Seite nicht
sofort lädt, ist der Besucher schon bei der Konkurrenz. Meine
Websites sind auf maximale Geschwindigkeit optimiert.
</LeadText>
</Reveal>
<Reveal delay={0.3}>
<div className="space-y-8 border-l border-slate-100 pl-8 md:pl-12">
{[
"Sofortiger Seitenaufbau ohne Verzögerung",
"Automatische Bild-Optimierung für Smartphones",
"Bessere Google-Platzierung (SEO) durch Top-Speed",
].map((item, i) => (
<div key={i} className="space-y-2">
<Label className="text-slate-900">0{i + 1}</Label>
<BodyText className="text-slate-500 text-lg">
{item}
</BodyText>
</div>
))}
</div>
</Reveal>
</div>
</div>
</Section>
{/* 03: Code-Qualität */}
<Section number="03" title="Qualität" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
Handarbeit statt <br />
<span className="text-slate-400">Fertigbausatz.</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
<Reveal delay={0.2}>
<LeadText className="text-xl md:text-2xl text-slate-500">
Vergessen Sie Sicherheitslücken durch veraltete Plugins oder
Themes, die ständig aktualisiert werden müssen.
</LeadText>
</Reveal>
<Reveal delay={0.3}>
<div className="space-y-12">
<div>
<Label className="text-slate-900 text-xl mb-4 block">
Saubere Programmierung
</Label>
<BodyText className="text-slate-500 text-lg">
Ihre Website wird von Grund auf solide entwickelt. Kein
unnötiger Ballast, der die Seite verlangsamt oder angreifbar
macht.
</BodyText>
</div>
<div>
<Label className="text-slate-900 text-xl mb-4 block">
Zukunftssicher
</Label>
<BodyText className="text-slate-500 text-lg">
Moderne Technologien garantieren, dass Ihre Website auch in
ein paar Jahren noch perfekt und fehlerfrei läuft.
</BodyText>
</div>
</div>
</Reveal>
</div>
</div>
</Section>
{/* 04: Sorglos Paket */}
<Section number="04" title="Sorglos-Paket" borderTop>
<div className="space-y-16 md:space-y-32">
<Reveal>
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
Das Prinzip: <br />
<span className="text-slate-400">Null Aufwand.</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24 items-start">
<Reveal delay={0.2}>
<LeadText className="text-2xl md:text-4xl text-slate-900 leading-snug">
Sie haben Besseres zu tun, als Bilder zu tauschen oder Updates
zu installieren.
</LeadText>
</Reveal>
<Reveal delay={0.3}>
<div className="space-y-8">
<BodyText className="text-slate-500 text-lg md:text-xl leading-relaxed">
Mein Sorglos-Paket deckt alles für ein ganzes Jahr ab. Wenn
Sie eine Änderung brauchen, schreiben Sie mir einfach eine
E-Mail. Kein kompliziertes System, keine Schulungen. Ich setze
es für Sie um.
</BodyText>
<div className="pt-8 border-t border-slate-100 flex gap-8">
<div>
<Label className="text-slate-900">Inklusive</Label>
<BodyText className="text-slate-500 mt-2">
Änderungen & Wartung
</BodyText>
</div>
<div>
<Label className="text-slate-900">Ihr Stresslevel</Label>
<BodyText className="text-slate-500 mt-2">0%</BodyText>
</div>
</div>
</div>
</Reveal>
</div>
</div>
</Section>
{/* 05: CTA */}
<Section
number="05"
title="Ergebnis"
borderTop
effects={<GradientMesh variant="metallic" className="opacity-60" />}
>
<div className="py-12 md:py-24 relative z-10">
<div className="space-y-16 md:space-y-24">
<Reveal>
<H3 className="text-5xl md:text-8xl lg:text-9xl leading-none tracking-tighter">
Ihre fertige <br />
<span className="text-slate-400">
<GlitchText delay={0.5} duration={1}>
Website.
</GlitchText>
</span>
</H3>
</Reveal>
<Reveal delay={0.2}>
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-12">
<LeadText className="text-xl md:text-3xl text-slate-500 max-w-xl">
Kein Stress, kein Overhead. Schreiben Sie mir einfach eine
Nachricht.
</LeadText>
<Button
href="/contact"
className="w-full md:w-auto px-12 py-6 text-xl"
>
Sagen Sie Hallo
</Button>
</div>
</Reveal>
</div>
</div>
</Section>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { notFound } from "next/navigation";
/** Any address that is not a page ends in the error page of its language. */
export default function UnknownPage() {
notFound();
}
@@ -3,6 +3,8 @@
import { useEffect } from "react";
import * as Sentry from "@sentry/nextjs";
import { Button } from "@/src/components/Button";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
export default function Error({
error,
@@ -11,32 +13,50 @@ export default function Error({
error: Error & { digest?: string };
reset: () => void;
}) {
const { error: copy } = useContent();
const home = pathFor("home", useLocale());
useEffect(() => {
// Log the error to Sentry/GlitchTip
Sentry.captureException(error);
console.error("Caught in error.tsx:", error);
// Auto-reload on ChunkLoadError or Server Action errors to fetch the new deployment assets
const isChunkLoadError =
error.message &&
(error.message.includes("ChunkLoadError") ||
error.message.includes("Loading chunk") ||
error.message.includes("Failed to find Server Action"));
if (isChunkLoadError) {
const lastReload = sessionStorage.getItem("last-chunk-reload");
const now = Date.now();
if (!lastReload || now - parseInt(lastReload) > 10000) {
sessionStorage.setItem("last-chunk-reload", now.toString());
window.location.reload();
}
}
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-red-50 border border-red-100 rounded text-[10px] font-mono text-red-500 uppercase tracking-widest">
Error 500
{copy.kicker}
</span>
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
Kritischer Fehler.
{copy.title}
</h1>
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
Ein unerwartetes Problem ist aufgetreten. Unsere Systeme haben den
Vorfall protokolliert.
{copy.text}
</p>
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<Button href="#" onClick={() => reset()} variant="primary">
System neu starten (Retry)
{copy.retry}
</Button>
<Button href="/" variant="outline">
Zurück zur Basis
<Button href={home} variant="outline">
{copy.home}
</Button>
</div>
+272
View File
@@ -0,0 +1,272 @@
import type { Metadata } from "next";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
import { Faq } from "@/src/components/home/Faq";
import { BitAura } from "@/src/components/bits/BitAura";
import { PathPicker } from "@/src/components/home/PathPicker";
import { TopicButton } from "@/src/components/home/TopicButton";
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
import { Reveal } from "@/src/components/Reveal";
import { SwipeRow } from "@/src/components/SwipeRow";
import { contentFor } from "@/src/content";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { freelance } = contentFor(locale);
return {
title: { absolute: freelance.metaTitle },
description: freelance.metaDescription,
alternates: {
canonical: pathFor("freelance", locale),
languages: languageAlternates("freelance"),
},
};
}
/** What a team may ask for. Clients have their own on the homepage. */
const TEAM_TOPICS = ["developer", "rescue", "other"] as const;
const MUTED = "text-slate-400";
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
const LIST_TITLE = "m-0 font-sans text-xl font-bold tracking-tight md:text-2xl";
/**
* The page for people who develop themselves or lead a team: which projects
* fit, the tools, and how a change is secured before it goes live.
*/
export default async function FreelancePage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const { freelance, technology } = content;
const { booking } = freelance;
const { services } = content;
const { working } = content;
return (
<div className="relative flex flex-col bg-white">
<section className="bg-white">
<div className="mx-auto w-full max-w-[1400px] px-5 pb-4 pt-14 md:px-12 md:pb-10 md:pt-28">
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
{freelance.label}
</p>
<h1 className="m-0 mt-8 max-w-6xl font-sans text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
{freelance.title.lead}{" "}
<span className={MUTED}>{freelance.title.rest}</span>
</h1>
<p className={`${BODY} mt-10 max-w-3xl md:mt-14`}>
{freelance.intro}
</p>
</div>
</section>
<Block
index="01"
label={technology.label}
title={
<>
{technology.title.lead}{" "}
<span className={MUTED}>{technology.title.rest}</span>
</>
}
>
<SwipeRow className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
{technology.fit.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
>
<h3
className={`${LIST_TITLE} ${i === 2 ? "text-slate-400" : "text-slate-900"}`}
>
{group.title}
</h3>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
i === 2 ? "text-slate-400" : "text-slate-900"
}`}
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
))}
</SwipeRow>
<Reveal>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
<h3 className={`${LIST_TITLE} text-slate-900`}>
{technology.techLabel}
</h3>
<div className="mt-6 grid grid-cols-1 gap-7 sm:grid-cols-2 md:mt-8 md:gap-10 lg:grid-cols-4">
{technology.tech.map((group) => (
<div key={group.area}>
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
{group.area}
</p>
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
{group.items.map((item) => (
<li
key={item}
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
>
<DecodeText text={item} trigger="both" />
</li>
))}
</ul>
</div>
))}
</div>
</div>
</Reveal>
</Block>
<Block
index="02"
label={services.label}
title={
<>
{freelance.cases.lead}{" "}
<span className={MUTED}>{freelance.cases.rest}</span>
</>
}
>
<Reveal>
<PathPicker ids={["developer", "rescue"]} pricing="estimate" />
</Reveal>
</Block>
<Block
index="03"
label={freelance.pipelineLabel}
title={technology.pipelineTitle}
>
<Reveal>
<p className={`${BODY} mb-12 max-w-3xl`}>
{technology.pipelineIntro}
</p>
</Reveal>
<Reveal>
<PipelineRun />
</Reveal>
</Block>
<Block
index="04"
label={working.label}
title={
<>
{working.title.lead}{" "}
<span className={MUTED}>{working.title.rest}</span>
</>
}
>
<Reveal>
<BitAura source="bottom" tone="light" className="mb-16 md:mb-24">
<div className="overflow-hidden rounded-3xl bg-slate-900 p-4 md:p-10">
<ContactDiagram />
</div>
</BitAura>
</Reveal>
<SwipeRow className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
{working.principles.map((item, i) => (
<Reveal key={item.title} delay={0.08 * i}>
<div className="border-t border-slate-900 pt-6">
<h3 className="m-0 font-sans text-3xl font-bold tracking-tight text-slate-900 md:text-4xl">
{item.title}
</h3>
<p className={`${BODY} mt-4`}>{item.text}</p>
</div>
</Reveal>
))}
</SwipeRow>
</Block>
<Block
index="05"
label={booking.label}
title={
<>
{booking.title.lead}{" "}
<span className={MUTED}>{booking.title.rest}</span>
</>
}
>
<Reveal>
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className={BODY}>{booking.text}</p>
<ul className="m-0 mt-8 list-none p-0">
{booking.terms.map((term) => (
<li
key={term}
className="border-t border-slate-200 py-4 font-sans text-lg text-slate-900 md:text-xl"
>
{term}
</li>
))}
</ul>
</div>
<div className="md:col-span-6">
<div className="rounded-3xl bg-slate-50 p-6 md:p-10">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
{[booking.rate, booking.example].map((price) => (
<div key={price.label}>
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
{price.label}
</p>
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
{price.amount}
</p>
<p className="m-0 mt-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
{price.note}
</p>
</div>
))}
</div>
<div className="mt-10 border-t border-slate-200 pt-8">
<TopicButton topic="developer">{booking.cta}</TopicButton>
</div>
</div>
</div>
</div>
</Reveal>
<Reveal>
<div className="mt-12 border-t border-slate-900 pt-6 md:mt-20">
<h3 className="m-0 font-sans text-2xl font-bold tracking-tight text-slate-900 md:text-4xl">
{booking.remote.title}
</h3>
<p className={`${BODY} mt-4 max-w-3xl`}>{booking.remote.text}</p>
</div>
</Reveal>
</Block>
<Block
index="06"
label={content.faq.label}
title={
<>
{freelance.faq.title.lead}{" "}
<span className={MUTED}>{freelance.faq.title.rest}</span>
</>
}
>
<Reveal>
<Faq items={freelance.faq.items} />
</Reveal>
</Block>
<ClosingCta contact={content.contact} topics={TEAM_TOPICS} />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { LegalPage } from "@/src/components/LegalPage";
import { IMPRINT } from "@/src/content/legal/imprint";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, description } = IMPRINT[locale];
return {
title,
description,
alternates: {
canonical: pathFor("imprint", locale),
languages: languageAlternates("imprint"),
},
robots: { index: false, follow: true },
};
}
export default async function Page({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, Body } = IMPRINT[locale];
return (
<LegalPage title={title}>
<Body />
</LegalPage>
);
}
+136
View File
@@ -0,0 +1,136 @@
import type { Metadata, Viewport } from "next";
import type { StaticImageData } from "next/image";
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { BitBurst } from "@/src/components/bits/BitBurst";
import { BitGlow } from "@/src/components/bits/BitGlow";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
import { ContentProvider } from "@/src/components/ContentProvider";
import { BitCurtain } from "@/src/components/bits/BitCurtain";
import shareImageDe from "@/src/assets/share/share-de.png";
import shareImageEn from "@/src/assets/share/share-en.png";
import { contentFor } from "@/src/content";
import { INDEXING, SITE_NAME, SITE_URL } from "@/src/content/site";
import { robotsMeta } from "@/src/domain/indexing";
import {
DEFAULT_LOCALE,
LOCALES,
parseLocale,
type Locale,
} from "@/src/domain/locale";
import { pathFor } from "@/src/domain/routes";
import "../globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
// A real monospaced fallback has almost the same letter width. The default
// fallback (a resized Arial) wraps the mono labels differently, so the hero
// jumped when the font arrived.
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
adjustFontFallback: false,
fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"],
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: "italic",
display: "swap",
});
type Props = {
readonly children: React.ReactNode;
readonly params: Promise<{ readonly locale: string }>;
};
/** The share image is a screenshot of the hero in that language (see scripts/render-share-image.mjs). */
const SHARE_IMAGE: Readonly<Record<Locale, StaticImageData>> = {
en: shareImageEn,
de: shareImageDe,
};
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { site } = contentFor(locale);
const image = SHARE_IMAGE[locale];
const images = [
{
url: image.src,
width: image.width,
height: image.height,
alt: site.shareImageAlt,
},
];
return {
metadataBase: new URL(SITE_URL),
title: {
default: site.title,
template: `%s | ${SITE_NAME}`,
},
description: site.description,
applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
robots: robotsMeta(INDEXING),
icons: {
icon: "/favicon.svg",
apple: "/apple-touch-icon.png",
},
openGraph: {
type: "website",
url: pathFor("home", locale),
siteName: SITE_NAME,
locale: site.ogLocale,
alternateLocale: LOCALES.filter((other) => other !== locale).map(
(other) => contentFor(other).site.ogLocale,
),
title: site.title,
description: site.description,
images,
},
twitter: {
card: "summary_large_image",
title: site.title,
description: site.description,
images,
},
};
}
export const viewport: Viewport = {
themeColor: "#ffffff",
colorScheme: "light",
viewportFit: "cover",
};
export default async function RootLayout({ children, params }: Props) {
// The proxy only lets the two languages through; anything else is a file-like
// address that ends in the error page of the main language.
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
return (
<html
lang={locale}
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
>
<body className="min-h-screen bg-white">
<ContentProvider locale={locale} content={contentFor(locale)}>
<Header />
<main>{children}</main>
<Footer />
<InteractiveElements />
<BitCurtain />
</ContentProvider>
<BitBurst />
<BitGlow />
</body>
</html>
);
}
@@ -1,32 +1,28 @@
export const metadata = {
title: "404 - Seite nicht gefunden | Marc Mintel",
description: "Diese Seite konnte leider nicht gefunden werden.",
};
"use client";
import Link from "next/link";
import { Button } from "@/src/components/Button";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
export default function NotFound() {
const { notFound } = useContent();
const home = pathFor("home", useLocale());
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-slate-50 border border-slate-100 rounded text-[10px] font-mono text-slate-500 uppercase tracking-widest">
Error 404
{notFound.kicker}
</span>
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
System-Anomalie.
{notFound.title}
</h1>
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
Die angeforderte URL existiert nicht in dieser Zeitleiste.
Möglicherweise wurde die Seite verschoben oder gelöscht.
{notFound.text}
</p>
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<Button href="/" variant="primary">
Zurück zur Basis
</Button>
<Button href="/blog" variant="outline">
Aktuelle Artikel lesen
<Button href={home} variant="primary">
{notFound.home}
</Button>
</div>
+284
View File
@@ -0,0 +1,284 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BitAura } from "@/src/components/bits/BitAura";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
import { Faq } from "@/src/components/home/Faq";
import { HeroStatement } from "@/src/components/home/HeroStatement";
import { HomeJsonLd } from "@/src/components/home/HomeJsonLd";
import { PathPicker } from "@/src/components/home/PathPicker";
import { PortraitSection } from "@/src/components/home/PortraitSection";
import { StickyBook } from "@/src/components/home/StickyBook";
import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
import { CareerLine } from "@/src/components/illustrations/CareerLine";
import {
PresentationCompare,
ReasonDrawing,
} from "@/src/components/illustrations/WebPresentation";
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
import { Reveal } from "@/src/components/Reveal";
import { StoryThread } from "@/src/components/story/StoryThread";
import { SwipeRow } from "@/src/components/SwipeRow";
import { contentFor } from "@/src/content";
import type { SplitTitle } from "@/src/content/types";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { site } = contentFor(locale);
return {
title: { absolute: site.title },
description: site.description,
alternates: {
canonical: pathFor("home", locale),
languages: languageAlternates("home"),
},
};
}
/** What a client of the homepage may ask for. Teams have their own on the freelance page. */
const HOME_TOPICS = ["automation", "website", "other"] as const;
const MUTED = "text-slate-400";
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
const LIST_TITLE =
"m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl";
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
title,
muted = MUTED,
}) => (
<>
{title.lead} <span className={muted}>{title.rest}</span>
</>
);
export default async function LandingPage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const {
hello,
services,
automation,
presentations,
story,
freelance,
work,
faq,
} = content;
return (
<div className="relative flex flex-col bg-white">
<HomeJsonLd locale={locale} />
<HeroStatement hero={content.hero} />
<StickyBook />
<PortraitSection
id="hello"
index="01"
label={hello.label}
title={hello.title}
portraitAlt={hello.portraitAlt}
story={story.hello}
>
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
{hello.lead}
</p>
{hello.paragraphs.map((paragraph, i) => (
<p
key={paragraph}
className={`m-0 font-sans text-lg leading-relaxed text-slate-300 md:text-xl ${i === 0 ? "mt-8" : "mt-6"}`}
>
{paragraph}
</p>
))}
</PortraitSection>
<section className="relative overflow-clip bg-slate-900">
<div className="mx-auto w-full max-w-[1400px] px-5 pb-20 pt-4 md:px-12 md:pb-36 md:pt-0">
<p className="m-0 mb-10 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
{hello.careerLabel}
</p>
<CareerLine stations={hello.career} />
</div>
</section>
<Block
index="02"
label={services.label}
chapter={{ line: story.services, figure: "diamond", flip: false }}
title={<Title title={services.title} />}
>
<Reveal>
<PathPicker ids={["automation", "website"]} pricing="offer" />
</Reveal>
<Reveal>
<Link
href={pathFor("freelance", locale)}
className="group mt-14 flex flex-col gap-5 rounded-3xl bg-slate-50 p-6 no-underline transition-colors duration-300 hover:bg-slate-100 md:mt-24 md:flex-row md:items-center md:justify-between md:p-10"
>
<span className="block max-w-2xl">
<span className="block font-sans text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
{freelance.teaser.title}
</span>
<span className="mt-3 block font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{freelance.teaser.text}
</span>
</span>
<span className="inline-flex shrink-0 items-center gap-3 self-start rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white md:self-auto">
{freelance.teaser.cta}
<span
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
→
</span>
</span>
</Link>
</Reveal>
</Block>
<Block
index="03"
label={automation.label}
chapter={{ line: story.automation, figure: "hexagon", flip: true }}
title={<Title title={automation.title} />}
>
<Reveal>
<p className={`${BODY} mb-12 max-w-3xl`}>{automation.intro}</p>
</Reveal>
<Reveal>
<AutomationFlow />
</Reveal>
<SwipeRow className="mt-12 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
{automation.notes.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div className="border-t border-slate-900 pt-6">
<h3 className={LIST_TITLE}>{group.title}</h3>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className="border-b border-slate-100 py-4 font-sans text-lg text-slate-900 md:text-xl"
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
))}
</SwipeRow>
</Block>
<Block
index="04"
label={presentations.label}
chapter={{ line: story.presentations, figure: "screen", flip: false }}
tone="dark"
title={<Title title={presentations.title} muted="text-slate-500" />}
>
<Reveal>
<p className="m-0 mb-12 max-w-3xl font-sans text-lg leading-relaxed text-slate-300 md:mb-16 md:text-xl">
{presentations.intro}
</p>
</Reveal>
<Reveal>
<BitAura source="around" tone="dark">
<div className="rounded-3xl border border-slate-700 bg-slate-950 px-4 py-8 md:px-10 md:py-12">
<PresentationCompare />
</div>
</BitAura>
</Reveal>
<SwipeRow className="mt-12 grid grid-cols-1 gap-x-10 gap-y-14 md:mt-28 md:grid-cols-2 lg:grid-cols-4">
{presentations.reasons.map((reason, i) => (
<Reveal key={reason.title} delay={0.08 * i}>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
<ReasonDrawing index={i} />
</div>
<h3 className="m-0 mt-6 font-sans text-2xl font-bold tracking-tight text-white">
{reason.title}
</h3>
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-300 md:text-lg">
{reason.text}
</p>
</Reveal>
))}
</SwipeRow>
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-28 md:grid-cols-12 md:gap-12">
<Reveal className="md:col-span-6">
<div className="border-t border-slate-700 pt-6">
<h3 className="m-0 font-sans text-xl font-bold tracking-tight text-white md:text-2xl">
{presentations.goodFor.title}
</h3>
<ul className="m-0 mt-4 list-none p-0">
{presentations.goodFor.items.map((item) => (
<li
key={item}
className="border-b border-slate-800 py-4 font-sans text-lg text-slate-100 md:text-xl"
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
<Reveal delay={0.1} className="md:col-span-6">
<div className="rounded-3xl border border-slate-700 bg-slate-950/60 p-6 md:p-10">
<h3 className="m-0 font-sans text-2xl font-bold tracking-tight text-white md:text-3xl">
{presentations.honest.title}
</h3>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-300 md:text-lg">
{presentations.honest.text}
</p>
<a
href="#contact"
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
{presentations.cta} <span aria-hidden="true">→</span>
</a>
</div>
</Reveal>
</div>
</Block>
<Block
index="05"
label={work.label}
chapter={{ line: story.work, figure: "square", flip: true }}
tone="dark"
title={<Title title={work.title} muted="text-slate-500" />}
>
<WorkShowcase />
<Reveal>
<p className="m-0 mt-20 max-w-3xl border-t border-slate-800 pt-8 font-sans text-lg leading-relaxed text-slate-300 md:mt-28 md:text-xl">
{work.note}
</p>
</Reveal>
</Block>
<Block
index="06"
label={faq.label}
chapter={{ line: story.faq, figure: "triangle", flip: false }}
title={<Title title={faq.title} />}
>
<Reveal>
<Faq items={faq.items} />
</Reveal>
</Block>
<ClosingCta
contact={content.contact}
topics={HOME_TOPICS}
story={story.contact}
/>
<StoryThread />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { LegalPage } from "@/src/components/LegalPage";
import { PRIVACY } from "@/src/content/legal/privacy";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, description } = PRIVACY[locale];
return {
title,
description,
alternates: {
canonical: pathFor("privacy", locale),
languages: languageAlternates("privacy"),
},
robots: { index: false, follow: true },
};
}
export default async function Page({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, Body } = PRIVACY[locale];
return (
<LegalPage title={title}>
<Body />
</LegalPage>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import fs from "fs";
import path from "path";
export async function GET() {
try {
const POSTS_DIR = path.join(process.cwd(), "content/blog");
let files = [];
if (fs.existsSync(POSTS_DIR)) {
files = fs.readdirSync(POSTS_DIR);
}
// Revalidate the two problematic pages
revalidatePath("/blog/website-as-a-service");
revalidatePath("/blog/zero-overhead-agencies");
revalidatePath("/blog"); // also bust the blog list cache
return NextResponse.json({
success: true,
cwd: process.cwd(),
postsDir: POSTS_DIR,
postsDirExists: fs.existsSync(POSTS_DIR),
files: files,
message: "Cache revalidated for specific paths",
});
} catch (err: any) {
return NextResponse.json({ success: false, error: err.message });
}
}
-22
View File
@@ -1,22 +0,0 @@
import { NextResponse } from 'next/server';
import { getTweet } from 'react-tweet/api';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const tweet = await getTweet(id);
if (!tweet) {
return NextResponse.json({ error: 'Tweet not found' }, { status: 404 });
}
return NextResponse.json({ data: tweet });
} catch (error) {
console.error('Error fetching tweet:', error);
return NextResponse.json({ error: 'Failed to fetch tweet' }, { status: 500 });
}
}
-25
View File
@@ -1,25 +0,0 @@
import { NextResponse, NextRequest } from "next/server";
import { revalidatePath } from "next/cache";
import fs from "fs";
import path from "path";
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const pathParam = searchParams.get("path") || "/";
const typeParam =
(searchParams.get("type") as "page" | "layout") || "layout";
// Revalidate the requested path (defaults to entire app via layout)
revalidatePath(pathParam, typeParam);
return NextResponse.json({
success: true,
revalidatedPath: pathParam,
revalidatedType: typeParam,
message: `Cache revalidated for ${pathParam} (${typeParam})`,
});
} catch (err: any) {
return NextResponse.json({ success: false, error: err.message });
}
}
@@ -1,9 +1,11 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import Error from "next/error";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Inter, Newsreader } from "next/font/google";
import { contentFor } from "@/src/content";
import { DEFAULT_LOCALE, type Locale } from "@/src/domain/locale";
import { localeOfPath } from "@/src/domain/routes";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
@@ -21,24 +23,44 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
// The root layout failed, so the language is read from the address.
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
useEffect(() => setLocale(localeOfPath(window.location.pathname)), []);
const copy = contentFor(locale).error;
useEffect(() => {
Sentry.captureException(error);
// Auto-reload on ChunkLoadError or Server Action errors to fetch the new deployment assets
const isChunkLoadError =
error.message &&
(error.message.includes("ChunkLoadError") ||
error.message.includes("Loading chunk") ||
error.message.includes("Failed to find Server Action"));
if (isChunkLoadError) {
const lastReload = sessionStorage.getItem("last-chunk-reload");
const now = Date.now();
if (!lastReload || now - parseInt(lastReload) > 10000) {
sessionStorage.setItem("last-chunk-reload", now.toString());
window.location.reload();
}
}
}, [error]);
return (
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
<html lang={locale} className={`${inter.variable} ${newsreader.variable}`}>
<body className="min-h-screen bg-white font-sans text-slate-900">
<div className="flex flex-col items-center justify-center min-h-screen px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-red-50 border border-red-200 rounded text-[10px] font-mono text-red-600 uppercase tracking-widest border-dashed">
Root Level Error
{copy.kicker}
</span>
<h1 className="text-4xl md:text-6xl font-black tracking-tighter text-slate-900">
Systemausfall der Hauptebene.
{copy.title}
</h1>
<p className="text-lg md:text-xl text-slate-500 font-serif italic max-w-xl mx-auto">
Ein kritischer Fehler auf der Root-Layout Ebene hat das Rendering
blockiert. Der Vorfall wurde zur Untersuchung protokolliert.
{copy.text}
</p>
<div className="pt-8">
@@ -47,7 +69,7 @@ export default function GlobalError({
className="relative inline-flex items-center justify-center gap-3 overflow-hidden rounded-full font-bold uppercase tracking-[0.15em] transition-all duration-300 group cursor-pointer px-8 py-4 text-[10px] bg-slate-900 text-white hover:shadow-xl hover:-translate-y-0.5"
>
<span className="relative z-10 flex items-center gap-3">
Notfall-Neustart (Reset)
{copy.retry}
</span>
</button>
</div>
@@ -321,26 +321,6 @@
/* Additional global styles from BaseLayout */
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Selection color */
::selection {
background: #0f172a;
@@ -1004,3 +984,537 @@ pre:has(code[class*="language-"]) {
opacity: 0.4;
}
}
/* ── Binary micro-interactions ─────────────────────────────────────────────── */
/* Digits that fly out from a pressed link or button (see BitBurst). */
.bit-burst {
position: fixed;
z-index: 200;
pointer-events: none;
/* On the layer, not the digits: a fixed layer blends as a whole with the page. */
mix-blend-mode: difference;
}
.bit-burst span {
position: absolute;
left: 0;
top: 0;
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
font-size: 13px;
font-weight: 700;
color: #ffffff;
opacity: 0;
animation: bit-burst-fly 620ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes bit-burst-fly {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy)))
scale(0.6);
}
}
/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */
.bit-glow {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: 0;
transition: opacity 400ms ease;
}
@media (hover: hover) {
[data-glow]:hover > .bit-glow {
opacity: 1;
}
}
/* Dark surfaces: the dot grid lights up around the pointer. */
.bit-glow-dots {
background-image:
radial-gradient(
320px circle at var(--gx, 50%) var(--gy, 50%),
rgba(148, 163, 184, 0.22),
transparent 70%
),
radial-gradient(#e2e8f0 1.25px, transparent 1.25px);
background-size:
auto,
20px 20px;
-webkit-mask-image: radial-gradient(
280px circle at var(--gx, 50%) var(--gy, 50%),
#000,
transparent 75%
);
mask-image: radial-gradient(
280px circle at var(--gx, 50%) var(--gy, 50%),
#000,
transparent 75%
);
}
/* Cards: only the border lights up, in the card's text colour. */
.bit-glow-edge {
inset: -1px;
padding: 2px;
background: radial-gradient(
320px circle at var(--gx, 50%) var(--gy, 50%),
currentColor,
transparent 70%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box exclude,
linear-gradient(#000 0 0);
}
/* Dark buttons: a spot of light under the pointer. */
.bit-glow-spot {
background: radial-gradient(
90px circle at var(--gx, 50%) var(--gy, 50%),
rgba(129, 140, 248, 0.5),
transparent 70%
);
}
@media (prefers-reduced-motion: reduce) {
.bit-burst,
.bit-glow {
display: none;
}
}
/*
* Calls to action: a light travels round the edge of the pill and glows softly
* behind it; on hover the glow comes up. `bit-cta-light` is for white pills on
* dark surfaces, `bit-cta-quiet` only lights up on hover.
*/
@property --cta-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.bit-cta {
--cta-a: rgba(129, 140, 248, 0.95);
--cta-b: #ffffff;
position: relative;
isolation: isolate;
}
.bit-cta-light {
--cta-a: rgba(99, 102, 241, 0.95);
--cta-b: #312e81;
}
.bit-cta-quiet {
--cta-a: rgba(99, 102, 241, 0.9);
--cta-b: #0f172a;
}
.bit-cta::before,
.bit-cta::after {
content: "";
position: absolute;
border-radius: inherit;
pointer-events: none;
background: conic-gradient(
from var(--cta-angle),
transparent 0 58%,
var(--cta-a) 76%,
var(--cta-b) 86%,
var(--cta-a) 93%,
transparent 100%
);
animation: bit-cta-turn 6s linear infinite;
}
/* The edge itself. */
.bit-cta::before {
inset: -1px;
padding: 1.5px;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box exclude,
linear-gradient(#000 0 0);
}
/* The glow behind the edge. */
.bit-cta::after {
inset: -2px;
z-index: -1;
padding: 6px;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box exclude,
linear-gradient(#000 0 0);
filter: blur(10px);
opacity: 0.55;
transition: opacity 300ms ease;
}
.bit-cta-quiet::before,
.bit-cta-quiet::after {
opacity: 0;
transition: opacity 300ms ease;
}
@media (hover: hover) {
.bit-cta:hover::after,
.group:hover > .bit-cta::after {
opacity: 1;
}
.bit-cta-quiet:hover::before {
opacity: 1;
}
.bit-cta-quiet:hover::after {
opacity: 0.6;
}
}
@keyframes bit-cta-turn {
to {
--cta-angle: 360deg;
}
}
/*
* The contact address on a dark surface: a slow sheen runs through the letters
* and a light travels along the underline. On hover the whole line lights up.
*/
.bit-mail {
position: relative;
display: inline-block;
padding-bottom: 0.42em;
background: linear-gradient(
100deg,
#ffffff 0 38%,
#a5b4fc 50%,
#ffffff 62% 100%
);
background-size: 260% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
animation: bit-mail-sheen 7s ease-in-out infinite;
transition: filter 300ms ease;
}
.bit-mail::before,
.bit-mail::after {
content: "";
position: absolute;
left: 0;
right: 0;
border-radius: 4px;
pointer-events: none;
}
/* The resting underline. */
.bit-mail::before {
bottom: 1px;
height: 2px;
background: rgba(255, 255, 255, 0.26);
}
/* The light travelling along it. */
.bit-mail::after {
bottom: 0;
height: 4px;
background: linear-gradient(
90deg,
transparent,
#818cf8 30%,
#ffffff 50%,
#818cf8 70%,
transparent
)
no-repeat;
background-size: 42% 100%;
filter: drop-shadow(0 0 8px rgba(129, 140, 248, 0.95));
animation: bit-mail-run 3.8s ease-in-out infinite alternate;
transition: background-size 400ms ease;
}
@media (hover: hover) {
.bit-mail:hover {
filter: drop-shadow(0 0 18px rgba(129, 140, 248, 0.7));
}
.bit-mail:hover::after {
background-size: 100% 100%;
}
}
@keyframes bit-mail-sheen {
0%,
100% {
background-position: 100% 0;
}
50% {
background-position: 0% 0;
}
}
@keyframes bit-mail-run {
from {
background-position: -30% 0;
}
to {
background-position: 130% 0;
}
}
/* Slow light drifting over a dark picture (see PortraitSection). */
.bit-aurora {
position: absolute;
inset: -10%;
pointer-events: none;
mix-blend-mode: screen;
background:
radial-gradient(
38% 46% at 38% 42%,
rgba(99, 102, 241, 0.55),
transparent 70%
),
radial-gradient(
30% 40% at 64% 72%,
rgba(129, 140, 248, 0.35),
transparent 70%
);
filter: blur(40px);
animation: bit-aurora-drift 16s ease-in-out infinite alternate;
}
@keyframes bit-aurora-drift {
from {
transform: translate3d(-4%, -3%, 0) scale(1);
}
to {
transform: translate3d(5%, 4%, 0) scale(1.12);
}
}
/* The current station on the career line: a node that breathes light. */
.bit-node-live {
animation: bit-node-breathe 3.2s ease-in-out infinite;
}
@keyframes bit-node-breathe {
0%,
100% {
box-shadow:
0 0 0 4px rgba(129, 140, 248, 0.22),
0 0 18px 2px rgba(129, 140, 248, 0.55);
}
50% {
box-shadow:
0 0 0 8px rgba(129, 140, 248, 0.12),
0 0 34px 8px rgba(129, 140, 248, 0.85);
}
}
/*
* SwipeRow: on phones equal items stand side by side and are swiped through,
* with the next one peeking in. From tablet width on the slide wrappers
* dissolve and the items sit in the grid the row was given.
*/
@media (max-width: 767px) {
.swipe-row {
display: flex;
gap: 1rem;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
/* Bleed to the screen edges, but start in line with the text. */
margin-inline: -1.25rem;
padding-inline: 1.25rem;
scroll-padding-inline: 1.25rem;
scrollbar-width: none;
}
.swipe-row::-webkit-scrollbar {
display: none;
}
.swipe-slide {
flex: 0 0 82%;
scroll-snap-align: start;
}
}
@media (min-width: 768px) {
.swipe-slide {
display: contents;
}
}
/*
* The story thread (see StoryThread): a trace down the page like a track on a
* circuit board. Indigo on light sections, light on dark ones.
*/
.story-thread {
--thread-ink: #4f46e5;
--thread-ground: #ffffff;
}
.story-thread-on-light {
--thread-ink: #4f46e5;
--thread-ground: #ffffff;
}
.story-thread-on-dark {
--thread-ink: #c7d2fe;
--thread-ground: #0f172a;
}
.story-thread path,
.story-thread line {
stroke: var(--thread-ink);
stroke-width: 1.75;
stroke-linecap: square;
stroke-linejoin: miter;
}
/* The second track of the bus. */
.story-thread path.story-thread-bus {
stroke-width: 1;
opacity: 0.38;
}
/* Solder points where the track bends or branches. */
.story-thread-pad {
fill: var(--thread-ground);
stroke: var(--thread-ink);
stroke-width: 1.75;
opacity: 0;
transition: opacity 250ms ease;
}
/* What makes a figure a component: its pins and its number in binary. */
.story-thread-part {
opacity: 0;
transition: opacity 500ms ease 350ms;
}
.story-thread-part text {
fill: var(--thread-ink);
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.14em;
}
/* The bit that travels at the head of the track. */
.story-thread-bit {
display: grid;
place-items: center;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 3px;
background: #fde68a;
color: #0f172a;
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
font-size: 12px;
font-weight: 700;
line-height: 1;
box-shadow:
0 0 0 3px rgba(253, 230, 138, 0.25),
0 0 18px 4px rgba(251, 191, 36, 0.8),
0 0 40px 12px rgba(129, 140, 248, 0.5);
transition: opacity 300ms ease;
will-change: transform;
}
/*
* Native feel: the page should behave like a well-made app on every input type.
*/
html {
color-scheme: light;
/* Anchor jumps stop below the fixed header. */
scroll-padding-top: 6rem;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
accent-color: #0f172a;
caret-color: #0f172a;
}
body {
text-rendering: optimizeLegibility;
font-kerning: normal;
}
/* Headlines break into even lines, running text avoids lonely last words. */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p,
li,
dd,
summary {
text-wrap: pretty;
}
/* Controls react at once to a tap and are not selected like text. */
a,
button,
summary,
[role="tab"],
[role="button"] {
touch-action: manipulation;
}
button,
summary,
[role="tab"],
[role="button"],
a[href^="#"] {
-webkit-user-select: none;
user-select: none;
}
/* Decoration is neither selectable nor draggable. */
[aria-hidden="true"],
canvas,
img,
svg {
-webkit-user-select: none;
user-select: none;
}
img,
svg,
a {
-webkit-user-drag: none;
}
/* One visible keyboard focus, none for mouse and touch. */
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid #0f172a;
outline-offset: 3px;
}
a:focus-visible,
button:focus-visible,
summary:focus-visible {
border-radius: 6px;
}
/* A short press-in on anything that can be pressed. */
@media (prefers-reduced-motion: no-preference) {
button:active,
[role="tab"]:active,
a[class*="rounded-full"]:active,
a:active > [class*="rounded-full"] {
scale: 0.97;
transition-duration: 80ms;
}
}
/* Dark surfaces: light selection, light focus ring, dark system controls. */
.bg-slate-900,
.bg-slate-950 {
color-scheme: dark;
}
.bg-slate-900 ::selection,
.bg-slate-950 ::selection {
background: #f8fafc;
color: #0f172a;
}
.bg-slate-900 :focus-visible,
.bg-slate-950 :focus-visible {
outline-color: #f8fafc;
}
/* Fixed controls stay clear of the home indicator and rounded corners. */
@supports (padding: env(safe-area-inset-bottom)) {
body {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
import { INDEXING, SITE_URL } from "@/src/content/site";
import { robotsFile } from "@/src/domain/indexing";
export default function robots(): MetadataRoute.Robots {
const { rules, sitemap } = robotsFile(INDEXING, SITE_URL);
return {
rules: {
...rules,
disallow:
typeof rules.disallow === "string"
? rules.disallow
: [...rules.disallow],
},
sitemap,
};
}
+16
View File
@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/src/content/site";
import { sitemapEntries } from "@/src/domain/routes";
/**
* The homepage and the freelance page, each in two languages; the legal pages
* are not meant for search results. No modification date: it would have to be the date the content
* last changed, and a date that moves with every build teaches search engines to
* ignore it.
*/
export default function sitemap(): MetadataRoute.Sitemap {
return sitemapEntries(SITE_URL, ["home", "freelance"]).map((entry) => ({
url: entry.url,
alternates: { languages: { ...entry.alternates.languages } },
}));
}
-35
View File
@@ -1,35 +0,0 @@
> @mintel/web@0.1.0 build /Users/marcmintel/Projects/mintel.me/apps/web
> next build --webpack
▲ Next.js 16.1.6 (webpack)
- Environments: .env
- Experiments (use with caution):
· clientTraceMetadata
Creating an optimized production build ...
[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
[@sentry/nextjs] DEPRECATION WARNING: It is recommended renaming your `sentry.client.config.ts` file, or moving its content to `instrumentation-client.ts`. When using Turbopack `sentry.client.config.ts` will no longer work. Read more about the `instrumentation-client.ts` file: https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
⚠ Compiled with warnings in 50s
Running TypeScript ...
Collecting page data using 15 workers ...
Error: Cannot find module '/Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/globals/AiSettings' imported from /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/index.js
at ignore-listed frames {
code: 'ERR_MODULE_NOT_FOUND',
url: 'file:///Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/globals/AiSettings'
}
> Build error occurred
Error: Failed to collect page data for /blog/[slug]/opengraph-image-fx5gi7
at ignore-listed frames {
type: 'Error'
}
 ELIFECYCLE  Command failed with exit code 1.
-38
View File
@@ -1,38 +0,0 @@
> @mintel/web@0.1.0 build /Users/marcmintel/Projects/mintel.me/apps/web
> next build --webpack
▲ Next.js 16.1.6 (webpack)
- Environments: .env
- Experiments (use with caution):
· clientTraceMetadata
Creating an optimized production build ...
[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
[@sentry/nextjs] DEPRECATION WARNING: It is recommended renaming your `sentry.client.config.ts` file, or moving its content to `instrumentation-client.ts`. When using Turbopack `sentry.client.config.ts` will no longer work. Read more about the `instrumentation-client.ts` file: https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
⚠ Compiled with warnings in 48s
Running TypeScript ...
Collecting page data using 15 workers ...
TypeError: Unknown file extension ".css" for /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/react-image-crop@10.1.8_react@19.2.4/node_modules/react-image-crop/dist/ReactCrop.css
at Object.getFileProtocolModuleFormat [as (file:] (node:internal/modules/esm/get_format:176:9) {
code: 'ERR_UNKNOWN_FILE_EXTENSION'
}
TypeError: Unknown file extension ".css" for /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/react-image-crop@10.1.8_react@19.2.4/node_modules/react-image-crop/dist/ReactCrop.css
at Object.getFileProtocolModuleFormat [as (file:] (node:internal/modules/esm/get_format:176:9) {
code: 'ERR_UNKNOWN_FILE_EXTENSION'
}
> Build error occurred
Error: Failed to collect page data for /sitemap.xml
at ignore-listed frames {
type: 'Error'
}
 ELIFECYCLE  Command failed with exit code 1.

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