Compare commits

...
148 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
mmintel 001c8514b5 fix(ci): violently purge buildx containers to avoid overlayfs corruption
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🏗️ Build (push) Failing after 40m17s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-03 16:55:00 +02:00
mmintel 00a8e3248f chore: retry pipeline 2934 due to runner docker crash
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🏗️ Build (push) Failing after 16m17s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2m48s
2026-06-03 16:34:54 +02:00
mmintel 9951a357ac fix(cache): update debug-mdx route to purge entire cache for Server Actions
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🏗️ Build (push) Failing after 6m9s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-03 16:27:11 +02:00
mmintel 3395e6ebf8 chore: move debug route out of /api to avoid Payload CMS interception
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🏗️ Build (push) Successful in 7m23s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🩺 Smoke Test (push) Successful in 4s
Build & Deploy / 🔔 Notify (push) Successful in 5s
2026-06-03 15:57:38 +02:00
mmintel 55c25ca203 fix(build): add outputFileTracingIncludes to guarantee all MDX blog posts are bundled into the docker standalone image
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🏗️ Build (push) Successful in 7m20s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🩺 Smoke Test (push) Successful in 6s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-03 15:48:28 +02:00
mmintel 53808c416a chore: add temp debug route to purge Next.js cache
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🏗️ Build (push) Successful in 7m24s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🩺 Smoke Test (push) Successful in 6s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-03 15:39:07 +02:00
mmintel 9dad18daa5 fix(content): remove multiline tags to prevent MDX parsing errors on prod
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🏗️ Build (push) Successful in 7m52s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-03 15:25:28 +02:00
mmintel d4b8c1ce4a chore: re-trigger pipeline after clearing runner disk space
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 6m43s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🩺 Smoke Test (push) Successful in 3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-06 12:14:34 +02:00
mmintel 04c3d73964 fix(site): fix grammar and missing layout margin on landing page button
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Failing after 3m15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-06 12:08:14 +02:00
mmintel 442983fe06 content: remove all editorial promises and clarify strict technical-only WaaS scope
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-06 12:05:17 +02:00
mmintel cb5be5426d fix(content): correct FAQ to specify non-editorial changes are included
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-05-06 12:03:53 +02:00
mmintel 85a0e2e051 fix(content): regenerate distinctly different thumbnails and remove duplicate TOCs
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-06 12:02:45 +02:00
mmintel f1d5d9e859 content: expand blog articles with tech depth and distinct visual thumbnails
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-05-06 11:57:43 +02:00
mmintel 0fb5e0cb66 fix(content): regenerate thumbnails matching the original style and fix file naming
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-06 11:53:11 +02:00
mmintel d08eaa45fc content: add two new blog posts aligning with the zero-overhead/Waas positioning
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-06 11:49:19 +02:00
mmintel 3f50ab034e feat: add minimalist blog section to homepage and refine wording/effects
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🏗️ Build (push) Successful in 5m46s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🩺 Smoke Test (push) Successful in 3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-06 11:42:39 +02:00
mmintel 55e67e38e2 style: modernize About and Websites pages with subtle effects and friendlier CTA wording
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-06 11:42:05 +02:00
mmintel c35d411874 style: radically simplify layouts to be extremely modern and minimalist
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-06 11:37:35 +02:00
mmintel 07c8d7c668 feat(content): rewrite copy to focus on worry-free package and static sites
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-06 11:34:52 +02:00
mmintel 5663c3ae38 Merge branch 'refactor/remove-payload-cms' into main
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 11m36s
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-06 11:15:26 +02:00
mmintel acb9c3df38 Fix: Move legacy asset rewrite to next.config.mjs beforeFiles for Next.js standalone compat
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 6m6s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🩺 Smoke Test (push) Successful in 4s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-05-06 10:51:50 +02:00
mmintel d5f36274aa Fix: Correct relative asset routing for case studies in middleware
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Successful in 6m37s
Build & Deploy / 🚀 Deploy (push) Successful in 32s
Build & Deploy / 🩺 Smoke Test (push) Failing after 4s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-05-06 10:21:03 +02:00
mmintel ce7d485b0c Fix: Add SKIP_RUNTIME_ENV_VALIDATION to branch deployments to prevent TARGET zod crash
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 6m19s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🩺 Smoke Test (push) Failing after 4s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-05-05 21:29:23 +02:00
mmintel 4ac5b15473 Chore: Silence WebVitalsScore missing prop warning in build logs
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Successful in 7m7s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-05-05 18:55:22 +02:00
mmintel c08950e865 Fix: Remove non-existent PortfolioHeader component from MDX
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-05 18:54:18 +02:00
mmintel 8dea906d23 Fix: Add TOC alias and update MDX to fix missing component error
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 10m35s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-05 16:55:18 +02:00
mmintel cb9e136d16 Fix: Add defensive check to WebVitalsScore and reformat MDX to avoid prerender error
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 9m49s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-05 16:38:46 +02:00
mmintel a02a419879 Fix: Remove payload env variables from Dockerfile and deploy.yml to resolve build failure
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 9m32s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-05 16:06:54 +02:00
mmintel 222140aa44 Chore: Remove heavy QA/E2E pipeline to simplify CI
Build & Deploy / 🔍 Prepare (push) Failing after 16s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-05 16:03:04 +02:00
mmintel 1187f5d48d Fix: Malformed frontmatter in maintenance-for-headless-systems.mdx
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-05 15:55:56 +02:00
mmintel 6e64da48d6 Fix: Point posts directory to content/blog and remove truncated malformed files
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🏗️ Build (push) Failing after 11m7s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-05 13:58:33 +02:00
mmintel f2bffea312 Cleanup: Remove legacy payload-types.ts
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🏗️ Build (push) Failing after 9m50s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-05 12:56:16 +02:00
mmintel 1669227f19 Cleanup: Remove legacy Payload-dependent scripts
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-05 12:50:15 +02:00
mmintel c644766a64 Fix: Add missing gray-matter dependency
Build & Deploy / 🔍 Prepare (push) Successful in 45s
Build & Deploy / 🏗️ Build (push) Failing after 10m7s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-05 11:07:04 +02:00
mmintel 05514b0c99 Fix: Remove Payload imports from blog page
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-05-05 10:59:40 +02:00
mmintel 66b80117b1 Refactor: remove PayloadCMS, use MDX for content and TSX for design, use direct email for contact
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🏗️ Build (push) Failing after 7m53s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-05 10:51:11 +02:00
mmintel 7bceb6cd2b design: redesign email templates to match premium industrial aesthetic
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 8m18s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 📝 E2E (push) Successful in 3m4s
Nightly QA / 🔍 Static Analysis (push) Successful in 3m55s
Nightly QA / 🎭 Lighthouse (push) Successful in 2m21s
Nightly QA / 🔗 Links & Deps (push) Successful in 1m52s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-04-14 19:31:17 +02:00
mmintel 569bb4e270 chore: redeploy with latest next-config fixes
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🏗️ Build (push) Successful in 9m44s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🩺 Smoke Test (push) Failing after 2s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-04-14 15:38:38 +02:00
mmintel 0a6f5f34ce fix(deploy): copy public directory to both standalone paths and ensure rewrites apply
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🏗️ Build (push) Successful in 9m47s
Build & Deploy / 🚀 Deploy (push) Successful in 20s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-04-14 14:48:16 +02:00
mmintel 5f27bb9950 chore: shift showcase asset rewrites to next.config.mjs for standalone stability
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 10m54s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🩺 Smoke Test (push) Failing after 4s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m56s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m22s
Nightly QA / 🔍 Static Analysis (push) Successful in 4m40s
Nightly QA / 📝 E2E (push) Successful in 5m5s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-04-13 23:44:01 +02:00
mmintel d83199ab1a fix(showcase): harden middleware rewrite logic to fix 404 assets in smoke tests
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 13m31s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-04-13 22:34:38 +02:00
mmintel 2cfe9115d2 feat: automate availability and fix KLZ showcase 404s
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 8m59s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🩺 Smoke Test (push) Failing after 4s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-13 21:11:23 +02:00
mmintel d4b586f29f fix(web): correct public asset path and whitelist image formats in Traefik
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 10m18s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🩺 Smoke Test (push) Failing after 4s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-13 19:15:34 +02:00
mmintel 9013d38e31 fix(ci): fix build-time nodemailer connection error and next.config warning
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 9m9s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-13 12:29:52 +02:00
mmintel f3cbdd8076 fix(ci): remove redundant robots-test diagnostic route and check
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-04-13 12:20:18 +02:00
mmintel f143fc6099 fix: whitelist /robots-test in traefik public rule
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 12m50s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-13 11:33:46 +02:00
mmintel 83190f1d00 fix: production stabilization (middleware routing + docker paths)
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 12m46s
Build & Deploy / 🚀 Deploy (push) Successful in 20s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 7s
Nightly QA / 🔍 Static Analysis (push) Successful in 2m36s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m13s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m47s
Nightly QA / 📝 E2E (push) Successful in 4m28s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-04-12 23:28:05 +02:00
mmintel 12848a4f23 fix: ensure rewrites are merged when config is a function (withPayload)
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 14m52s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-12 23:20:35 +02:00
mmintel 0a9cb81841 fix: harden next.config rewrites against wrapper overwrites
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 15m9s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-12 22:38:46 +02:00
mmintel c505050cb4 chore: add diagnostic rewrite and automated asset smoke tests
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 9m38s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🩺 Smoke Test (push) Failing after 4s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-12 18:49:11 +02:00
mmintel d5d0d77180 fix: comprehensive asset rewrites to resolve 404s in klz showcase
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 10m9s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🩺 Smoke Test (push) Successful in 3s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-12 12:53:41 +02:00
mmintel 9399be3ea9 chore: implement nuclear runner cleanup to solve no space left on device
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 13m17s
Build & Deploy / 🚀 Deploy (push) Successful in 32s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-12 11:04:46 +02:00
mmintel 7ec82027a8 fix: add build dependencies to Dockerfile for native modules
Build & Deploy / 🔍 Prepare (push) Successful in 15s
Build & Deploy / 🏗️ Build (push) Failing after 24m54s
Nightly QA / 🔍 Static Analysis (push) Successful in 5m22s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m18s
Nightly QA / 📝 E2E (push) Successful in 5m17s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m46s
Nightly QA / 🔔 Notify (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-04-12 01:53:14 +02:00
mmintel 631d051c2e chore: move rewrites to beforeFiles and fix smoke test dependencies
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-04-12 01:51:43 +02:00
mmintel 1a8758a88c chore: extreme runner cleanup and disable registry cache to unblock deployment
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 8m6s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🩺 Smoke Test (push) Failing after 2m2s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-12 00:14:26 +02:00
mmintel 7d6d34dd7b fix(deploy): optimize Dockerfile for disk efficiency and add runner diagnostics
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Failing after 13m21s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-11 23:54:23 +02:00
mmintel 1f0de18755 fix(form): resolve silent failures and cleanup deployment pipeline
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 22m37s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-11 23:21:13 +02:00
mmintel eeb0920afd chore: stabilize deployment and resolve asset 404s
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 12m38s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-11 21:55:24 +02:00
mmintel f8eec731c0 fix: final YAML syntax repair for docker-compose.yml (v1.15.14)
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 20m27s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-11 19:33:25 +02:00
mmintel 8b80af3d1a fix: repair YAML indentation in docker-compose.yml (v1.15.13)
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 21m34s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-11 19:32:19 +02:00
mmintel de42890476 fix: override CMD to apps/web/server.js (v1.15.12)
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 14m35s
Build & Deploy / 🚀 Deploy (push) Failing after 12s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-11 19:12:51 +02:00
mmintel fbd33da925 fix: stabilize build-time DB connection (v1.15.11)
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 13m25s
Build & Deploy / 🚀 Deploy (push) Failing after 13s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-11 18:06:45 +02:00
mmintel f3bab24bd3 feat: enforced CI deployment healthcheck waiting (zero-downtime)
Build & Deploy / 🔍 Prepare (push) Successful in 17s
Build & Deploy / 🏗️ Build (push) Failing after 1h24m30s
Nightly QA / 🔍 Static Analysis (push) Successful in 2m9s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m45s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m24s
Nightly QA / 📝 E2E (push) Successful in 5m13s
Nightly QA / 🔔 Notify (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-04-10 23:13:13 +02:00
mmintel 3b668fc876 fix: correct standalone entrypoint - apps/web/server.js (v1.15.9)
Build & Deploy / 🔍 Prepare (push) Successful in 39s
Build & Deploy / 🏗️ Build (push) Failing after 2h26m35s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-04-10 23:11:15 +02:00
mmintel 2827239796 chore: OOM hardening - lower heap to 4GB, disable sourcemaps (v1.15.8)
Build & Deploy / 🔍 Prepare (push) Successful in 15s
Build & Deploy / 🏗️ Build (push) Successful in 23m12s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-10 14:26:19 +02:00
mmintel 7cc1e49ba6 chore: purge brittle QA/smoke verification jobs from pipeline (v1.15.7)
Build & Deploy / 🔍 Prepare (push) Successful in 35s
Build & Deploy / 🏗️ Build (push) Failing after 34m22s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-10 13:56:58 +02:00
mmintel 258eb9464e fix(deploy): throttle build workers to 1 to prevent OOM and fix entrypoint
Build & Deploy / 🔍 Prepare (push) Successful in 18s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 7m32s
Build & Deploy / 🧪 QA (push) Successful in 1m49s
Build & Deploy / 🏗️ Build (push) Successful in 36m51s
Build & Deploy / 🚀 Deploy (push) Successful in 1m22s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-04-10 13:44:15 +02:00
mmintel 015386ba4a fix(deploy): pass build-time secrets and fix standalone path resolution
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 33m41s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 7s
2026-04-10 13:13:00 +02:00
mmintel 49abaaf2fd fix(web): increase build memory to 8GB and support smooth-scroll in IframeSection
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 16m45s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m9s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m9s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-10 11:37:09 +02:00
mmintel 7e9005e338 fix(web): remove forced transparent background from IframeSection to prevent visibility issues
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 37m43s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
Nightly QA / 🔗 Links & Deps (push) Successful in 4m13s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m38s
Nightly QA / 🔍 Static Analysis (push) Successful in 4m55s
Nightly QA / 📝 E2E (push) Successful in 5m9s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-04-09 23:09:38 +02:00
mmintel bbcc7d159c fix(deploy): resolve permission issues and stabilize asset build context
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 19m0s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🧪 QA (push) Successful in 1m34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m19s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-04-09 22:26:57 +02:00
mmintel 39f5bd3986 fix(blog): force dynamic rendering to avoid empty build-time cache
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Successful in 13m5s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🧪 QA (push) Successful in 1m8s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m8s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-04-09 21:37:35 +02:00
mmintel 944d369d43 ci: automate S3 cache purge during deployment
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 10m13s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🧪 QA (push) Successful in 1m9s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m59s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 18:18:34 +02:00
mmintel 6aaf8ac44f fix(types): manually align Inquiry type for contact form fields
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 17m53s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 QA (push) Successful in 1m9s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m8s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 12:34:56 +02:00
mmintel 2097b571f3 feat(web): streamline contact form and integrate identity fields
Build & Deploy / 🔍 Prepare (push) Successful in 1m9s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 30m21s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-04-07 23:08:04 +02:00
mmintel aeb1814a34 fix: trigger deploy to purge cache and ensure chunk alignment 2026-04-01 19:25:07 +02:00
mmintel 57e093ea54 fix(ci): pass S3 build-args to docker build for importMap generation
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 35m36s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 6s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m28s
Nightly QA / 🔍 Static Analysis (push) Successful in 5m15s
Nightly QA / 🎭 Lighthouse (push) Successful in 5m29s
Nightly QA / 📝 E2E (push) Successful in 5m59s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-04-01 11:20:27 +02:00
mmintel 76e25d03b9 fix(web): rename mail env vars and pass build context to Dockerfile to fix CMS white screen
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Successful in 23m32s
Build & Deploy / 🚀 Deploy (push) Successful in 2m25s
Build & Deploy / 🧪 QA (push) Successful in 3m50s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m49s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-01 11:16:05 +02:00
mmintel 19ef042fb6 fix(env): simplify sentry_dsn validation to prevent server-side crashes on empty dsn strings
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 19m40s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🧪 QA (push) Successful in 1m11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m8s
Build & Deploy / 🔔 Notify (push) Successful in 3s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m58s
Nightly QA / 🔍 Static Analysis (push) Successful in 4m32s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m39s
Nightly QA / 📝 E2E (push) Successful in 4m58s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-03-31 19:24:23 +02:00
mmintel 6032394ac9 fix(ci): fix dockerfile monorepo copy path to align with deploy.yml links
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🏗️ Build (push) Successful in 15m4s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m31s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 🔗 Links & Deps (push) Successful in 4m36s
Nightly QA / 🔍 Static Analysis (push) Successful in 5m15s
Nightly QA / 🎭 Lighthouse (push) Successful in 5m14s
Nightly QA / 📝 E2E (push) Successful in 5m32s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-03-30 22:11:43 +02:00
mmintel 033a9ad65e fix(env): allow nullish and empty SENTRY_DSN
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 6m36s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-30 21:56:51 +02:00
mmintel 3f6fa36f9b feat(crm): implement bulk mail action for contacts
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 6m25s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Add BulkMailButton component for Payload list view
- Add bulkMailEndpoint handler for processing prompts
- Integrate bulk mail action into CrmContacts collection
- Update dev:clean script to skip interactive prompts
2026-03-30 19:27:56 +02:00
mmintel c52a132d62 fix(ci): repair 'Extract Build Error Logs' undefined token variables
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Failing after 9m49s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
Nightly QA / 🔍 Static Analysis (push) Successful in 5m12s
Nightly QA / 📝 E2E (push) Successful in 5m38s
Nightly QA / 🎭 Lighthouse (push) Successful in 5m16s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m0s
Nightly QA / 🔔 Notify (push) Has been skipped
- Replaced missing steps.discover_token.outputs reference with secrets.NPM_TOKEN in Job 3 failure extractor
- Prevents the workflow from masking the actual docker build failure logs with a secondary auth failure
2026-03-07 23:36:09 +01:00
mmintel 11f735bbdf fix: resolve admin white screen via static ChatWindowProvider import
Build & Deploy / 🔍 Prepare (push) Successful in 16s
Build & Deploy / 🏗️ Build (push) Failing after 6m58s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Statically declare ChatWindowProvider in payload.config.ts admin.components.providers
- Regenerate importMap.js with correct component mappings
- Update Dockerfile to support _at-mintel monorepo sync for local builds
- Add .gitignore entries for manual build artifacts
- Update blocks and collections (payload-ai integration updates)
- Sync pnpm-lock.yaml
2026-03-07 11:47:52 +01:00
mmintel c81dae0b7b fix(next): add transpilePackages for @mintel sibling workspaces to fix CI module resolution
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 14m37s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 🔗 Links & Deps (push) Successful in 4m18s
Nightly QA / 🔍 Static Analysis (push) Successful in 4m40s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m46s
Nightly QA / 📝 E2E (push) Successful in 5m7s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-03-06 22:43:46 +01:00
mmintel 716ece1c6c fix(payload): explicitly copy generated importMap.js to nextjs standalone image runner
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 18m27s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-06 18:33:01 +01:00
mmintel e0ccf1cdfb fix(payload): add importMap generation to build script and isolate UI imports
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 18m25s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-06 13:54:16 +01:00
mmintel 6a6fbb6f19 feat: register payloadChatPlugin from @mintel/payload-ai in Payload CMS config
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Failing after 17m52s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-06 01:50:54 +01:00
mmintel 6b6b2b8ece fix(blog): auto-play LoadTimeSimulator, fix Carousel data, filter TableOfContents text, extend CarouselBlock schema
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 21m19s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m59s
Build & Deploy / 🔔 Notify (push) Successful in 9s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m40s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m18s
Nightly QA / 🔍 Static Analysis (push) Successful in 4m34s
Nightly QA / 📝 E2E (push) Successful in 4m45s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-03-06 00:54:45 +01:00
mmintel 9f412d81a8 chore: release v1.9.9 to trigger prod deploy
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 10m35s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 QA (push) Successful in 50s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m44s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 23:54:12 +01:00
mmintel 9c401f13de chore: trigger CI build after clearing infra registry space
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🏗️ Build (push) Successful in 16m49s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 QA (push) Successful in 56s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m0s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-05 23:22:45 +01:00
mmintel 5857404ac1 fix(blog): merge defaultJSXConverters to prevent 'unknown node' on standard Lexical nodes
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 20m40s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-05 22:53:19 +01:00
mmintel 34a96f8aef fix(blog): resolve IconList string collision rendering 'Check' text
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 16m0s
Build & Deploy / 🚀 Deploy (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m9s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m34s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 21:50:05 +01:00
mmintel 4e6f3f29cf fix(blog): add missing mintelP/TLDR renderers, fix iconList, diagram blocks, reduce AI components to 13
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 11m52s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 1m15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 6m31s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Add mintelP renderer with inline markdown link/marker support (228 broken blocks)
- Add mintelTldr renderer for summary boxes
- Fix iconList to display item.title instead of empty item.description
- Rewire all 6 diagram block types to render via Mermaid
- Remove ai property from 30 non-essential blocks (46 -> 13)
- Tighten MemeCard to 5 verified templates, max 1 per article
- Fix PerformanceChartBlock syntax after ai removal
2026-03-05 17:39:57 +01:00
1001 changed files with 24947 additions and 74695 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.
+18 -6
View File
@@ -1,9 +1,13 @@
node_modules
.next
out
dist
*.log
.git
# Exclude all binary/dependency folders recursively
**/node_modules
**/.pnpm-store
**/.git
**/.next
**/dist
**/out
**/*.log
# Specific exclusions for this project
.DS_Store
cloned-websites
storage
@@ -11,3 +15,11 @@ storage
verify_ci
pnpm_install_log.txt
full_tree.json
backups
data
# Ensure we don't copy the sibling's build artifacts either
_at-mintel/**/node_modules
_at-mintel/**/dist
_at-mintel/**/.next
_at-mintel/.git
+124 -269
View File
@@ -118,151 +118,7 @@ jobs:
echo "target=skip" >> "$GITHUB_OUTPUT"
fi
# ──────────────────────────────────────────────────────────────────────────────
# JOB 2: QA (Lint, Typecheck, Test)
# ──────────────────────────────────────────────────────────────────────────────
qa:
name: 🧪 QA
needs: [prepare, deploy]
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Provide sibling monorepo
run: |
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
# Force ALL @mintel packages to use the local clone instead of the registry
# This handles root package.json
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:.\/_at-mintel\/packages\/$1"/g' package.json
# Special case for pdf -> pdf-library
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
# Handle apps/web/package.json
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:..\/\.\.\/_at-mintel\/packages\/$1"/g' apps/web/package.json
# Special case for pdf -> pdf-library
perl -pi -e 's/link:\.\.\/\.\.\/_at-mintel\/packages\/pdf"/link:..\/\.\.\/_at-mintel\/packages\/pdf-library"/g' apps/web/package.json
# Fix tsconfig paths if they exist
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
# Fix tsconfig paths if they exist
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
- name: 🔐 Registry Auth
run: |
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
TOKENS="${{ secrets.GITHUB_TOKEN }} ${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
VALID_TOKEN=""
VALID_USER=""
for T_RAW in $TOKENS; do
if [ -n "$T_RAW" ]; then
T=$(echo "$T_RAW" | tr -d ' ' | tr -d '\n' | tr -d '\r')
echo "Testing API with token..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $T" https://git.infra.mintel.me/api/v1/user || echo "failed")
echo "API returned: $HTTP_CODE"
for U in $USERS; do
if [ -n "$U" ]; then
echo "Attempting docker login for a token with user $U..."
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
echo "✅ Successfully authenticated with a token."
VALID_TOKEN="$T"
VALID_USER="$U"
break 2
fi
fi
done
fi
done
if [ -z "$VALID_TOKEN" ]; then
echo "❌ All token/user combinations failed to authenticate!"
T=$(echo "$TOKENS" | awk '{print $1}')
echo "Attempting open diagnostic login with first token and user mmintel..."
echo "$T" | docker login git.infra.mintel.me -u "mmintel" --password-stdin || true
exit 1
fi
TOKEN="$VALID_TOKEN"
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> $GITHUB_OUTPUT
echo "user=$VALID_USER" >> $GITHUB_OUTPUT
echo "Configuring .npmrc for git.infra.mintel.me..."
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${TOKEN}" >> .npmrc
echo "always-auth=true" >> .npmrc
# Also export for pnpm to pick it up from env if needed
echo "NPM_TOKEN=${TOKEN}" >> $GITHUB_ENV
- name: 🏗️ Compile Sibling Monorepo
timeout-minutes: 15
run: |
mkdir -p ci-logs
echo "=== Compile Sibling Monorepo ===" >> ci-logs/summary.txt
cp .npmrc _at-mintel/
cd _at-mintel
pnpm install --no-frozen-lockfile --loglevel info 2>&1 | tee -a ../ci-logs/summary.txt
pnpm --filter "...@mintel/payload-ai" \
--filter @mintel/pdf... \
--filter @mintel/concept-engine... \
--filter @mintel/estimation-engine... \
--filter @mintel/meme-generator... \
build --loglevel info 2>&1 | tee -a ../ci-logs/summary.txt
- name: Install dependencies
timeout-minutes: 10
run: |
echo "=== Install dependencies (Root) ===" >> ci-logs/summary.txt
pnpm install --no-frozen-lockfile --loglevel info 2>&1 | tee -a ci-logs/summary.txt
- name: 🧪 Test
if: github.event.inputs.skip_checks != 'true'
timeout-minutes: 10
run: |
echo "=== Test (@mintel/web) ===" >> ci-logs/summary.txt
pnpm --filter @mintel/web test --loglevel info 2>&1 | tee -a ci-logs/summary.txt
- name: Inspect on Failure
if: failure()
run: |
echo "==== runner state ===="
ls -la
echo "==== _at-mintel state ===="
ls -la _at-mintel || true
echo "==== .npmrc check ===="
cat .npmrc | sed -E 's/authToken=[a-f0-9]{5}.*/authToken=REDACTED/'
echo "==== pnpm debug logs ===="
[ -f pnpm-debug.log ] && tail -n 100 pnpm-debug.log || echo "No root pnpm-debug.log"
[ -f _at-mintel/pnpm-debug.log ] && tail -n 100 _at-mintel/pnpm-debug.log || echo "No sibling pnpm-debug.log"
- name: Extract QA Error Logs
if: failure()
run: |
mkdir -p ci-logs
echo "QA Failure Report" > ci-logs/summary.txt
ls -R >> ci-logs/summary.txt
[ -f pnpm-debug.log ] && cp pnpm-debug.log ci-logs/ || true
[ -f _at-mintel/pnpm-debug.log ] && cp _at-mintel/pnpm-debug.log ci-logs/at-mintel-pnpm-debug.log || true
SSH_KEY_FILE=$(mktemp)
echo "${{ secrets.ALPHA_SSH_KEY }}" > "$SSH_KEY_FILE"
chmod 600 "$SSH_KEY_FILE"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY_FILE" root@alpha.mintel.me "mkdir -p ~/logs"
scp -r -o StrictHostKeyChecking=no -i "$SSH_KEY_FILE" ci-logs/* root@alpha.mintel.me:~/logs/ || true
rm "$SSH_KEY_FILE"
# (JOB 2: QA was removed to reduce pipeline noise)
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
@@ -298,6 +154,33 @@ jobs:
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: 🧹 Nuclear Runner Cleanup
run: |
echo "Disk space before nuclear prune:"
df -h
# Massive cleanup of pre-installed runner software to free up ~5GB+
# These are standard on VM-based runners and often unnecessary
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /usr/local/lib/android || true
sudo rm -rf /opt/ghc || true
sudo rm -rf /opt/hostedtoolcache/CodeQL || true
# Comprehensive Docker purge
docker buildx stop || true
docker buildx rm -a || true
docker rm -fv $(docker ps -aq --filter "ancestor=moby/buildkit") || true
docker system prune -af --volumes
docker builder prune -af
docker buildx prune -af
# Clean temp build artifacts
rm -rf /tmp/docker-actions-toolkit-* || true
echo "Disk space after nuclear prune:"
df -h
continue-on-error: true
- name: 🏗️ Build and Push
uses: docker/build-push-action@v5
with:
@@ -310,9 +193,18 @@ jobs:
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
S3_ENDPOINT=${{ secrets.S3_ENDPOINT || vars.S3_ENDPOINT || 'https://fsn1.your-objectstorage.com' }}
S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY || vars.S3_ACCESS_KEY }}
S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY || vars.S3_SECRET_KEY }}
S3_BUCKET=${{ secrets.S3_BUCKET || vars.S3_BUCKET || 'mintel' }}
S3_REGION=${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
S3_PREFIX=${{ secrets.S3_PREFIX || vars.S3_PREFIX || 'mintel.me' }}
BUILD_ID=${{ github.sha }}
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
# Temporarily disable registry cache export to save runner disk/bandwidth
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }}
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }},mode=max
# cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }},mode=max
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
@@ -323,20 +215,19 @@ 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 "${{ steps.discover_token.outputs.token }}" | docker login git.infra.mintel.me -u "${{ steps.discover_token.outputs.user }}" --password-stdin > login.log 2>&1
echo "${{ steps.discover_token.outputs.token }}" > /tmp/npm_token.txt
echo "${{ secrets.NPM_TOKEN }}" > /tmp/npm_token.txt
docker build \
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }} \
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
--build-arg NPM_TOKEN=${{ steps.discover_token.outputs.token }} \
--build-arg NPM_TOKEN=${{ secrets.NPM_TOKEN }} \
--secret id=NPM_TOKEN,src=/tmp/npm_token.txt \
--progress plain \
-t temp-image . > docker_build_failed.log 2>&1
cat login.log >> docker_build_failed.log
scp docker_build_failed.log root@alpha.mintel.me:/root/docker_build_failed.log
cat login.log >> docker_build_failed.log || true
scp docker_build_failed.log root@infra.mintel.me:/root/docker_build_failed.log
# JOB 4: Deploy
# ──────────────────────────────────────────────────────────────────────────────
deploy:
@@ -353,13 +244,6 @@ jobs:
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
# Database configuration
postgres_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
postgres_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
postgres_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
DATABASE_URI: postgres://${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}:${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}@postgres-db:5432/${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'secret' }}
# Mail
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
@@ -422,11 +306,7 @@ jobs:
SENTRY_DSN=$SENTRY_DSN
PROJECT_COLOR=$PROJECT_COLOR
LOG_LEVEL=$LOG_LEVEL
postgres_DB_NAME=$postgres_DB_NAME
postgres_DB_USER=$postgres_DB_USER
postgres_DB_PASSWORD=$postgres_DB_PASSWORD
DATABASE_URI=$DATABASE_URI
PAYLOAD_SECRET=$PAYLOAD_SECRET
MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME
@@ -446,6 +326,7 @@ jobs:
S3_REGION=$S3_REGION
S3_PREFIX=$S3_PREFIX
TARGET=$TARGET
SKIP_RUNTIME_ENV_VALIDATION=true
SENTRY_ENVIRONMENT=$TARGET
PROJECT_NAME=$PROJECT_NAME
ENV_FILE=$ENV_FILE
@@ -466,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
@@ -488,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..."
@@ -519,14 +400,37 @@ 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
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --wait --remove-orphans
"
- name: 🧹 Purge S3 Cache
shell: bash
run: |
echo "Installing rclone..."
curl -s -O https://downloads.rclone.org/rclone-current-linux-amd64.deb
sudo dpkg -i rclone-current-linux-amd64.deb > /dev/null 2>&1
echo "Configuring rclone..."
cat > rclone.conf <<EOF
[mintel-s3]
type = s3
provider = Other
access_key_id = ${{ secrets.S3_ACCESS_KEY || vars.S3_ACCESS_KEY }}
secret_access_key = ${{ secrets.S3_SECRET_KEY || vars.S3_SECRET_KEY }}
endpoint = ${{ secrets.S3_ENDPOINT || vars.S3_ENDPOINT || 'https://fsn1.your-objectstorage.com' }}
region = ${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
EOF
echo "Purging S3 cache for ${{ env.S3_PREFIX }} ..."
rclone --config rclone.conf delete mintel-s3:${{ env.S3_BUCKET }}/${{ env.S3_PREFIX }}/cache/ --include "*" || true
rm rclone.conf rclone-current-linux-amd64.deb
- name: 🧹 Post-Deploy Cleanup (Runner)
if: always()
run: docker builder prune -f --filter "until=1h"
@@ -535,112 +439,63 @@ jobs:
# JOB 5: Post-Deploy Verification
# ──────────────────────────────────────────────────────────────────────────────
post_deploy_checks:
name: 🧪 Post-Deploy Verification
needs: [prepare, deploy, qa]
if: success() || failure() # Run even if QA fails (due to E2E noise)
name: 🩺 Smoke Test
needs: [prepare, deploy]
runs-on: docker
if: needs.deploy.result == 'success'
container:
image: catthehacker/ubuntu:act-latest
image: alpine:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Provide sibling monorepo
- name: 🌐 Check Production URL
shell: sh
run: |
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
apk add --no-cache curl
# Wait longer (up to 2 minutes) for Next.js to fully prime
COUNT=0
MAX=24
URL="${{ needs.prepare.outputs.next_public_url }}"
# Force ALL @mintel packages to use the local clone instead of the registry
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:.\/_at-mintel\/packages\/$1"/g' package.json
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:..\/\.\.\/_at-mintel\/packages\/$1"/g' apps/web/package.json
perl -pi -e 's/link:\.\.\/\.\.\/_at-mintel\/packages\/pdf"/link:..\/\.\.\/_at-mintel\/packages\/pdf-library"/g' apps/web/package.json
echo "Verifying $URL is responsive..."
while [ $COUNT -lt $MAX ]; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL/" || echo "000")
if [ "$STATUS" = "200" ]; then
echo "✅ Site is UP (200 OK)"
break
fi
echo "⏳ Wait for 200 OK (Status: $STATUS)..."
sleep 5
COUNT=$((COUNT + 1))
done
# Fix tsconfig paths if they exist
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
- name: 🔐 Registry Auth
run: |
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
TOKENS="${{ secrets.GITHUB_TOKEN }} ${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
VALID_TOKEN=""
for TOKEN_RAW in $TOKENS; do
if [ -n "$TOKEN_RAW" ]; then
TOKEN=$(echo "$TOKEN_RAW" | tr -d '[:space:]' | tr -d '\n' | tr -d '\r')
for U in $USERS; do
if [ -n "$U" ]; then
if echo "$TOKEN" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
echo "✅ Successfully authenticated with a token."
VALID_TOKEN="$TOKEN"
break 2
fi
fi
done
fi
done
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
TOKEN="$VALID_TOKEN"
echo "Configuring .npmrc for git.infra.mintel.me..."
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${TOKEN}" >> .npmrc
echo "always-auth=true" >> .npmrc
echo "NPM_TOKEN=${TOKEN}" >> $GITHUB_ENV
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: 🏥 App Health Check
shell: bash
env:
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
run: |
echo "Waiting for app to start at $DEPLOY_URL ..."
for i in {1..30}; do
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' "$DEPLOY_URL" 2>&1) || true
echo "Attempt $i: HTTP $HTTP_CODE"
if [[ "$HTTP_CODE" =~ ^2 ]]; then
echo "✅ App is up (HTTP $HTTP_CODE)"
exit 0
fi
echo "⏳ Waiting... (got $HTTP_CODE)"
sleep 10
done
echo "❌ App health check failed after 30 attempts"
if [ "$STATUS" != "200" ]; then
echo "❌ Site failed smoke test after 2 minutes! (Status: $STATUS)"
exit 1
- name: 🚀 OG Image Check
continue-on-error: true
env:
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
run: pnpm --filter @mintel/web check:og
- name: 📝 E2E Smoke Test
continue-on-error: true
env:
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
PUPPETEER_SKIP_DOWNLOAD: "true"
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
fi
- name: 🌐 Check Legal Pages and Old URLs
shell: sh
run: |
# Install system Chromium + dependencies (KLZ pattern)
# Ubuntu's default 'chromium' is a snap wrapper, so we use xtradeb PPA for native binary
sudo apt-get update && sudo apt-get install -y gnupg wget ca-certificates
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
# Setup xtradeb PPA for native chromium
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
sudo mkdir -p /etc/apt/keyrings
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | sudo gpg --dearmor -o /etc/apt/keyrings/xtradeb.gpg || true
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" | sudo tee /etc/apt/sources.list.d/xtradeb-ppa.list
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" | sudo tee /etc/apt/preferences.d/xtradeb
# 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
sudo apt-get update
sudo apt-get install -y --allow-downgrades chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 || sudo apt-get install -y --allow-downgrades chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2
[ -f /usr/bin/chromium ] && sudo ln -sf /usr/bin/chromium /usr/bin/google-chrome
pnpm --filter @mintel/web check:forms
# 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
@@ -660,7 +515,7 @@ jobs:
TARGET="${{ needs.prepare.outputs.target }}"
VERSION="${{ needs.prepare.outputs.image_tag }}"
if [[ "$DEPLOY" == "success" ]] && [[ "$SMOKE" == "success" || "$SMOKE" == "skipped" ]]; then
if [[ "$DEPLOY" == "success" ]]; then
PRIORITY=5
EMOJI="✅"
else
@@ -670,5 +525,5 @@ jobs:
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$EMOJI mintel.me $VERSION -> $TARGET" \
-F "message=Deploy: $DEPLOY | Smoke: $SMOKE" \
-F "message=Deploy: $DEPLOY" \
-F "priority=$PRIORITY" || true
-232
View File
@@ -1,232 +0,0 @@
name: Nightly QA
on:
workflow_run:
workflows: ["Build & Deploy"]
branches: [main]
types:
- completed
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
env:
TARGET_URL: "https://testing.mintel.me"
PROJECT_NAME: "mintel.me"
jobs:
# ────────────────────────────────────────────────────
# 1. Static Checks (HTML, Assets, HTTP)
# ────────────────────────────────────────────────────
static:
name: 🔍 Static Analysis
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
- name: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🌐 Install Chrome & Dependencies
run: |
apt-get update && apt-get install -y --fix-missing \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 || true
apt-get install -y libasound2t64 || apt-get install -y libasound2 || true
npx puppeteer browsers install chrome || true
- name: 🖼️ OG Images
continue-on-error: true
env:
TEST_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm --filter @mintel/web run check:og
# ────────────────────────────────────────────────────
# 2. E2E (Forms)
# ────────────────────────────────────────────────────
e2e:
name: 📝 E2E
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
- name: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🌐 Install Chrome & Dependencies
run: |
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
npx puppeteer browsers install chrome || true
- name: 📝 E2E Form Submission Test
continue-on-error: true
env:
TEST_URL: ${{ env.TARGET_URL }}
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm --filter @mintel/web run check:forms
# ────────────────────────────────────────────────────
# 3. Performance (Lighthouse)
# ────────────────────────────────────────────────────
lighthouse:
name: 🎭 Lighthouse
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
- name: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🌐 Install Chrome & Dependencies
run: |
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
npx puppeteer browsers install chrome || true
- name: 🎭 Desktop
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
PAGESPEED_LIMIT: 5
run: pnpm --filter @mintel/web run pagespeed:test
- name: 📱 Mobile
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
PAGESPEED_LIMIT: 5
run: pnpm --filter @mintel/web run pagespeed:test
# ────────────────────────────────────────────────────
# 4. Link Check & Dependency Audit
# ────────────────────────────────────────────────────
links:
name: 🔗 Links & Deps
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
- name: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 📦 Depcheck
continue-on-error: true
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
- name: 🔗 Lychee Link Check
uses: lycheeverse/lychee-action@v2
continue-on-error: true
with:
args: --accept 200,204,429 --timeout 10 --insecure --exclude "file://*" --exclude "https://logs.infra.mintel.me/*" --exclude "https://git.infra.mintel.me/*" --exclude "https://mintel.me/*" '*.md' 'docs/*.md'
fail: false
# ────────────────────────────────────────────────────
# 5. Notification
# ────────────────────────────────────────────────────
notify:
name: 🔔 Notify
needs: [static, e2e, lighthouse, links]
if: failure()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔔 Gotify
shell: bash
run: |
STATIC="${{ needs.static.result }}"
E2E="${{ needs.e2e.result }}"
LIGHTHOUSE="${{ needs.lighthouse.result }}"
LINKS="${{ needs.links.result }}"
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
PRIORITY=8
EMOJI="🚨"
STATUS="Failed"
else
PRIORITY=2
EMOJI="✅"
STATUS="Passed"
fi
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
MESSAGE="Static: $STATIC | E2E: $E2E | Lighthouse: $LIGHTHOUSE | Links: $LINKS
${{ env.TARGET_URL }}"
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \
-F "message=$MESSAGE" \
-F "priority=$PRIORITY" || true
+19
View File
@@ -57,3 +57,22 @@ apps/web/out/estimations/
backups/
.turbo
# Manual build artifacts
_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`.
+45 -10
View File
@@ -7,37 +7,65 @@ ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET
ARG UMAMI_API_ENDPOINT
ARG NPM_TOKEN
ARG S3_ENDPOINT
ARG S3_ACCESS_KEY
ARG S3_SECRET_KEY
ARG S3_BUCKET
ARG S3_REGION
ARG S3_PREFIX
ARG BUILD_ID
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV S3_ENDPOINT=$S3_ENDPOINT
ENV S3_ACCESS_KEY=$S3_ACCESS_KEY
ENV S3_SECRET_KEY=$S3_SECRET_KEY
ENV S3_BUCKET=$S3_BUCKET
ENV S3_REGION=$S3_REGION
ENV S3_PREFIX=$S3_PREFIX
ENV SKIP_RUNTIME_ENV_VALIDATION=true
ENV NEXT_BUILD_WORKERS=1
ENV NEXT_DISABLE_SOURCEMAPS=true
ENV SENTRY_SKIP_LOCAL_SOURCES=true
ENV NEXT_PRIVATE_LOCAL_WEBPACK=true
ENV CI=true
# Copy manifest files specifically for better layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
COPY apps/web/package.json ./apps/web/package.json
# Install build dependencies for native modules (like canvas)
RUN apk add --no-cache python3 make g++ pkgconfig pixman-dev cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev
# Copy sibling monorepo for linked dependencies (cloned during CI)
COPY _at-mintel* ./_at-mintel/
# Placing it inside /app so relative links like ../../_at-mintel resolve correctly!
COPY _at-mintel* /app/_at-mintel/
# Install dependencies with cache mount and dynamic .npmrc (High Fidelity pattern)
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/" > .npmrc && \
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
echo "//npm.infra.mintel.me/:_authToken=\"\${NPM_TOKEN}\"" >> .npmrc && \
echo "always-auth=true" >> .npmrc && \
cd _at-mintel && pnpm install --no-frozen-lockfile && pnpm build && \
cd /app/_at-mintel && pnpm install --no-frozen-lockfile && pnpm build && \
cd /app && pnpm install --no-frozen-lockfile && \
rm .npmrc
# Copy source code
# We use BUILD_ID here to ensure that if the commit changes, we always COPY the latest files
# even if Docker's metadata-based fingerprinting for the public directory fails.
ARG BUILD_ID
RUN echo "Building with ID: ${BUILD_ID}"
COPY . .
# Build application (monorepo filter)
ENV NODE_OPTIONS="--max_old_space_size=4096"
RUN pnpm --filter @mintel/web build
RUN NEXT_BUILD_WORKERS=1 pnpm --filter @mintel/web build
# Stage 2: Runner
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
@@ -45,11 +73,18 @@ WORKDIR /app
# Copy standalone output and static files (Monorepo paths)
# Note: Base image already handles the non-root user and basic env
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./apps/web/public
# Fallback target: Next.js sometimes resolves from the process root depending on version
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./public
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/standalone ./
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/static ./apps/web/.next/static
# Fix permissions for the non-root user (Standard uid/gid from base image)
USER nextjs
# Start from the app directory to ensure references solve correctly
WORKDIR /app/apps/web
CMD ["node", "server.js"]
# In Standalone mode, Next.js expects node_modules and public relative to the server.js
WORKDIR /app
CMD ["node", "apps/web/server.js"]
+1 -1
View File
@@ -1,7 +1,7 @@
FROM node:20-alpine
# Install essential build tools if needed (e.g., for node-gyp)
RUN apk add --no-cache libc6-compat python3 make g++
RUN apk add --no-cache libc6-compat git python3 make g++
WORKDIR /app
-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"
]
}
-13
View File
@@ -1,13 +0,0 @@
"use server";
import { handleServerFunctions as payloadHandleServerFunctions } from "@payloadcms/next/layouts";
import config from "@payload-config";
// @ts-ignore - Payload generates this file during the build process
import { importMap } from "./admin/importMap";
export const handleServerFunctions = async (args: any) => {
return payloadHandleServerFunctions({
...args,
config,
importMap,
});
};
@@ -1,26 +0,0 @@
import type { Metadata } from "next";
import configPromise from "@payload-config";
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
// @ts-ignore - Payload generates this file during the build process
import { importMap } from "../importMap";
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = async ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config: configPromise, params, searchParams });
const Page = async ({ params, searchParams }: Args) =>
RootPage({ config: configPromise, importMap, params, searchParams });
export default Page;
@@ -1 +0,0 @@
export const importMap = {};
@@ -1,16 +0,0 @@
import config from "@payload-config";
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from "@payloadcms/next/routes";
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const OPTIONS = REST_OPTIONS(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);
-20
View File
@@ -1,20 +0,0 @@
import configPromise from "@payload-config";
import "@payloadcms/next/css";
import { RootLayout } from "@payloadcms/next/layouts";
import React from "react";
import { handleServerFunctions } from "./actions";
// @ts-ignore - Payload generates this file during the build process
import { importMap } from "./admin/importMap";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<RootLayout
config={configPromise}
importMap={importMap}
serverFunction={handleServerFunctions}
>
{children}
</RootLayout>
);
}
@@ -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,
},
);
}
-333
View File
@@ -1,333 +0,0 @@
"use client";
import Image from "next/image";
import { Section } from "@/src/components/Section";
import { Reveal } from "@/src/components/Reveal";
import {
ExperienceIllustration,
ResponsibilityIllustration,
ResultIllustration,
ContactIllustration,
HeroLines,
ParticleNetwork,
GridLines,
} from "@/src/components/Landing";
import { Signature } from "@/src/components/Signature";
import { Check } from "lucide-react";
import {
H1,
H3,
H4,
LeadText,
BodyText,
Label,
MonoLabel,
} from "@/src/components/Typography";
import { Card, Container } from "@/src/components/Layout";
import { Button } from "@/src/components/Button";
import { IconList, IconListItem } from "@/src/components/IconList";
import {
GradientMesh,
CodeSnippet,
AbstractCircuit,
} from "@/src/components/Effects";
import { Marker } from "@/src/components/Marker";
export default function AboutPage() {
return (
<div className="flex flex-col bg-white overflow-hidden relative">
{/* Background decoration removed per user request */}
{/* Hero Section */}
<section className="relative pt-12 md:pt-32 pb-8 md:pb-24 overflow-hidden border-b border-slate-50">
<Container variant="narrow" className="relative z-10">
<div className="flex flex-col items-center text-center space-y-6 md:space-y-12">
<Reveal width="fit-content">
<div className="relative">
{/* Structural rings around avatar */}
{/* Structural rings removed per user request */}
<div className="relative w-32 h-32 md:w-40 md:h-40 rounded-full overflow-hidden border border-slate-200 shadow-xl bg-white p-1 group">
<div className="w-full h-full rounded-full overflow-hidden relative aspect-square">
<img
src="/marc-mintel.png"
alt="Marc Mintel"
className="object-cover grayscale transition-all duration-1000 ease-in-out scale-110 group-hover:scale-100 group-hover:grayscale-0 w-full h-full"
/>
</div>
</div>
</div>
</Reveal>
<div className="space-y-3 md:space-y-6 max-w-3xl">
<Reveal delay={0.1}>
<div className="flex items-center justify-center gap-2 md:gap-4 mb-1 md:mb-4">
<div className="h-px w-6 md:w-8 bg-slate-900"></div>
<MonoLabel className="text-slate-900 text-[10px] md:text-sm">
Digital Architect
</MonoLabel>
<div className="h-px w-6 md:w-8 bg-slate-900"></div>
</div>
</Reveal>
<Reveal delay={0.2}>
<H1 className="text-4xl md:text-8xl leading-none tracking-tighter">
Über <span className="text-slate-400">mich.</span>
</H1>
</Reveal>
<Reveal delay={0.3}>
<p className="text-slate-400 font-medium max-w-xl mx-auto text-sm md:text-xl">
15 Jahre Erfahrung. Ein Ziel: Websites, die ihre Versprechen
halten.
</p>
</Reveal>
</div>
</div>
</Container>
{/* Connector to first section */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-12 md:h-16 bg-gradient-to-b from-transparent to-slate-200" />
</section>
{/* Section 01: Story */}
<Section
number="01"
title="Erfahrung"
borderTop
illustration={<ExperienceIllustration className="w-24 h-24" />}
>
<div className="space-y-8 md:space-y-12">
<Reveal>
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
Vom Designer <br />
<span className="text-slate-400">zum Architekten.</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
<Reveal delay={0.1}>
<div className="space-y-6 md:space-y-8">
<LeadText className="text-xl md:text-2xl text-slate-400">
Agenturen, Konzerne, Startups – ich habe die Branche von allen
Seiten kennengelernt. Was hängen geblieben ist:{" "}
<span className="text-slate-900">
<Marker delay={0.2}>Ergebnisse</Marker> zählen. Nicht der
Weg dorthin.
</span>
</LeadText>
<IconList className="space-y-4">
{[
"Frontend, Backend, Infrastruktur – Fullstack",
"Komplexe Systeme auf das Wesentliche reduziert",
"Performance-Probleme systematisch gelöst",
].map((item, i) => (
<IconListItem key={i} bullet>
<BodyText className="text-lg">{item}</BodyText>
</IconListItem>
))}
</IconList>
</div>
</Reveal>
<Reveal delay={0.2}>
<Card
variant="gray"
hover={false}
padding="normal"
className="group"
>
<H4 className="text-xl mb-6">
Heute: Direkte Zusammenarbeit ohne Reibungsverluste.
</H4>
<div className="flex flex-wrap gap-3">
{["Effizient", "Pragmatisch", "Verlässlich"].map((tag, i) => (
<span
key={i}
className="px-4 py-2 bg-white border border-slate-200 rounded-full shadow-sm"
>
<Label className="text-slate-900">{tag}</Label>
</span>
))}
</div>
</Card>
</Reveal>
</div>
</div>
</Section>
{/* Section 02: Arbeitsweise – HOW I work */}
<Section
number="02"
title="Arbeitsweise"
variant="gray"
borderTop
illustration={<ResponsibilityIllustration className="w-24 h-24" />}
effects={<GradientMesh variant="subtle" className="opacity-60" />}
>
<div className="space-y-12">
<Reveal>
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
So läuft ein Projekt <br />
<span className="text-slate-400">bei mir ab.</span>
</H3>
</Reveal>
{/* Timeline Steps */}
<div className="space-y-1 relative">
{/* Connecting line */}
<div className="absolute left-[15px] top-8 bottom-8 w-px bg-slate-200 hidden md:block" />
{[
{
step: "01",
title: "Briefing",
desc: "Sie beschreiben Ihr Vorhaben. Ich höre zu und stelle die richtigen Fragen.",
},
{
step: "02",
title: "Angebot",
desc: "Ein Fixpreis-Angebot mit klarem Leistungsumfang. Keine Überraschungen.",
},
{
step: "03",
title: "Umsetzung",
desc: "Schnelle Iterationen. Sie sehen regelmäßig den Fortschritt und geben Feedback.",
},
{
step: "04",
title: "Launch",
desc: "Go-Live mit automatisiertem Deployment. Dokumentiert und übergabereif.",
},
].map((item, i) => (
<Reveal key={i} delay={0.1 + i * 0.1}>
<div className="flex gap-4 md:gap-6 py-2 md:py-6 group">
<div className="relative z-10 shrink-0">
<div className="w-8 h-8 rounded-full bg-white border border-slate-200 flex items-center justify-center group-hover:border-slate-400 group-hover:shadow-md transition-all duration-500">
<span className="text-[9px] font-mono font-bold text-slate-400 group-hover:text-slate-900 transition-colors">
{item.step}
</span>
</div>
</div>
<div className="space-y-1 md:space-y-2 pt-1">
<H4 className="text-base md:text-xl font-bold">
{item.title}
</H4>
<BodyText className="text-slate-500 text-sm md:text-base">
{item.desc}
</BodyText>
</div>
</div>
</Reveal>
))}
</div>
</div>
</Section>
{/* Section 03: Garantie – The Pledge */}
<Section number="03" title="Garantie" borderTop>
<div className="relative">
<Reveal>
<div className="max-w-4xl text-left space-y-12 md:space-y-16 py-8 md:py-16">
<H3 className="text-3xl md:text-6xl leading-tight">
Ich stehe für <br />
<span className="text-slate-400">meine Arbeit gerade.</span>
</H3>
<div className="prose prose-lg md:prose-2xl text-slate-500 leading-relaxed">
<p>
Keine Hierarchien. Keine Ausreden. Wenn etwas nicht passt,
liegt die Verantwortung bei mir.
</p>
<p>
Ich liefere nicht nur Code, sondern{" "}
<span className="text-slate-900 font-medium relative inline-block">
Ergebnisse
<svg
className="absolute -bottom-2 left-0 w-full h-3 text-blue-500/30"
viewBox="0 0 100 10"
preserveAspectRatio="none"
>
<path
d="M0 5 Q 50 10 100 5"
stroke="currentColor"
strokeWidth="2"
fill="none"
/>
</svg>
</span>
, auf die Sie bauen können.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl text-left">
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
<h4 className="font-bold text-slate-900 mb-2">
Fixpreis-Garantie
</h4>
<p className="text-slate-500 text-sm">
Keine versteckten Kosten. Der vereinbarte Preis ist final.
</p>
</div>
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
<h4 className="font-bold text-slate-900 mb-2">
Satisfaction Guarantee
</h4>
<p className="text-slate-500 text-sm">
Wir gehen erst live, wenn Sie zu 100% zufrieden sind.
</p>
</div>
</div>
<div className="pt-8 md:pt-12 flex flex-col items-start">
<div className="w-64 md:w-80">
<Signature delay={0.5} />
</div>
</div>
</div>
</Reveal>
</div>
</Section>
{/* Section 04: CTA */}
<Section
number="04"
title="Kontakt"
variant="gray"
borderTop
illustration={<ContactIllustration className="w-24 h-24" />}
effects={<GradientMesh variant="metallic" className="opacity-60" />}
>
<div className="space-y-10 md:space-y-12">
<Reveal>
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
Bereit für eine <br />
<span className="text-slate-400">Zusammenarbeit?</span>
</H3>
</Reveal>
<Card
variant="glass"
hover={false}
padding="normal"
techBorder
className="rounded-3xl shadow-xl relative overflow-hidden group"
>
<div className="relative z-10 space-y-6 md:space-y-8">
<LeadText className="text-lg md:text-4xl leading-tight max-w-2xl text-slate-400">
Lassen Sie uns gemeinsam etwas bauen, das{" "}
<span className="text-slate-900">
wirklich <Marker delay={0.3}>funktioniert.</Marker>
</span>
</LeadText>
<div className="pt-2 md:pt-4">
<Button href="/contact" className="w-full md:w-auto">
Projekt anfragen
</Button>
</div>
</div>
</Card>
</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,
},
);
}
-136
View File
@@ -1,136 +0,0 @@
import * as React from "react";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { getPayloadHMR } from "@payloadcms/next/utilities";
import configPromise from "@payload-config";
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 { PayloadRichText } from "@/src/components/PayloadRichText";
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) {
const payload = await getPayloadHMR({ config: configPromise });
const redirectDoc = await payload.find({
collection: "redirects",
where: {
from: { equals: slug },
},
});
if (redirectDoc.docs.length > 0) {
redirect(`/blog/${redirectDoc.docs[0].to}`);
}
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 />
{post.lexicalContent ? (
<PayloadRichText data={post.lexicalContent} />
) : (
<MDXContent code={post.body.code} />
)}
</div>
</Reveal>
</div>
</Section>
</main>
<TextSelectionShare />
</div>
);
}
-14
View File
@@ -1,14 +0,0 @@
import { getAllPosts } from "@/src/lib/posts";
import { BlogClient } from "@/src/components/blog/BlogClient";
import type { Metadata } from "next";
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",
template: "%s | Marc Mintel",
},
description:
"Technical problem solver's blog - practical insights and learning notes",
metadataBase: new URL("https://mintel.me"),
icons: {
icon: "/favicon.svg",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" 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,
},
);
}
-327
View File
@@ -1,327 +0,0 @@
"use client";
import {
ComparisonRow,
ConceptCode,
ConceptCommunication,
ConceptPrice,
ConceptPrototyping,
ConceptWebsite,
} from "@/src/components/Landing";
import { Reveal } from "@/src/components/Reveal";
import { Section } from "@/src/components/Section";
import {
H1,
H3,
LeadText,
BodyText,
MonoLabel,
Label,
} from "@/src/components/Typography";
import { Card, Container } from "@/src/components/Layout";
import { Button } from "@/src/components/Button";
import { GradientMesh, CodeSnippet } from "@/src/components/Effects";
import { IconList, IconListItem } from "@/src/components/IconList";
import { HeroSection } from "@/src/components/HeroSection";
import { GlitchText } from "@/src/components/GlitchText";
import { Marker } from "@/src/components/Marker";
import { PenCircle } from "@/src/components/PenCircle";
export default function LandingPage() {
return (
<div className="flex flex-col bg-white overflow-hidden relative">
{/* Dark Hero */}
<HeroSection />
{/* Rest of page on white */}
{/* Section 02: The Promise – Streamlined */}
<Section
number="02"
title="Das Versprechen"
borderTop
effects={<GradientMesh variant="metallic" className="opacity-70" />}
>
<div className="space-y-10 md:space-y-16 relative">
<Reveal>
<H3 className="max-w-3xl">
Kein Agentur-Zirkus. <br />
<Marker delay={0.3}>Ergebnisse.</Marker>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 relative z-10">
{[
{
icon: <ConceptCommunication className="w-8 h-8" />,
title: "Direkte Kommunikation",
text: "Sie sprechen mit dem Entwickler. Keine Stille Post, keine Umwege.",
},
{
icon: <ConceptPrototyping className="w-8 h-8" />,
title: "Schnelle Umsetzung",
text: "Sichtbare Fortschritte in Tagen. Prototypen statt Konzeptpapiere.",
},
{
icon: <ConceptCode className="w-8 h-8" />,
title: "Sauberer Code",
text: "Maßgeschneiderte Architektur. Kein Baukasten, kein Plugin-Chaos.",
},
{
icon: <ConceptPrice className="w-8 h-8" />,
title: "Klare Fixpreise",
text: "Volle Budgetsicherheit. Keine versteckten Kosten.",
},
].map((item, i) => (
<Reveal key={i} delay={0.1 + i * 0.1}>
<Card
variant="glass"
padding="normal"
techBorder
className="group"
>
<div className="space-y-4 relative z-10">
<div className="w-12 h-12 rounded-xl bg-slate-50 border border-slate-100 flex items-center justify-center group-hover:scale-110 transition-transform duration-500">
{item.icon}
</div>
<Label className="text-slate-900">{item.title}</Label>
<BodyText className="text-slate-500">{item.text}</BodyText>
</div>
</Card>
</Reveal>
))}
</div>
</div>
</Section>
{/* Section 03: The Difference – Visual Comparison */}
<Section number="03" title="Der Unterschied" variant="white" borderTop>
<div className="space-y-10 md:space-y-16 relative">
<Reveal>
<H3 className="max-w-3xl">
Ich arbeite für das Ergebnis, <br />
nicht gegen die <Marker delay={0.4}>Uhr.</Marker>
</H3>
</Reveal>
<div className="grid grid-cols-1 gap-8 relative z-20">
<ComparisonRow
negativeLabel="Klassisch"
negativeText="Wochen in Planung, bevor eine einzige Zeile Code geschrieben wird."
positiveLabel="Mein Weg"
positiveText={
<>
Schnelle Prototypen. Ergebnisse in{" "}
<PenCircle delay={0.5}>Tagen</PenCircle>, nicht Monaten.
</>
}
delay={0.1}
/>
<ComparisonRow
negativeLabel="Klassisch"
negativeText="Unvorhersehbare Kosten durch Stundenabrechnungen."
positiveLabel="Mein Weg"
positiveText={
<>
<PenCircle delay={0.5}>Fixpreise.</PenCircle> Sie wissen von
Anfang an, was es kostet.
</>
}
reverse
delay={0.2}
/>
</div>
</div>
</Section>
{/* Section 04: Target Group */}
<Section number="04" title="Für wen" borderTop>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 relative z-10">
<Reveal>
<Card variant="glass" padding="normal" techBorder className="group">
<div className="space-y-4 md:space-y-6 relative overflow-hidden">
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center">
<ConceptPrice className="w-6 h-6 md:w-8 md:h-8" />
</div>
<H3 className="text-xl md:text-3xl">
Unternehmer & <br />
Geschäftsführer
</H3>
<LeadText className="text-slate-400 text-base md:text-lg">
Sie wollen eine Website, die funktioniert – ohne sich mit
Technik beschäftigen zu müssen.
</LeadText>
</div>
<div className="pt-6 md:pt-8 border-t border-slate-50 mt-6 md:mt-8">
<Label className="group-hover:text-slate-900 transition-colors">
Perfekt für Sie
</Label>
</div>
</Card>
</Reveal>
<Reveal delay={0.2}>
<Card variant="glass" padding="normal" techBorder className="group">
<div className="space-y-6 relative overflow-hidden">
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center">
{/* Icon placeholder or same as above if needed */}
<ConceptWebsite className="w-6 h-6 md:w-8 md:h-8" />
</div>
<H3 className="text-xl md:text-3xl">
Marketing & <br />
Vertrieb
</H3>
<LeadText className="text-slate-400 text-base md:text-lg">
Sie brauchen Landingpages und Tools, die Ergebnisse liefern.
Schnell und zuverlässig.
</LeadText>
</div>
<div className="pt-6 md:pt-8 border-t border-slate-50 mt-6 md:mt-8">
<Label className="group-hover:text-slate-900 transition-colors">
Perfekt für Sie
</Label>
</div>
</Card>
</Reveal>
</div>
</Section>
{/* Section 05: Leistungen — Interactive Service Rows */}
<Section number="05" title="Leistungen" variant="gray" borderTop>
<div className="space-y-0 relative z-20">
{[
{
num: "01",
binary: "00000001",
title: "Websites",
text: "High-Performance Websites mit maßgeschneiderter Architektur. Von der Konzeption bis zum Go-Live — individuell, schnell, messbar.",
tags: ["Next.js", "React", "TypeScript", "Performance"],
href: "/websites",
},
{
num: "02",
binary: "00000010",
title: "Systeme",
text: "Web-Applikationen und interne Tools, wenn Standard-Software nicht reicht. Dashboards, Portale, Automatisierungen.",
tags: ["Full-Stack", "APIs", "Datenbanken", "Auth"],
href: "/contact",
},
{
num: "03",
binary: "00000011",
title: "Automatisierung",
text: "Verbindung von Tools, automatische Prozesse, Daten-Synchronisation. Weniger manuelle Arbeit, mehr Effizienz.",
tags: ["CI/CD", "Workflows", "Integrationen", "Monitoring"],
href: "/contact",
},
].map((service, i) => (
<Reveal key={i} delay={0.1 + i * 0.15}>
<div className="group py-8 md:py-16 border-b border-slate-100 last:border-b-0 cursor-pointer transition-all duration-500">
<div className="flex flex-col md:flex-row md:items-start gap-6 md:gap-16">
{/* Number + Binary */}
<div className="shrink-0 flex md:block items-baseline gap-4">
<span className="text-4xl md:text-6xl font-black text-slate-100 group-hover:text-slate-200 transition-colors duration-500 tracking-tighter block leading-none">
{service.num}
</span>
<span
className="text-[8px] font-mono text-slate-200 tracking-[0.3em] mt-2 block select-none group-hover:text-blue-300 transition-colors duration-700 leading-none"
aria-hidden="true"
>
{service.binary}
</span>
</div>
{/* Content */}
<div className="flex-1 space-y-4 md:space-y-6">
<H3 className="text-xl md:text-4xl group-hover:translate-x-2 transition-transform duration-500">
<GlitchText
trigger="inView"
delay={0.2 + i * 0.15}
duration={0.6}
>
{service.title}
</GlitchText>
</H3>
<BodyText className="text-slate-400 text-sm md:text-base max-w-xl group-hover:text-slate-500 transition-colors duration-500">
{service.text}
</BodyText>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{service.tags.map((tag, j) => (
<span
key={j}
className="px-3 py-1 text-[8px] md:text-[9px] font-mono uppercase tracking-widest text-slate-400 border border-slate-100 rounded-full bg-white/50 group-hover:border-slate-200 group-hover:text-slate-500 transition-all duration-500"
>
{tag}
</span>
))}
</div>
</div>
{/* Arrow */}
<div className="md:self-center shrink-0 pt-4 md:pt-0">
<Button
href={service.href}
variant="ghost"
size="normal"
showArrow
className="w-full md:w-auto"
>
Details
</Button>
</div>
</div>
</div>
</Reveal>
))}
</div>
</Section>
{/* Section 06: Contact */}
<Section number="06" title="Kontakt" borderTop>
<div className="relative py-4 md:py-12" id="contact">
<Reveal>
<div className="space-y-8 md:space-y-16">
<H1 className="text-3xl md:text-8xl">
Lassen Sie uns <br />
<span className="text-slate-400">starten.</span>
</H1>
<div className="flex flex-col md:flex-row gap-6 md:gap-16 items-start relative z-10">
<div className="space-y-4 md:space-y-8 flex-1">
<LeadText className="text-lg md:text-3xl text-slate-400">
Beschreiben Sie kurz Ihr Vorhaben. Ich melde mich{" "}
<span className="text-slate-900 border-b-2 border-slate-900/10">
<Marker>zeitnah</Marker>
</span>{" "}
bei Ihnen.
</LeadText>
<div className="pt-2 md:pt-4">
<Button
href="/contact"
size="large"
className="w-full md:w-auto"
>
Projekt anfragen
</Button>
</div>
</div>
<div className="w-full md:w-72 space-y-4 md:space-y-6 p-6 glass rounded-2xl border border-slate-100">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<Label className="text-slate-900">Verfügbarkeit</Label>
</div>
<BodyText className="text-sm md:text-base leading-snug">
Aktuell nehme ich Projekte für{" "}
<span className="font-bold text-slate-900">Q2 2026</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,
}));
}
-375
View File
@@ -1,375 +0,0 @@
"use client";
import { Reveal } from "@/src/components/Reveal";
import { Section } from "@/src/components/Section";
import {
SpeedPerformance,
SolidFoundation,
LayerSeparation,
TaskDone,
} from "@/src/components/Landing";
import {
H3,
LeadText,
BodyText,
Label,
MonoLabel,
} from "@/src/components/Typography";
import { Card } from "@/src/components/Layout";
import { Button } from "@/src/components/Button";
import { IconList, IconListItem } from "@/src/components/IconList";
import {
GradientMesh,
CodeSnippet,
AbstractCircuit,
CMSVisualizer,
ArchitectureVisualizer,
ResultVisualizer,
} from "@/src/components/Effects";
import { Marker } from "@/src/components/Marker";
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-12 md:space-y-24">
<div className="space-y-6 md:space-y-10 max-w-5xl">
<Reveal>
<div className="space-y-4">
<MonoLabel className="text-blue-500 tracking-[0.2em] text-[10px] md:text-xs">
SYSTEM ENGINEERING
</MonoLabel>
<H3 className="text-4xl md:text-8xl leading-[1.0] tracking-tighter">
Websites, die einfach <br />
<span className="text-slate-400">
<Marker>funktionieren.</Marker>
</span>
</H3>
</div>
</Reveal>
<Reveal delay={0.2}>
<LeadText className="text-lg md:text-2xl max-w-2xl text-slate-500 md:text-slate-400 leading-relaxed">
Kein Baukasten. Kein Plugin-Chaos. Maßgeschneiderte Architektur
für{" "}
<span className="text-slate-900 font-bold underline decoration-slate-200 underline-offset-8">
maximale Performance
</span>
.
</LeadText>
</Reveal>
</div>
<div className="space-y-12">
<Reveal delay={0.3} direction="up">
<ArchitectureVisualizer />
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
label: "Next.js",
sub: "Architecture",
desc: "React-Framework für maximale SEO & Speed.",
},
{
label: "Docker",
sub: "Infrastructure",
desc: "Reproduzierbare Umgebungen überall.",
},
{
label: "Directus",
sub: "Management",
desc: "Headless CMS für flexible Datenabfrage.",
},
{
label: "Gitea",
sub: "Pipeline",
desc: "Self-hosted Git & CI/CD Pipelines.",
},
].map((item, i) => (
<Reveal key={i} delay={0.4 + i * 0.1}>
<div className="space-y-2 p-6 rounded-2xl border border-slate-50 bg-white shadow-sm hover:border-slate-200 transition-all group">
<Label className="text-slate-900 group-hover:text-blue-600 transition-colors uppercase tracking-widest text-[10px]">
{item.label}
</Label>
<BodyText className="text-xs text-slate-400">
{item.desc}
</BodyText>
</div>
</Reveal>
))}
</div>
</div>
</div>
</Section>
{/* 02: Performance */}
<Section
number="02"
title="Performance"
borderTop
variant="gray"
illustration={<SpeedPerformance className="w-24 h-24" />}
effects={<GradientMesh variant="metallic" className="opacity-60" />}
>
<div className="space-y-8 md:space-y-12">
<Reveal>
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
Geschwindigkeit ist <br />
<span className="text-slate-400">
kein Extra. Sie ist <Marker delay={0.3}>Standard.</Marker>
</span>
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-12 items-center">
<div className="md:col-span-12 lg:col-span-7 space-y-6 md:space-y-8">
<Reveal delay={0.2}>
<LeadText className="text-lg md:text-xl text-slate-500 md:text-slate-400">
Jede Seite wird vorab gerendert und über ein CDN ausgeliefert.
Das Ergebnis: Ladezeiten unter einer Sekunde. Messbar.{" "}
<span className="text-slate-900">Reproduzierbar.</span>
</LeadText>
</Reveal>
<Reveal delay={0.4}>
<IconList className="space-y-2 md:space-y-4">
{[
"Server-Side Rendering für sofortige Inhalte",
"Automatische Bild-Optimierung (WebP, AVIF)",
"Lighthouse-Score 90+ als Mindeststandard",
"Core Web Vitals im grünen Bereich",
].map((item, i) => (
<IconListItem key={i} bullet>
<LeadText className="text-base md:text-xl">
{item}
</LeadText>
</IconListItem>
))}
</IconList>
</Reveal>
</div>
<div className="md:col-span-5">
<Reveal delay={0.6}>
<Card
variant="glass"
padding="normal"
techBorder
className="text-center group py-10 md:py-12"
>
<div className="text-5xl md:text-8xl font-bold text-slate-900 tracking-tighter group-hover:scale-110 transition-transform duration-700">
90+
</div>
<Label className="mt-4">Lighthouse Score</Label>
<span className="block text-[8px] md:text-[9px] font-mono text-slate-300 mt-2 tracking-wider">
PERFORMANCE · ACCESSIBILITY · SEO
</span>
</Card>
</Reveal>
</div>
</div>
</div>
</Section>
{/* 03: Code-Qualität */}
<Section
number="03"
title="Code"
borderTop
illustration={<SolidFoundation className="w-24 h-24" />}
>
<div className="space-y-8 md:space-y-12">
<Reveal>
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
Keine Plugins. <br />
<span className="text-slate-400">Keine Abhängigkeiten.</span>
</H3>
</Reveal>
<Reveal delay={0.2}>
<LeadText className="text-xl md:text-2xl max-w-2xl text-slate-500 md:text-slate-400">
Ihre Website besteht aus{" "}
<span className="text-slate-900">Ihrem Code</span>. Kein
WordPress, kein Wix, keine Blackbox. Alles versioniert, alles
nachvollziehbar.
</LeadText>
</Reveal>
{/* Git Branch Visualization */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Reveal delay={0.4}>
<CodeSnippet variant="git" />
</Reveal>
<Reveal delay={0.5}>
<div className="space-y-4 md:space-y-6">
<Card variant="glass" padding="normal" className="group">
<div className="space-y-2">
<Label className="text-slate-900">Versionskontrolle</Label>
<BodyText>
Jede Änderung ist dokumentiert. Rollbacks in Sekunden.
Kein „wer hat das kaputt gemacht?".
</BodyText>
</div>
</Card>
<Card variant="glass" padding="normal" className="group">
<div className="space-y-2">
<Label className="text-slate-900">
Automatisches Deployment
</Label>
<BodyText>
Code wird geprüft, getestet und automatisch live
geschaltet. Ohne manuellen Eingriff.
</BodyText>
</div>
</Card>
</div>
</Reveal>
</div>
</div>
</Section>
{/* 04: Content-System */}
<Section
number="04"
title="Inhalte"
borderTop
variant="gray"
illustration={<LayerSeparation className="w-24 h-24" />}
effects={<GradientMesh variant="subtle" className="opacity-60" />}
>
<div className="space-y-12 md:space-y-20">
<div className="space-y-6 md:space-y-10 max-w-5xl">
<Reveal>
<div className="space-y-4">
<MonoLabel className="text-blue-500 tracking-[0.2em] text-[10px] md:text-xs">
ARCHITECTURAL SEPARATION
</MonoLabel>
<H3 className="text-4xl md:text-7xl leading-[1.1] tracking-tighter">
Inhalte pflegen <br />
<span className="text-slate-400 italic font-serif">
ohne Angst.
</span>
</H3>
</div>
</Reveal>
<Reveal delay={0.2}>
<div className="space-y-6">
<LeadText className="text-lg md:text-2xl text-slate-500 md:text-slate-400 leading-relaxed max-w-3xl">
Vergessen Sie zerschossene Layouts nach einem Textupdate.
Meine Websites trennen{" "}
<span className="text-slate-900 font-bold underline decoration-blue-500/30 underline-offset-8">
Daten von Design
</span>
.
</LeadText>
</div>
</Reveal>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
<div className="lg:col-span-8 relative">
<Reveal delay={0.4} direction="up">
<CMSVisualizer className="w-full mx-auto" />
</Reveal>
</div>
<div className="lg:col-span-4 space-y-8">
<Reveal delay={0.5}>
<div className="space-y-4">
<BodyText className="text-slate-500 leading-relaxed">
Durch eine krisenfeste Headless-Architektur (Directus)
bewegen Sie sich in einem geschützten Sandkasten – während
das Frontend-System die visuelle Integrität Ihrer Marke
garantiert.
</BodyText>
<div className="flex flex-wrap gap-3">
{["Layout-Schutz", "Live-Vorschau", "Role-RBAC"].map(
(tag, i) => (
<div
key={i}
className="px-3 py-1 bg-white border border-slate-100 rounded-full text-[10px] font-mono text-slate-400"
>
{tag}
</div>
),
)}
</div>
</div>
</Reveal>
<Reveal delay={0.6}>
<div className="p-6 bg-slate-900 rounded-2xl shadow-xl text-[10px] font-mono text-white/50 space-y-3">
<div className="flex justify-between items-center text-white/90">
<span>PROTOCOL</span>
<span className="text-green-500 font-bold">ENFORCED</span>
</div>
<p className="leading-tight">
Website architecture validates all CMS payloads against the
design schema before rendering.
</p>
<div className="pt-2 border-t border-white/10 flex items-center justify-between">
<span>INTEGRITY</span>
<span className="text-white">100%</span>
</div>
</div>
</Reveal>
</div>
</div>
<Reveal delay={0.7}>
<div className="p-px w-full bg-gradient-to-r from-transparent via-slate-100 to-transparent" />
</Reveal>
</div>
</Section>
{/* 05: Was Sie bekommen */}
<Section
number="05"
title="Ergebnis"
borderTop
illustration={<TaskDone className="w-24 h-24" />}
>
<div className="space-y-12 md:space-y-24">
<div className="max-w-4xl space-y-6">
<Reveal>
<H3 className="text-4xl md:text-7xl leading-[1.1] tracking-tighter">
Was Sie konkret <br />
<span className="text-slate-400">bekommen.</span>
</H3>
</Reveal>
<Reveal delay={0.2}>
<LeadText className="text-lg md:text-2xl text-slate-500 md:text-slate-400 max-w-2xl">
Keine halben Sachen. Ich liefere Ihnen ein schlüsselfertiges
System mit voller Kontrolle und Transparenz.
</LeadText>
</Reveal>
</div>
<Reveal delay={0.3} direction="up">
<ResultVisualizer />
</Reveal>
<Reveal delay={0.5}>
<div className="pt-10 md:pt-16 border-t border-slate-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-12">
<div className="space-y-3">
<MonoLabel className="text-blue-500">
BEREIT FÜR DEN NÄCHSTEN SCHRITT?
</MonoLabel>
<div className="text-xl md:text-3xl font-bold tracking-tight text-slate-900">
Lassen Sie uns über Ihr Projekt sprechen.
</div>
</div>
<Button
href="/contact"
className="w-full md:w-auto h-16 px-10 text-lg rounded-2xl shadow-2xl shadow-blue-500/10"
>
Projekt anfragen
</Button>
</div>
</Reveal>
</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>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import configPromise from "@payload-config";
export const dynamic = "force-dynamic";
/**
* Deep CMS Health Check
* Validates that Payload CMS can actually query the database.
* Used by post-deploy smoke tests to catch migration/schema issues.
*/
export async function GET() {
const checks: Record<string, string> = {};
try {
const payload = await getPayload({ config: configPromise });
checks.init = "ok";
// Verify each collection can be queried (catches missing locale tables, broken migrations)
// Adjusted for mintel.me collections
const collections = ["posts", "projects", "media", "inquiries"] as const;
for (const collection of collections) {
try {
await payload.find({ collection, limit: 1 });
checks[collection] = "ok";
} catch (e: any) {
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
}
}
const hasErrors = Object.values(checks).some((v) => v.startsWith("error"));
return NextResponse.json(
{ status: hasErrors ? "degraded" : "ok", checks },
{ status: hasErrors ? 503 : 200 },
);
} catch (e: any) {
return NextResponse.json(
{ status: "error", message: e.message?.substring(0, 200), checks },
{ status: 503 },
);
}
}

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