Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67c13d82f | ||
|
|
ca0eddfde8 | ||
|
|
5b037d7e0c | ||
|
|
6f6e52dd63 | ||
|
|
666b1d7547 | ||
|
|
60f094a754 | ||
|
|
f04e8c8463 |
@@ -477,8 +477,8 @@ jobs:
|
||||
run: |
|
||||
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
# The legal pages must be reachable.
|
||||
for PAGE in /imprint /privacy; do
|
||||
# The legal pages must be reachable in both languages.
|
||||
for PAGE in /impressum /datenschutz /en/imprint /en/privacy; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$PAGE")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ $PAGE failed! (Status: $STATUS)"
|
||||
@@ -488,7 +488,7 @@ jobs:
|
||||
done
|
||||
|
||||
# URLs of the previous site must redirect instead of returning 404.
|
||||
for OLD in /blog /contact /websites /case-studies/klz-cables /impressum; do
|
||||
for OLD in /blog /contact /websites /case-studies/klz-cables; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$OLD")
|
||||
if [ "$STATUS" != "308" ]; then
|
||||
echo "❌ $OLD does not redirect! (Status: $STATUS)"
|
||||
|
||||
@@ -13,8 +13,13 @@ import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
|
||||
import { CareerLine } from "@/src/components/illustrations/CareerLine";
|
||||
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
|
||||
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
|
||||
import {
|
||||
PresentationCompare,
|
||||
ReasonDrawing,
|
||||
} from "@/src/components/illustrations/WebPresentation";
|
||||
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { 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";
|
||||
@@ -51,8 +56,16 @@ const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
|
||||
export default async function LandingPage({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const content = contentFor(locale);
|
||||
const { hello, services, automation, technology, working, work, faq } =
|
||||
content;
|
||||
const {
|
||||
hello,
|
||||
services,
|
||||
automation,
|
||||
presentations,
|
||||
technology,
|
||||
working,
|
||||
work,
|
||||
faq,
|
||||
} = content;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
@@ -110,7 +123,7 @@ export default async function LandingPage({ params }: Props) {
|
||||
<Reveal>
|
||||
<AutomationFlow />
|
||||
</Reveal>
|
||||
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
|
||||
<SwipeRow className="mt-12 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
|
||||
{automation.notes.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
@@ -128,15 +141,87 @@ export default async function LandingPage({ params }: Props) {
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</SwipeRow>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="04"
|
||||
label={presentations.label}
|
||||
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">
|
||||
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
|
||||
{presentations.goodFor.title}
|
||||
</p>
|
||||
<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={technology.label}
|
||||
title={<Title title={technology.title} />}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
|
||||
<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
|
||||
@@ -158,12 +243,12 @@ export default async function LandingPage({ params }: Props) {
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</SwipeRow>
|
||||
|
||||
<Reveal>
|
||||
<div className="mt-16 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
|
||||
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
|
||||
<p className={`${MONO} m-0`}>{technology.techLabel}</p>
|
||||
<div className="mt-8 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<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">
|
||||
@@ -185,7 +270,7 @@ export default async function LandingPage({ params }: Props) {
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-16 md:mt-24">
|
||||
<div className="mt-12 md:mt-24">
|
||||
<Reveal>
|
||||
<h3 className="m-0 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
|
||||
{technology.pipelineTitle}
|
||||
@@ -201,7 +286,7 @@ export default async function LandingPage({ params }: Props) {
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
index="06"
|
||||
label={working.label}
|
||||
title={<Title title={working.title} />}
|
||||
>
|
||||
@@ -212,7 +297,7 @@ export default async function LandingPage({ params }: Props) {
|
||||
</div>
|
||||
</BitAura>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
|
||||
<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">
|
||||
@@ -223,11 +308,11 @@ export default async function LandingPage({ params }: Props) {
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</SwipeRow>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
index="07"
|
||||
label={work.label}
|
||||
tone="dark"
|
||||
title={<Title title={work.title} muted="text-slate-500" />}
|
||||
@@ -240,7 +325,7 @@ export default async function LandingPage({ params }: Props) {
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block index="07" label={faq.label} title={<Title title={faq.title} />}>
|
||||
<Block index="08" label={faq.label} title={<Title title={faq.title} />}>
|
||||
<Reveal>
|
||||
<Faq items={faq.items} />
|
||||
</Reveal>
|
||||
|
||||
@@ -1224,6 +1224,87 @@ pre:has(code[class*="language-"]) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The contact address on a dark surface: a slow sheen runs through the letters
|
||||
* and a light travels along the underline. On hover the whole line lights up.
|
||||
*/
|
||||
.bit-mail {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 0.42em;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
#ffffff 0 38%,
|
||||
#a5b4fc 50%,
|
||||
#ffffff 62% 100%
|
||||
);
|
||||
background-size: 260% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: bit-mail-sheen 7s ease-in-out infinite;
|
||||
transition: filter 300ms ease;
|
||||
}
|
||||
.bit-mail::before,
|
||||
.bit-mail::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The resting underline. */
|
||||
.bit-mail::before {
|
||||
bottom: 1px;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
/* The light travelling along it. */
|
||||
.bit-mail::after {
|
||||
bottom: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#818cf8 30%,
|
||||
#ffffff 50%,
|
||||
#818cf8 70%,
|
||||
transparent
|
||||
)
|
||||
no-repeat;
|
||||
background-size: 42% 100%;
|
||||
filter: drop-shadow(0 0 8px rgba(129, 140, 248, 0.95));
|
||||
animation: bit-mail-run 3.8s ease-in-out infinite alternate;
|
||||
transition: background-size 400ms ease;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.bit-mail:hover {
|
||||
filter: drop-shadow(0 0 18px rgba(129, 140, 248, 0.7));
|
||||
}
|
||||
.bit-mail:hover::after {
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
@keyframes bit-mail-sheen {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
50% {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
}
|
||||
@keyframes bit-mail-run {
|
||||
from {
|
||||
background-position: -30% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 130% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slow light drifting over a dark picture (see PortraitSection). */
|
||||
.bit-aurora {
|
||||
position: absolute;
|
||||
@@ -1271,6 +1352,38 @@ pre:has(code[class*="language-"]) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* SwipeRow: on phones equal items stand side by side and are swiped through,
|
||||
* with the next one peeking in. From tablet width on the slide wrappers
|
||||
* dissolve and the items sit in the grid the row was given.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.swipe-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x mandatory;
|
||||
/* Bleed to the screen edges, but start in line with the text. */
|
||||
margin-inline: -1.25rem;
|
||||
padding-inline: 1.25rem;
|
||||
scroll-padding-inline: 1.25rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.swipe-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.swipe-slide {
|
||||
flex: 0 0 82%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.swipe-slide {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Native feel: the page should behave like a well-made app on every input type.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Renders the share images (Open Graph and Twitter) from the real hero, one per
|
||||
// language, so they always match the site. Run it after the hero headline or its
|
||||
// design changes:
|
||||
// language, so they always match the site. The hero is shown the way the dark
|
||||
// sections of the site look: light type on a dark surface, a lit edge, and
|
||||
// binary digits coming off it. Run it after the hero headline or its design
|
||||
// changes:
|
||||
//
|
||||
// pnpm --filter @mintel/web share-image (uses the live site)
|
||||
// pnpm --filter @mintel/web share-image http://localhost:3000
|
||||
@@ -37,7 +39,9 @@ for (const shot of SHOTS) {
|
||||
main > div > section:first-of-type .grid { margin-top: 0 !important; display: block !important; }
|
||||
main > div > section:first-of-type h1 span:nth-of-type(2) { color: rgba(15, 23, 42, 0.2) !important; }
|
||||
main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
|
||||
html, body { overflow: hidden !important; }
|
||||
html, body { overflow: hidden !important; background: #000 !important; }
|
||||
/* Dark version of the hero: inverted, with the hues turned back. */
|
||||
main > div > section:first-of-type { filter: invert(1) hue-rotate(180deg) !important; }
|
||||
`,
|
||||
});
|
||||
// Let the page finish starting up first; it would discard the added labels.
|
||||
@@ -54,6 +58,66 @@ for (const shot of SHOTS) {
|
||||
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
|
||||
section.style.position = "relative";
|
||||
section.append(tag, site);
|
||||
|
||||
// Everything below lies over the inverted hero and is not inverted itself.
|
||||
const W = 1200;
|
||||
const H = 630;
|
||||
const layer = (css) => {
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText = `position:absolute;left:0;top:0;width:${W}px;height:${H}px;z-index:60;pointer-events:none;${css}`;
|
||||
document.body.append(el);
|
||||
return el;
|
||||
};
|
||||
document.body.style.position = "relative";
|
||||
// Black becomes the slate of the site's dark sections; light type stays light.
|
||||
layer("background:#0f172a;mix-blend-mode:screen");
|
||||
// Light coming from the lower right corner.
|
||||
layer(
|
||||
"mix-blend-mode:screen;background:radial-gradient(58% 62% at 92% 108%, rgba(99,102,241,.85), transparent 70%),radial-gradient(34% 40% at 104% -6%, rgba(129,140,248,.4), transparent 70%)",
|
||||
);
|
||||
// A frame whose lower right edge is lit, with a soft glow behind it.
|
||||
const INSET = 22;
|
||||
const edge = (blur, width, opacity) => {
|
||||
const el = layer(
|
||||
`left:${INSET}px;top:${INSET}px;width:${W - 2 * INSET}px;height:${H - 2 * INSET}px;box-sizing:border-box;border-radius:30px;padding:${width}px;opacity:${opacity};filter:blur(${blur}px);` +
|
||||
"background:conic-gradient(from 60deg at 50% 50%, transparent 0 8%, rgba(129,140,248,.95) 22%, #fff 30%, rgba(129,140,248,.95) 40%, transparent 56% 100%);" +
|
||||
"-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
|
||||
);
|
||||
return el;
|
||||
};
|
||||
layer(
|
||||
`left:${INSET}px;top:${INSET}px;width:${W - 2 * INSET}px;height:${H - 2 * INSET}px;box-sizing:border-box;border-radius:30px;border:1px solid rgba(165,180,252,.16)`,
|
||||
);
|
||||
edge(26, 14, 0.9);
|
||||
edge(8, 5, 0.9);
|
||||
edge(0, 2, 1);
|
||||
|
||||
// Digits coming off the lit edge. Seeded, so the picture is the same on every run.
|
||||
let seed = 20261004;
|
||||
const random = () => {
|
||||
seed = (seed * 1664525 + 1013904223) % 4294967296;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
const digits = layer(
|
||||
"font:500 15px var(--font-jetbrains-mono), ui-monospace, Menlo, monospace;color:#c7d2fe",
|
||||
);
|
||||
for (let i = 0; i < 150; i++) {
|
||||
// Along the bottom edge (right half) and up the right edge (lower half).
|
||||
const alongBottom = random() < 0.62;
|
||||
const spread = Math.pow(random(), 1.8);
|
||||
const x = alongBottom ? W * 0.42 + random() * (W * 0.58 - INSET - 30) : 0;
|
||||
// Under the lead text the digits stay close to the edge.
|
||||
const reach = spread * (alongBottom && x < 820 ? 54 : 120);
|
||||
const left = alongBottom ? x : W - INSET - 14 - reach;
|
||||
const y = alongBottom
|
||||
? H - INSET - 16 - reach
|
||||
: H * 0.3 + random() * (H * 0.7 - INSET - 30);
|
||||
const digit = document.createElement("span");
|
||||
digit.textContent = random() < 0.5 ? "0" : "1";
|
||||
const big = random() < 0.2;
|
||||
digit.style.cssText = `position:absolute;left:${left.toFixed(1)}px;top:${y.toFixed(1)}px;opacity:${((1 - spread) * (0.35 + random() * 0.65)).toFixed(2)};font-size:${big ? 19 : 14}px;font-weight:${big ? 700 : 500}`;
|
||||
digits.append(digit);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await page.waitForTimeout(4500);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 277 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 278 KiB |
@@ -56,10 +56,18 @@ export const AbstractCircuit: React.FC<{
|
||||
// ── Seed characters ──
|
||||
const LANE = 28;
|
||||
const GAP = 20;
|
||||
const PHONE_WIDTH = 640;
|
||||
const PHONE_INK = 2.4;
|
||||
const PHONE_GROW = 2;
|
||||
|
||||
const seed = () => {
|
||||
const chars: Char[] = [];
|
||||
const { w, h } = s;
|
||||
// On a phone the field is small and held close: the digits need more
|
||||
// size and ink there, or they are not seen at all.
|
||||
const small = w < PHONE_WIDTH;
|
||||
const ink = small ? PHONE_INK : 1;
|
||||
const grow = small ? PHONE_GROW : 0;
|
||||
|
||||
// Horizontal lanes
|
||||
for (let y = 0; y < h; y += LANE) {
|
||||
@@ -73,8 +81,8 @@ export const AbstractCircuit: React.FC<{
|
||||
val: Math.random() > 0.5 ? 1 : 0,
|
||||
speed: spd,
|
||||
vertical: false,
|
||||
size: 9 + Math.floor(Math.random() * 2),
|
||||
baseAlpha: 0.035 + Math.random() * 0.045,
|
||||
size: 9 + grow + Math.floor(Math.random() * 2),
|
||||
baseAlpha: (0.035 + Math.random() * 0.045) * ink,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
flipIn: 120 + Math.floor(Math.random() * 400),
|
||||
});
|
||||
@@ -93,8 +101,8 @@ export const AbstractCircuit: React.FC<{
|
||||
val: Math.random() > 0.5 ? 1 : 0,
|
||||
speed: spd,
|
||||
vertical: true,
|
||||
size: 8 + Math.floor(Math.random() * 2),
|
||||
baseAlpha: 0.025 + Math.random() * 0.035,
|
||||
size: 8 + grow + Math.floor(Math.random() * 2),
|
||||
baseAlpha: (0.025 + Math.random() * 0.035) * ink,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
flipIn: 150 + Math.floor(Math.random() * 500),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* SwipeRow: on phones the items stand side by side and are swiped through,
|
||||
* with the next one peeking in; from tablet width on they fall back into the
|
||||
* grid given by `className`. It keeps long lists of equal items from
|
||||
* stretching the page on a small screen. Styles: `swipe-row` in globals.css.
|
||||
*/
|
||||
export const SwipeRow: React.FC<{
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ className = "", children }) => (
|
||||
<div className={`swipe-row ${className}`}>
|
||||
{React.Children.map(children, (child) => (
|
||||
<div className="swipe-slide">{child}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -4,8 +4,8 @@ import * as React from "react";
|
||||
|
||||
type AuraSource = "top" | "right" | "bottom" | "left" | "around";
|
||||
type AuraTone = "dark" | "light";
|
||||
/** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it, `pulse` sends rings outward. */
|
||||
type AuraMotion = "sparks" | "rain" | "pulse";
|
||||
/** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it. */
|
||||
type AuraMotion = "sparks" | "rain";
|
||||
|
||||
interface BitAuraProps {
|
||||
/** The edge the energy sits on, or `around` to let it travel round the element. */
|
||||
@@ -53,8 +53,6 @@ interface Palette {
|
||||
core: string;
|
||||
digit: string;
|
||||
gain: number;
|
||||
/** Opacity of the pulse rings at the edge. */
|
||||
ring: number;
|
||||
/** Share of the digit sparks that are kept (1 = all). */
|
||||
density: number;
|
||||
}
|
||||
@@ -86,7 +84,6 @@ const LIGHT_PALETTE: Palette = {
|
||||
digit: "15, 23, 42",
|
||||
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
|
||||
gain: 0,
|
||||
ring: 0.45,
|
||||
density: 0.6,
|
||||
};
|
||||
|
||||
@@ -96,21 +93,16 @@ const DARK_PALETTE: Palette = {
|
||||
core: "224, 231, 255",
|
||||
digit: "199, 210, 254",
|
||||
gain: 1,
|
||||
ring: 0.8,
|
||||
density: 1,
|
||||
};
|
||||
|
||||
const MOTION_DENSITY: Record<AuraMotion, number> = {
|
||||
sparks: 0.45,
|
||||
rain: 0.3,
|
||||
pulse: 0.2,
|
||||
};
|
||||
|
||||
const PULSE_RINGS = 3;
|
||||
const PULSE_PERIOD_S = 4.2;
|
||||
/** How often a drifting digit switches between 0 and 1. */
|
||||
const FLIPS_PER_S = 0.9;
|
||||
const PULSE_REACH = 56;
|
||||
|
||||
const fraction = (value: number) => value - Math.floor(value);
|
||||
|
||||
@@ -150,12 +142,6 @@ const spotsAt = (
|
||||
t: number,
|
||||
box: Box,
|
||||
): readonly Spot[] => {
|
||||
if (motion === "pulse") {
|
||||
return [0, 1, 2, 3].map((corner) => ({
|
||||
at: (corner + 0.5) / 4,
|
||||
power: 0.45 + 0.25 * Math.sin(t * 0.8 - corner * 1.57),
|
||||
}));
|
||||
}
|
||||
if (source === "around") {
|
||||
return [
|
||||
{ at: fraction(t * 0.03), power: 1 },
|
||||
@@ -327,26 +313,6 @@ export const BitAura: React.FC<BitAuraProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (motion === "pulse") {
|
||||
context.setLineDash([3, 7]);
|
||||
context.lineWidth = 1.5;
|
||||
for (let ring = 0; ring < PULSE_RINGS; ring++) {
|
||||
const phase = fraction(clock / PULSE_PERIOD_S + ring / PULSE_RINGS);
|
||||
const grow = phase * PULSE_REACH;
|
||||
context.strokeStyle = `rgba(${palette.core}, ${(1 - phase) ** 2 * palette.ring})`;
|
||||
context.beginPath();
|
||||
context.roundRect(
|
||||
-grow,
|
||||
-grow,
|
||||
box.width + 2 * grow,
|
||||
box.height + 2 * grow,
|
||||
radius + grow,
|
||||
);
|
||||
context.stroke();
|
||||
}
|
||||
context.setLineDash([]);
|
||||
}
|
||||
|
||||
context.fillStyle = `rgb(${palette.digit})`;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
|
||||
@@ -35,6 +35,7 @@ const LIGHT = "255, 255, 255";
|
||||
const INTRO_MS = 1600;
|
||||
const FRAME_MS = 1000 / 30;
|
||||
const POINTER_RADIUS = 170;
|
||||
const MIN_CELL = 6;
|
||||
|
||||
const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
@@ -155,9 +156,11 @@ export const BitHeadline: React.FC<{
|
||||
});
|
||||
|
||||
const fontSize = parseFloat(getComputedStyle(wrap).fontSize) || 100;
|
||||
cell = Math.round(Math.min(9, Math.max(4, fontSize / 19)));
|
||||
// Digits below about 6px are specks, not digits: on phones the cells stay
|
||||
// readable and the letters are built from fewer of them.
|
||||
cell = Math.round(Math.min(9, Math.max(MIN_CELL, fontSize / 19)));
|
||||
// Small type needs a stronger wash under the digits to keep its shape.
|
||||
wash = fontSize < 100 ? 0.2 : 0.07;
|
||||
wash = fontSize < 100 ? 0.24 : 0.07;
|
||||
cols = Math.ceil(width / cell);
|
||||
rows = Math.ceil(height / cell);
|
||||
bits = new Uint8Array(cols * rows);
|
||||
|
||||
@@ -49,7 +49,7 @@ export const Block: React.FC<BlockProps> = ({
|
||||
/>
|
||||
)}
|
||||
{dark && <BitSeam />}
|
||||
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
|
||||
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-14 md:px-12 md:py-36">
|
||||
<Reveal>
|
||||
<div className="mb-8 flex items-center gap-4 md:mb-12">
|
||||
<span
|
||||
@@ -78,7 +78,7 @@ export const Block: React.FC<BlockProps> = ({
|
||||
{title}
|
||||
</h2>
|
||||
</BitWipe>
|
||||
<div className="mt-14 md:mt-24">{children}</div>
|
||||
<div className="mt-10 md:mt-24">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
|
||||
/>
|
||||
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
|
||||
<BitSeam />
|
||||
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
|
||||
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-12 px-5 py-16 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
|
||||
<Reveal className="lg:col-span-6">
|
||||
<BitHeadline
|
||||
as="h2"
|
||||
@@ -38,7 +38,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
|
||||
{contact.lead}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-2xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-4xl" />
|
||||
<EmailLink className="bit-mail font-sans text-3xl font-bold tracking-tight no-underline md:text-5xl" />
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.15} className="lg:col-span-6">
|
||||
|
||||
@@ -92,7 +92,7 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
||||
setStep(index);
|
||||
setAuto(false);
|
||||
}}
|
||||
className={`relative flex w-full gap-5 border-t py-6 text-left transition-colors duration-300 md:gap-6 ${
|
||||
className={`relative flex w-full gap-5 border-t py-4 text-left transition-colors duration-300 md:gap-6 md:py-6 ${
|
||||
selected ? "border-slate-900" : "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
@@ -122,8 +122,11 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-1.5 block font-sans text-base leading-relaxed transition-colors duration-300 ${
|
||||
selected ? "text-slate-600" : "text-slate-400"
|
||||
className={`mt-1.5 font-sans text-base leading-relaxed transition-colors duration-300 ${
|
||||
// On phones only the current step is spelled out.
|
||||
selected
|
||||
? "block text-slate-600"
|
||||
: "hidden text-slate-400 md:block"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
@@ -153,7 +156,7 @@ export const PathPicker: React.FC = () => {
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={services.tablistLabel}
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
|
||||
className="grid grid-cols-2 gap-2 md:gap-3 lg:grid-cols-4 lg:gap-4"
|
||||
>
|
||||
{paths.map((path, index) => {
|
||||
const selected = path.id === activeId;
|
||||
@@ -167,7 +170,7 @@ export const PathPicker: React.FC = () => {
|
||||
aria-controls={`path-panel-${path.id}`}
|
||||
onClick={() => setActiveId(path.id)}
|
||||
data-glow
|
||||
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
|
||||
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-4 text-left transition-all duration-300 md:p-7 ${
|
||||
selected
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
|
||||
@@ -177,11 +180,11 @@ export const PathPicker: React.FC = () => {
|
||||
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
|
||||
0{index + 1}
|
||||
</span>
|
||||
<span className="mt-3 block font-sans text-xl font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
|
||||
<span className="mt-3 block font-sans text-base font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
|
||||
{path.need}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-2 block font-sans text-sm leading-relaxed md:mt-4 md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
|
||||
className={`mt-2 hidden font-sans text-sm leading-relaxed md:mt-4 md:block md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
|
||||
>
|
||||
{path.forWhom}
|
||||
</span>
|
||||
@@ -199,7 +202,7 @@ export const PathPicker: React.FC = () => {
|
||||
id={`path-panel-${path.id}`}
|
||||
aria-labelledby={`path-tab-${path.id}`}
|
||||
hidden={!selected}
|
||||
className="mt-12 md:mt-20"
|
||||
className="mt-8 md:mt-20"
|
||||
>
|
||||
<motion.div
|
||||
key={selected ? "on" : "off"}
|
||||
@@ -251,7 +254,7 @@ export const PathPicker: React.FC = () => {
|
||||
{path.pricingText}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:col-span-6">
|
||||
<div className="grid grid-cols-2 gap-6 md:col-span-6 md:gap-8">
|
||||
{path.prices.map((price) => (
|
||||
<div key={price.label}>
|
||||
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { SwipeRow } from "@/src/components/SwipeRow";
|
||||
import * as React from "react";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
@@ -252,7 +253,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
{flow.summary}
|
||||
</p>
|
||||
|
||||
<BitAura source="around" tone="light" motion="pulse" className="mt-8">
|
||||
<BitAura source="left" tone="light" className="mt-8">
|
||||
<div className="overflow-hidden rounded-3xl bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
|
||||
@@ -391,7 +392,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
|
||||
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
|
||||
<div className="px-5 py-5 md:col-span-7 md:border-r md:border-slate-800 md:px-7">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>{automation.log}</p>
|
||||
<ol
|
||||
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
|
||||
@@ -413,7 +414,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
|
||||
<ul className="m-0 hidden list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:flex md:px-7">
|
||||
{KINDS.filter((kind) =>
|
||||
flow.nodes.some((node) => node.kind === kind),
|
||||
).map((kind) => (
|
||||
@@ -442,7 +443,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
</div>
|
||||
</BitAura>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<SwipeRow className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>{automation.byHand}</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
||||
@@ -455,7 +456,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
{flow.automated}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SwipeRow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -241,7 +241,10 @@ export const PipelineRun: React.FC = () => {
|
||||
</p>
|
||||
<p
|
||||
className={`m-0 mt-2 font-sans text-base leading-relaxed transition-colors duration-300 ${
|
||||
active ? "text-slate-600" : "text-slate-400"
|
||||
// On phones only the current step is spelled out.
|
||||
active
|
||||
? "block text-slate-600"
|
||||
: "hidden text-slate-400 md:block"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
|
||||
/**
|
||||
* WebPresentation: the drawings of the web presentations section. The main one
|
||||
* shows the same presentation as a slide file and as a website on three
|
||||
* screens; four small ones stand next to the reasons. Same line language as
|
||||
* PathScenes.
|
||||
*/
|
||||
|
||||
const INK = "#f8fafc";
|
||||
const LINE = "#475569";
|
||||
const RIM = "#64748b";
|
||||
const SOFT = "#1e293b";
|
||||
const MARK = "#fde68a";
|
||||
const LIVE = "#10b981";
|
||||
const TINT = "rgba(248, 250, 252, 0.14)";
|
||||
const FINE = 1.1;
|
||||
const STEP_MS = 3600;
|
||||
const MOVE = { duration: 0.7, ease: [0.16, 1, 0.3, 1] as const };
|
||||
|
||||
const MONO: React.CSSProperties = {
|
||||
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
|
||||
fontSize: "var(--scene-label, 10px)",
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
fill: "#94a3b8",
|
||||
};
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Rect extends Size {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Projector, laptop, phone. */
|
||||
const SCREENS: readonly Size[] = [
|
||||
{ width: 300, height: 170 },
|
||||
{ width: 230, height: 150 },
|
||||
{ width: 96, height: 190 },
|
||||
];
|
||||
|
||||
const SLIDE: Size = { width: 160, height: 90 };
|
||||
const PAD = 12;
|
||||
const GAP = 8;
|
||||
|
||||
const centred = (size: Size, cx: number, cy: number): Rect => ({
|
||||
...size,
|
||||
x: cx - size.width / 2,
|
||||
y: cy - size.height / 2,
|
||||
});
|
||||
|
||||
/** The slide keeps its format: it is scaled down until it fits the screen. */
|
||||
const fitSlide = (screen: Rect) => {
|
||||
const scale = Math.min(
|
||||
(screen.width - 2 * PAD) / SLIDE.width,
|
||||
(screen.height - 2 * PAD) / SLIDE.height,
|
||||
);
|
||||
return {
|
||||
scale,
|
||||
x: screen.x + (screen.width - SLIDE.width * scale) / 2,
|
||||
y: screen.y + (screen.height - SLIDE.height * scale) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
/** The web page rearranges: three columns, two columns and a row, or one column. */
|
||||
const webLayout = (screen: Rect, index: number) => {
|
||||
const left = screen.x + PAD;
|
||||
const inner = screen.width - 2 * PAD;
|
||||
const header: Rect = { x: left, y: screen.y + PAD, width: inner, height: 12 };
|
||||
const top = header.y + header.height + GAP;
|
||||
const area = screen.y + screen.height - PAD - top;
|
||||
|
||||
if (index === 0) {
|
||||
const width = (inner - 2 * GAP) / 3;
|
||||
return {
|
||||
header,
|
||||
cards: [0, 1, 2].map((i) => ({
|
||||
x: left + i * (width + GAP),
|
||||
y: top,
|
||||
width,
|
||||
height: area,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (index === 1) {
|
||||
const width = (inner - GAP) / 2;
|
||||
const upper = (area - GAP) * 0.56;
|
||||
return {
|
||||
header,
|
||||
cards: [
|
||||
{ x: left, y: top, width, height: upper },
|
||||
{ x: left + width + GAP, y: top, width, height: upper },
|
||||
{
|
||||
x: left,
|
||||
y: top + upper + GAP,
|
||||
width: inner,
|
||||
height: area - upper - GAP,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const height = (area - 2 * GAP) / 3;
|
||||
return {
|
||||
header,
|
||||
cards: [0, 1, 2].map((i) => ({
|
||||
x: left,
|
||||
y: top + i * (height + GAP),
|
||||
width: inner,
|
||||
height,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const Frame: React.FC<{ rect: Rect }> = ({ rect }) => (
|
||||
<motion.rect
|
||||
rx={12}
|
||||
fill="#0b1220"
|
||||
stroke={RIM}
|
||||
strokeWidth={FINE}
|
||||
initial={false}
|
||||
animate={{
|
||||
attrX: rect.x,
|
||||
attrY: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
transition={MOVE}
|
||||
/>
|
||||
);
|
||||
|
||||
/** One 16:9 slide, drawn at its own size and scaled as a whole. */
|
||||
const Slide: React.FC<{ screen: Rect }> = ({ screen }) => {
|
||||
const fit = fitSlide(screen);
|
||||
return (
|
||||
<motion.g
|
||||
style={{ originX: 0, originY: 0 }}
|
||||
initial={false}
|
||||
animate={{ x: fit.x, y: fit.y, scale: fit.scale }}
|
||||
transition={MOVE}
|
||||
>
|
||||
<rect
|
||||
width={SLIDE.width}
|
||||
height={SLIDE.height}
|
||||
rx={4}
|
||||
fill={TINT}
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
<line
|
||||
x1={12}
|
||||
x2={84}
|
||||
y1={18}
|
||||
y2={18}
|
||||
stroke={INK}
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{[0, 1, 2].map((bullet) => (
|
||||
<g key={bullet}>
|
||||
<circle cx={14} cy={38 + bullet * 13} r={1.6} fill={RIM} />
|
||||
<line
|
||||
x1={20}
|
||||
x2={20 + [58, 46, 52][bullet % 3]}
|
||||
y1={38 + bullet * 13}
|
||||
y2={38 + bullet * 13}
|
||||
stroke={RIM}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
{[0, 1, 2, 3].map((bar) => {
|
||||
const height = [18, 30, 24, 40][bar % 4];
|
||||
return (
|
||||
<rect
|
||||
key={bar}
|
||||
x={104 + bar * 11}
|
||||
y={74 - height}
|
||||
width={7}
|
||||
height={height}
|
||||
rx={2}
|
||||
fill={bar === 3 ? MARK : RIM}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.g>
|
||||
);
|
||||
};
|
||||
|
||||
const WebPage: React.FC<{ screen: Rect; index: number }> = ({
|
||||
screen,
|
||||
index,
|
||||
}) => {
|
||||
const { header, cards } = webLayout(screen, index);
|
||||
return (
|
||||
<g>
|
||||
<motion.rect
|
||||
rx={4}
|
||||
fill={SOFT}
|
||||
stroke={LINE}
|
||||
strokeWidth={1}
|
||||
initial={false}
|
||||
animate={{
|
||||
attrX: header.x,
|
||||
attrY: header.y,
|
||||
width: header.width,
|
||||
height: header.height,
|
||||
}}
|
||||
transition={MOVE}
|
||||
/>
|
||||
{cards.map((card, i) => (
|
||||
<g key={i}>
|
||||
<motion.rect
|
||||
rx={6}
|
||||
fill={TINT}
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
initial={false}
|
||||
animate={{
|
||||
attrX: card.x,
|
||||
attrY: card.y,
|
||||
width: card.width,
|
||||
height: card.height,
|
||||
}}
|
||||
transition={MOVE}
|
||||
/>
|
||||
<motion.line
|
||||
stroke={i === 2 ? MARK : INK}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
initial={false}
|
||||
animate={{
|
||||
x1: card.x + 9,
|
||||
x2: card.x + Math.min(card.width - 9, 9 + 46),
|
||||
y1: card.y + 12,
|
||||
y2: card.y + 12,
|
||||
}}
|
||||
transition={MOVE}
|
||||
/>
|
||||
<motion.line
|
||||
stroke={RIM}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
initial={false}
|
||||
animate={{
|
||||
x1: card.x + 9,
|
||||
x2: card.x + Math.min(card.width - 9, 9 + 62),
|
||||
y1: card.y + 23,
|
||||
y2: card.y + 23,
|
||||
}}
|
||||
transition={MOVE}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PresentationCompare: the same presentation as a slide file and as a website,
|
||||
* on a projector, a laptop and a phone. It steps through the screens on its
|
||||
* own until the visitor picks one.
|
||||
*/
|
||||
export const PresentationCompare: React.FC = () => {
|
||||
const { compare } = useContent().presentations;
|
||||
const narrow = useNarrow();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [index, setIndex] = React.useState(0);
|
||||
const [auto, setAuto] = React.useState(true);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setInView(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!auto || !inView) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const timer = window.setInterval(
|
||||
() => setIndex((current) => (current + 1) % SCREENS.length),
|
||||
STEP_MS,
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [auto, inView]);
|
||||
|
||||
// Side by side on wide screens, one above the other on a phone.
|
||||
const view = narrow
|
||||
? { width: 380, height: 600 }
|
||||
: { width: 760, height: 300 };
|
||||
const stages = narrow
|
||||
? [
|
||||
{ cx: 190, cy: 150 },
|
||||
{ cx: 190, cy: 450 },
|
||||
]
|
||||
: [
|
||||
{ cx: 190, cy: 150 },
|
||||
{ cx: 570, cy: 150 },
|
||||
];
|
||||
const size = SCREENS[index] ?? SCREENS[0];
|
||||
if (!size) return null;
|
||||
const slideScreen = centred(size, stages[0]?.cx ?? 0, stages[0]?.cy ?? 0);
|
||||
const webScreen = centred(size, stages[1]?.cx ?? 0, stages[1]?.cy ?? 0);
|
||||
const tooSmall = index === SCREENS.length - 1;
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<svg
|
||||
viewBox={`0 0 ${view.width} ${view.height}`}
|
||||
role="img"
|
||||
aria-label={compare.description}
|
||||
className="block h-auto w-full [--scene-label:13px] md:[--scene-label:10px]"
|
||||
>
|
||||
{[
|
||||
{
|
||||
stage: stages[0],
|
||||
name: compare.slides,
|
||||
note: compare.slideNotes[index],
|
||||
colour: tooSmall ? MARK : "#94a3b8",
|
||||
},
|
||||
{
|
||||
stage: stages[1],
|
||||
name: compare.web,
|
||||
note: compare.webNotes[index],
|
||||
colour: LIVE,
|
||||
},
|
||||
].map(
|
||||
(column) =>
|
||||
column.stage && (
|
||||
<g key={column.name}>
|
||||
<text
|
||||
x={column.stage.cx}
|
||||
y={column.stage.cy - 118}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: INK }}
|
||||
>
|
||||
{column.name}
|
||||
</text>
|
||||
<motion.text
|
||||
key={column.note}
|
||||
x={column.stage.cx}
|
||||
y={column.stage.cy + 128}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: column.colour }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.35 }}
|
||||
>
|
||||
{column.note}
|
||||
</motion.text>
|
||||
</g>
|
||||
),
|
||||
)}
|
||||
<Frame rect={slideScreen} />
|
||||
<Slide screen={slideScreen} />
|
||||
<Frame rect={webScreen} />
|
||||
<WebPage screen={webScreen} index={index} />
|
||||
</svg>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2 md:mt-8">
|
||||
<span className="mr-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
|
||||
{compare.pickScreen}
|
||||
</span>
|
||||
{compare.screens.map((screen, i) => {
|
||||
const selected = i === index;
|
||||
return (
|
||||
<button
|
||||
key={screen}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
setIndex(i);
|
||||
setAuto(false);
|
||||
}}
|
||||
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
||||
selected
|
||||
? "border-white bg-white text-slate-900"
|
||||
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{screen}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const draw = (delay: number) => ({
|
||||
initial: { pathLength: 0, opacity: 0 },
|
||||
whileInView: { pathLength: 1, opacity: 1 },
|
||||
viewport: { once: true, margin: "-10%" },
|
||||
transition: { duration: 0.7, delay, ease: "easeOut" as const },
|
||||
});
|
||||
|
||||
/** Every screen: a projector, a laptop and a phone drawn one after another. */
|
||||
const ScreensDrawing: React.FC = () => (
|
||||
<g fill="none" stroke={INK} strokeWidth={FINE} strokeLinejoin="round">
|
||||
<motion.rect x={14} y={14} width={86} height={50} rx={5} {...draw(0)} />
|
||||
<motion.path d="M 44 74 h 26 M 57 64 v 10" stroke={RIM} {...draw(0.2)} />
|
||||
<motion.rect x={112} y={30} width={60} height={38} rx={4} {...draw(0.35)} />
|
||||
<motion.path d="M 104 72 h 76" stroke={RIM} {...draw(0.5)} />
|
||||
<motion.rect
|
||||
x={190}
|
||||
y={22}
|
||||
width={28}
|
||||
height={52}
|
||||
rx={6}
|
||||
fill={TINT}
|
||||
{...draw(0.7)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** One link: several copies of a file on one side, a single address on the other. */
|
||||
const LinkDrawing: React.FC = () => (
|
||||
<g>
|
||||
{[0, 1, 2].map((copy) => (
|
||||
<rect
|
||||
key={copy}
|
||||
x={18 + copy * 12}
|
||||
y={18 + copy * 8}
|
||||
width={38}
|
||||
height={48}
|
||||
rx={4}
|
||||
fill="#0b1220"
|
||||
stroke={LINE}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
))}
|
||||
<motion.path
|
||||
d="M 96 46 h 30"
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
markerEnd="url(#reason-arrow)"
|
||||
{...draw(0.2)}
|
||||
/>
|
||||
<rect
|
||||
x={138}
|
||||
y={32}
|
||||
width={82}
|
||||
height={28}
|
||||
rx={14}
|
||||
fill={TINT}
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={152}
|
||||
cy={46}
|
||||
r={3.5}
|
||||
fill={LIVE}
|
||||
animate={{ opacity: [1, 0.35, 1] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity }}
|
||||
/>
|
||||
<line
|
||||
x1={162}
|
||||
x2={208}
|
||||
y1={46}
|
||||
y2={46}
|
||||
stroke={INK}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** The real thing: a chart whose bars keep moving, because the figures are live. */
|
||||
const LiveDrawing: React.FC = () => (
|
||||
<g>
|
||||
<rect
|
||||
x={24}
|
||||
y={12}
|
||||
width={186}
|
||||
height={66}
|
||||
rx={8}
|
||||
fill="#0b1220"
|
||||
stroke={RIM}
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
<line x1={38} x2={196} y1={66} y2={66} stroke={LINE} strokeWidth={1} />
|
||||
{[0, 1, 2, 3, 4, 5].map((bar) => {
|
||||
const low = [14, 22, 18, 30, 24, 36][bar % 6] ?? 20;
|
||||
const high = [26, 16, 34, 22, 40, 28][bar % 6] ?? 30;
|
||||
return (
|
||||
<motion.rect
|
||||
key={bar}
|
||||
x={44 + bar * 25}
|
||||
width={14}
|
||||
rx={3}
|
||||
fill={bar === 5 ? MARK : RIM}
|
||||
initial={false}
|
||||
animate={{
|
||||
height: [low, high, low],
|
||||
attrY: [66 - low, 66 - high, 66 - low],
|
||||
}}
|
||||
transition={{
|
||||
duration: 3.6,
|
||||
delay: bar * 0.25,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<motion.circle
|
||||
cx={198}
|
||||
cy={24}
|
||||
r={3}
|
||||
fill={LIVE}
|
||||
animate={{ opacity: [1, 0.3, 1] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Afterwards: a page that is read on its own and passed on. */
|
||||
const AfterDrawing: React.FC = () => (
|
||||
<g>
|
||||
<rect
|
||||
x={30}
|
||||
y={10}
|
||||
width={74}
|
||||
height={70}
|
||||
rx={6}
|
||||
fill={TINT}
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
{[0, 1, 2, 3].map((row) => (
|
||||
<line
|
||||
key={row}
|
||||
x1={42}
|
||||
x2={42 + [48, 40, 46, 30][row % 4]}
|
||||
y1={26 + row * 12}
|
||||
y2={26 + row * 12}
|
||||
stroke={row === 0 ? INK : RIM}
|
||||
strokeWidth={row === 0 ? 3 : 2.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
))}
|
||||
{[24, 46, 68].map((y, i) => (
|
||||
<g key={y}>
|
||||
<motion.path
|
||||
d={`M 104 45 C 136 45 138 ${y} 168 ${y}`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
{...draw(0.2 + i * 0.2)}
|
||||
/>
|
||||
<circle
|
||||
cx={180}
|
||||
cy={y}
|
||||
r={9}
|
||||
fill="#0b1220"
|
||||
stroke={RIM}
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
<circle cx={180} cy={y - 2} r={2.6} fill={RIM} />
|
||||
<path
|
||||
d={`M 174.5 ${y + 5.5} q 5.5 -6 11 0`}
|
||||
fill="none"
|
||||
stroke={RIM}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
|
||||
const REASON_DRAWINGS: readonly React.FC[] = [
|
||||
ScreensDrawing,
|
||||
LinkDrawing,
|
||||
LiveDrawing,
|
||||
AfterDrawing,
|
||||
];
|
||||
|
||||
/** The small drawing next to one of the reasons, by its position in the list. */
|
||||
export const ReasonDrawing: React.FC<{ index: number }> = ({ index }) => {
|
||||
const Drawing = REASON_DRAWINGS[index];
|
||||
if (!Drawing) return null;
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 234 90"
|
||||
aria-hidden="true"
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="reason-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
|
||||
</marker>
|
||||
</defs>
|
||||
<Drawing />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -203,7 +203,7 @@ export const WorkShowcase: React.FC = () => {
|
||||
{copy.text}
|
||||
</p>
|
||||
|
||||
<ul className="m-0 mt-8 list-none p-0">
|
||||
<ul className="m-0 mt-8 hidden list-none p-0 md:block">
|
||||
{copy.did.map((line) => (
|
||||
<li
|
||||
key={line}
|
||||
|
||||
@@ -441,6 +441,62 @@ export const de: Content = {
|
||||
},
|
||||
],
|
||||
},
|
||||
presentations: {
|
||||
label: "Web-Präsentationen",
|
||||
title: { lead: "Eine Präsentation als Link.", rest: "Nicht als Anhang." },
|
||||
intro:
|
||||
"Die meisten Präsentationen sind noch Foliendateien: ein festes Format, verschickt als Anhang. Ich baue Präsentationen als Websites. Sie öffnen sich in jedem Browser, passen sich dem Bildschirm an, auf dem sie gezeigt werden, und können Dinge enthalten, die eine Folie nicht kann.",
|
||||
compare: {
|
||||
description:
|
||||
"Eine Folie behält auf jedem Bildschirm ihr festes Format und wird auf dem Handy zu klein zum Lesen. Eine Web-Präsentation ordnet ihren Inhalt für jeden Bildschirm neu an.",
|
||||
slides: "Foliendatei",
|
||||
web: "Web-Präsentation",
|
||||
pickScreen: "Gezeigt auf",
|
||||
screens: ["Beamer", "Laptop", "Handy"],
|
||||
slideNotes: [
|
||||
"Für dieses Format gemacht",
|
||||
"Passt noch",
|
||||
"Verkleinert: zu klein zum Lesen",
|
||||
],
|
||||
webNotes: [
|
||||
"Nutzt die volle Breite",
|
||||
"Zwei Spalten",
|
||||
"Eine Spalte, gut lesbar",
|
||||
],
|
||||
},
|
||||
reasons: [
|
||||
{
|
||||
title: "Passt auf jeden Bildschirm",
|
||||
text: "Eine Folie hat ein festes Format. Eine Web-Präsentation ordnet sich für Beamer, Laptop und Handy jeweils neu an.",
|
||||
},
|
||||
{
|
||||
title: "Ein Link, ein Stand",
|
||||
text: "Niemand muss fragen, welche Datei die aktuelle ist. Sie ändern es an einer Stelle, und jeder, der den Link öffnet, sieht diesen Stand.",
|
||||
},
|
||||
{
|
||||
title: "Zeigt das Echte",
|
||||
text: "Aktuelle Zahlen, eine funktionierende Demo oder ein klickbarer Prototyp können direkt in der Präsentation stehen, statt eines Screenshots davon.",
|
||||
},
|
||||
{
|
||||
title: "Auch danach noch nützlich",
|
||||
text: "Nach dem Termin ist sie eine Seite, die man allein lesen, weitergeben und wiederfinden kann.",
|
||||
},
|
||||
],
|
||||
goodFor: {
|
||||
title: "Typische Einsätze",
|
||||
items: [
|
||||
"Unternehmens- und Produktpräsentationen",
|
||||
"Pitches und Angebote, die als Link verschickt werden",
|
||||
"Berichte mit Zahlen, die sich ändern",
|
||||
"Bildschirme auf Messen und Veranstaltungen",
|
||||
],
|
||||
},
|
||||
honest: {
|
||||
title: "Wann Folien reichen",
|
||||
text: "Für einen einzelnen internen Termin ist eine Foliendatei schneller und günstiger. Eine Web-Präsentation lohnt sich, wenn sie oft gezeigt wird, an viele Menschen geht oder für Ihr Unternehmen steht.",
|
||||
},
|
||||
cta: "Nach einer Präsentation fragen",
|
||||
},
|
||||
technology: {
|
||||
label: "Technik",
|
||||
title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." },
|
||||
|
||||
@@ -425,6 +425,58 @@ export const en: Content = {
|
||||
},
|
||||
],
|
||||
},
|
||||
presentations: {
|
||||
label: "Web presentations",
|
||||
title: { lead: "A presentation as a link.", rest: "Not as an attachment." },
|
||||
intro:
|
||||
"Most presentations are still slide files: one fixed format, sent around as an attachment. I build presentations as websites. They open in any browser, adapt to the screen they are shown on, and can contain things a slide cannot.",
|
||||
compare: {
|
||||
description:
|
||||
"A slide keeps its fixed format on every screen and becomes too small to read on a phone. A web presentation rearranges its content for each screen.",
|
||||
slides: "Slide file",
|
||||
web: "Web presentation",
|
||||
pickScreen: "Shown on",
|
||||
screens: ["Projector", "Laptop", "Phone"],
|
||||
slideNotes: [
|
||||
"Made for this format",
|
||||
"Still fits",
|
||||
"Shrunk: too small to read",
|
||||
],
|
||||
webNotes: ["Uses the full width", "Two columns", "One column, readable"],
|
||||
},
|
||||
reasons: [
|
||||
{
|
||||
title: "Fits every screen",
|
||||
text: "A slide has one fixed format. A web presentation rearranges itself for the projector, the laptop and the phone.",
|
||||
},
|
||||
{
|
||||
title: "One link, one version",
|
||||
text: "Nobody has to ask which file is the current one. You change it in one place, and everyone who opens the link sees that state.",
|
||||
},
|
||||
{
|
||||
title: "Shows the real thing",
|
||||
text: "Current figures, a working demo or a clickable prototype can sit inside the presentation, instead of a screenshot of them.",
|
||||
},
|
||||
{
|
||||
title: "Still useful afterwards",
|
||||
text: "After the meeting it is a page people can read on their own, pass on and find again.",
|
||||
},
|
||||
],
|
||||
goodFor: {
|
||||
title: "Typical uses",
|
||||
items: [
|
||||
"Company and product presentations",
|
||||
"Pitches and offers sent as a link",
|
||||
"Reports with figures that change",
|
||||
"Screens at trade fairs and events",
|
||||
],
|
||||
},
|
||||
honest: {
|
||||
title: "When slides are enough",
|
||||
text: "For a single internal meeting, a slide file is quicker and cheaper. A web presentation is worth it when it is shown often, sent to many people, or stands for your company.",
|
||||
},
|
||||
cta: "Ask about a presentation",
|
||||
},
|
||||
technology: {
|
||||
label: "Technology",
|
||||
title: { lead: "Which projects", rest: "I am right for." },
|
||||
|
||||
@@ -198,6 +198,28 @@ export type Content = {
|
||||
readonly workflows: Readonly<Record<string, WorkflowCopy>>;
|
||||
readonly notes: readonly TitledList[];
|
||||
};
|
||||
readonly presentations: {
|
||||
readonly label: string;
|
||||
readonly title: SplitTitle;
|
||||
readonly intro: string;
|
||||
readonly compare: {
|
||||
/** What the drawing shows, for screen readers. */
|
||||
readonly description: string;
|
||||
readonly slides: string;
|
||||
readonly web: string;
|
||||
readonly pickScreen: string;
|
||||
/** Projector, laptop, phone: in this order. */
|
||||
readonly screens: readonly string[];
|
||||
/** What happens to the slide on each screen, in the same order. */
|
||||
readonly slideNotes: readonly string[];
|
||||
readonly webNotes: readonly string[];
|
||||
};
|
||||
/** Every screen, one link, real content, useful afterwards: in this order. */
|
||||
readonly reasons: readonly TitledText[];
|
||||
readonly goodFor: TitledList;
|
||||
readonly honest: TitledText;
|
||||
readonly cta: string;
|
||||
};
|
||||
readonly technology: {
|
||||
readonly label: string;
|
||||
readonly title: SplitTitle;
|
||||
|
||||
Reference in New Issue
Block a user