updated version
This commit is contained in:
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
# Keep host build artefacts and platform-specific binaries out of the image;
|
||||
# dependencies are installed fresh inside the container by `npm ci`.
|
||||
node_modules
|
||||
.output
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
dist
|
||||
.git
|
||||
.claude
|
||||
.lovable
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# Contact form email delivery (https://resend.com). Without RESEND_API_KEY the
|
||||
# form falls back to opening the visitor's email app.
|
||||
RESEND_API_KEY=
|
||||
# Where enquiries are delivered (defaults to hello@kapfoundry.com).
|
||||
CONTACT_TO_EMAIL=
|
||||
# Sender; must be on a domain verified in Resend, e.g. "KapFoundry <website@kapfoundry.com>".
|
||||
CONTACT_FROM_EMAIL=
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -24,9 +24,15 @@ dist-ssr
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.claude
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<!-- LOVABLE:BEGIN -->
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is connected to [Lovable](https://lovable.dev). Avoid rewriting
|
||||
> published git history — force pushing, or rebasing/amending/squashing commits
|
||||
@@ -7,4 +8,5 @@
|
||||
>
|
||||
> Commits you push to the connected branch sync back to Lovable and show up in
|
||||
> the editor, so keep the branch in a working state.
|
||||
|
||||
<!-- LOVABLE:END -->
|
||||
|
||||
@@ -25,6 +25,7 @@ COPY --from=build /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
|
||||
18
nginx.conf
18
nginx.conf
@@ -1,18 +0,0 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
# This line forces Nginx to pass routing back to React Router
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -5978,16 +5978,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nitro/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"extraneous": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/nitro/node_modules/unstorage": {
|
||||
"version": "2.0.0-alpha.7",
|
||||
"resolved": "https://registry.npmjs.org/unstorage/-/unstorage-2.0.0-alpha.7.tgz",
|
||||
|
||||
BIN
public/assets/1.png
Normal file
BIN
public/assets/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 532 KiB |
BIN
public/assets/2.png
Normal file
BIN
public/assets/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 492 KiB |
BIN
public/assets/3.jpg
Normal file
BIN
public/assets/3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
public/assets/4.jpeg
Normal file
BIN
public/assets/4.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
65
src/lib/contact.ts
Normal file
65
src/lib/contact.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
|
||||
export const CONTACT_EMAIL = "hello@kapfoundry.com";
|
||||
|
||||
export const INTERESTS = [
|
||||
"Investing in the studio",
|
||||
"Co-founding a venture",
|
||||
"Partnership or collaboration",
|
||||
"General enquiry",
|
||||
] as const;
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
organization: z.string().trim().max(200).default(""),
|
||||
email: z.string().trim().email().max(320),
|
||||
interest: z.enum(INTERESTS),
|
||||
message: z.string().trim().max(5000).default(""),
|
||||
// Honeypot: hidden from people, so only bots fill it in.
|
||||
website: z.string().default(""),
|
||||
});
|
||||
|
||||
export type ContactInput = z.input<typeof contactSchema>;
|
||||
|
||||
export type ContactResult = { ok: true } | { ok: false; reason: "not-configured" | "send-failed" };
|
||||
|
||||
/**
|
||||
* Emails an enquiry to the studio via Resend (https://resend.com).
|
||||
* Env: RESEND_API_KEY (required to send), CONTACT_TO_EMAIL, CONTACT_FROM_EMAIL.
|
||||
* Without an API key it reports "not-configured" so the page can fall back to mailto.
|
||||
*/
|
||||
export const sendContactEnquiry = createServerFn({ method: "POST" })
|
||||
.validator((data: ContactInput) => contactSchema.parse(data))
|
||||
.handler(async ({ data }): Promise<ContactResult> => {
|
||||
if (data.website) return { ok: true };
|
||||
|
||||
const apiKey = process.env.RESEND_API_KEY;
|
||||
if (!apiKey) return { ok: false, reason: "not-configured" };
|
||||
|
||||
const text = [
|
||||
`Name: ${data.name}`,
|
||||
`Organization: ${data.organization || "-"}`,
|
||||
`Email: ${data.email}`,
|
||||
`Interested in: ${data.interest}`,
|
||||
"",
|
||||
data.message || "(no message)",
|
||||
].join("\n");
|
||||
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
from: process.env.CONTACT_FROM_EMAIL ?? "KapFoundry Website <onboarding@resend.dev>",
|
||||
to: [process.env.CONTACT_TO_EMAIL ?? CONTACT_EMAIL],
|
||||
reply_to: data.email,
|
||||
subject: `Website enquiry: ${data.interest} (${data.name})`,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Contact email failed", res.status, await res.text());
|
||||
return { ok: false, reason: "send-failed" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -7,15 +7,15 @@ is `src/routes/__root.tsx`.
|
||||
|
||||
## Conventions
|
||||
|
||||
| File | URL |
|
||||
| --- | --- |
|
||||
| `index.tsx` | `/` |
|
||||
| `about.tsx` | `/about` |
|
||||
| `users/index.tsx` | `/users` |
|
||||
| `users/$id.tsx` | `/users/:id` (dynamic — bare `$`, no curly braces) |
|
||||
| `posts/{-$category}.tsx` | `/posts/:category?` (optional segment) |
|
||||
| `files/$.tsx` | `/files/*` (splat — read via `_splat` param, never `*`) |
|
||||
| `_layout.tsx` | layout route (renders children via `<Outlet />`) |
|
||||
| `__root.tsx` | app shell — wraps every page; preserve `<Outlet />` |
|
||||
| File | URL |
|
||||
| ------------------------ | ------------------------------------------------------- |
|
||||
| `index.tsx` | `/` |
|
||||
| `about.tsx` | `/about` |
|
||||
| `users/index.tsx` | `/users` |
|
||||
| `users/$id.tsx` | `/users/:id` (dynamic — bare `$`, no curly braces) |
|
||||
| `posts/{-$category}.tsx` | `/posts/:category?` (optional segment) |
|
||||
| `files/$.tsx` | `/files/*` (splat — read via `_splat` param, never `*`) |
|
||||
| `_layout.tsx` | layout route (renders children via `<Outlet />`) |
|
||||
| `__root.tsx` | app shell — wraps every page; preserve `<Outlet />` |
|
||||
|
||||
`routeTree.gen.ts` is auto-generated. Don't edit it by hand.
|
||||
|
||||
@@ -47,12 +47,13 @@ function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
This page didn't load
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Please refresh or head back home.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Please refresh or head back home.</p>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
onClick={() => { router.invalidate(); reset(); }}
|
||||
onClick={() => {
|
||||
router.invalidate();
|
||||
reset();
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Try again
|
||||
@@ -74,14 +75,14 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ title: "KapFoundry — Venture Studio & Technology Holding Company" },
|
||||
{ title: "KapFoundry | Venture Studio & Technology Holding Company" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"KapFoundry is a Hyderabad-based venture studio and technology holding company engineering AI-first, category-defining companies across logistics, retail, fintech, agritech, cuisine, and carbon.",
|
||||
},
|
||||
{ name: "author", content: "KapFoundry" },
|
||||
{ property: "og:title", content: "KapFoundry — Build Bold. Build the Future." },
|
||||
{ property: "og:title", content: "KapFoundry: Build Bold. Build the Future." },
|
||||
{
|
||||
property: "og:description",
|
||||
content:
|
||||
@@ -98,7 +99,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@300;400;500;600;700&display=swap",
|
||||
href: "https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Geist:wght@400;500;600&display=swap",
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
2552
src/routes/index.tsx
2552
src/routes/index.tsx
File diff suppressed because it is too large
Load Diff
529
src/styles.css
529
src/styles.css
@@ -32,18 +32,22 @@
|
||||
--color-ink: var(--ink);
|
||||
--color-cream: var(--cream);
|
||||
|
||||
--font-display: "Fraunces", ui-serif, Georgia, serif;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--font-display: "Sora", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Dark is the default palette; .theme-dark re-applies it inside a light section. */
|
||||
:root,
|
||||
.theme-dark {
|
||||
/* Etihad-inspired: deep charcoal/black with warm gold */
|
||||
--background: oklch(0.14 0.005 60); /* near-black warm */
|
||||
--background: oklch(0.14 0.005 60); /* near-black warm */
|
||||
--foreground: oklch(0.96 0.015 85);
|
||||
--ink: oklch(0.10 0.005 60);
|
||||
--ink: oklch(0.1 0.005 60);
|
||||
--cream: oklch(0.95 0.03 85);
|
||||
|
||||
--card: oklch(0.18 0.008 65);
|
||||
@@ -51,13 +55,13 @@
|
||||
--popover: oklch(0.18 0.008 65);
|
||||
--popover-foreground: oklch(0.96 0.015 85);
|
||||
|
||||
--primary: oklch(0.78 0.13 82); /* warm gold */
|
||||
--primary: oklch(0.78 0.13 82); /* warm gold */
|
||||
--primary-foreground: oklch(0.12 0.005 60);
|
||||
--gold: oklch(0.82 0.14 85); /* champagne gold accent */
|
||||
--gold: oklch(0.82 0.14 85); /* champagne gold accent */
|
||||
|
||||
--secondary: oklch(0.22 0.01 70);
|
||||
--secondary-foreground: oklch(0.96 0.015 85);
|
||||
--muted: oklch(0.20 0.008 65);
|
||||
--muted: oklch(0.2 0.008 65);
|
||||
--muted-foreground: oklch(0.72 0.03 80);
|
||||
--accent: oklch(0.28 0.03 75);
|
||||
--accent-foreground: oklch(0.96 0.015 85);
|
||||
@@ -68,20 +72,63 @@
|
||||
--ring: oklch(0.78 0.13 82);
|
||||
}
|
||||
|
||||
/* Warm cream palette for alternating light sections. */
|
||||
.theme-light {
|
||||
--background: oklch(0.975 0.012 85);
|
||||
--foreground: oklch(0.17 0.01 60);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.17 0.01 60);
|
||||
--primary: oklch(0.56 0.12 70);
|
||||
--primary-foreground: oklch(0.99 0.005 85);
|
||||
--secondary: oklch(0.93 0.02 85);
|
||||
--secondary-foreground: oklch(0.17 0.01 60);
|
||||
--muted: oklch(0.94 0.015 85);
|
||||
--muted-foreground: oklch(0.44 0.02 70);
|
||||
--accent: oklch(0.92 0.03 85);
|
||||
--accent-foreground: oklch(0.17 0.01 60);
|
||||
--border: oklch(0.17 0.01 60 / 12%);
|
||||
--input: oklch(0.17 0.01 60 / 18%);
|
||||
--ring: oklch(0.56 0.12 70);
|
||||
}
|
||||
|
||||
.dark { color-scheme: dark; }
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { border-color: var(--color-border); }
|
||||
html { scroll-behavior: smooth; }
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
html {
|
||||
scroll-padding-top: 5rem;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.02em; }
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.035em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* One visible keyboard focus style for every interactive element. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@utility container-x {
|
||||
@@ -89,25 +136,22 @@
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.5rem;
|
||||
max-width: 1280px;
|
||||
@media (min-width: 768px) { padding-inline: 2.5rem; }
|
||||
@media (min-width: 768px) {
|
||||
padding-inline: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Plain section label (used sparingly: roughly one per three sections). */
|
||||
@utility eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.22em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@utility hairline {
|
||||
display: inline-block;
|
||||
width: 2.5rem;
|
||||
height: 1px;
|
||||
background: var(--color-primary);
|
||||
vertical-align: middle;
|
||||
margin-right: 0.75rem;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@utility grid-lines {
|
||||
@@ -116,3 +160,428 @@
|
||||
linear-gradient(to bottom, oklch(1 0 0 / 4%) 1px, transparent 1px);
|
||||
background-size: 80px 80px;
|
||||
}
|
||||
|
||||
@utility section-y {
|
||||
padding-block: 6rem;
|
||||
@media (min-width: 768px) {
|
||||
padding-block: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Infinite ticker: the track holds two copies of its content and slides by half. */
|
||||
@keyframes marquee {
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
.marquee-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
.marquee:hover .marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* Scroll reveal. Only elements below the fold at hydration get .reveal-pending,
|
||||
so server-rendered content never flashes or stays hidden without JS. The JS removes
|
||||
both classes once an element has settled, handing it back to its own transitions. */
|
||||
:root {
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
[data-reveal] {
|
||||
--reveal-wait: calc(var(--reveal-delay, 0ms) + var(--batch-delay, 0ms));
|
||||
}
|
||||
[data-reveal].reveal-pending {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 40px, 0);
|
||||
}
|
||||
[data-reveal].reveal-pending.is-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition:
|
||||
opacity 1s var(--ease-out-expo),
|
||||
transform 1.2s var(--ease-out-expo),
|
||||
clip-path 1.3s var(--ease-out-expo);
|
||||
transition-delay: var(--reveal-wait);
|
||||
}
|
||||
|
||||
/* Headlines: the text rises through a mask that opens from the top. */
|
||||
[data-reveal="mask"].reveal-pending {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0.45em, 0);
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
[data-reveal="mask"].reveal-pending.is-visible {
|
||||
transform: none;
|
||||
clip-path: inset(-0.3em -0.3em -0.35em -0.3em);
|
||||
}
|
||||
|
||||
/* Media: the frame opens out from an inset while the photo settles from a slight zoom. */
|
||||
[data-reveal="clip"].reveal-pending {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
clip-path: inset(12% 10% 12% 10% round 1.5rem);
|
||||
}
|
||||
[data-reveal="clip"].reveal-pending.is-visible {
|
||||
clip-path: inset(0 0 0 0 round 0);
|
||||
}
|
||||
[data-reveal="clip"].reveal-pending img {
|
||||
transform: scale(1.22);
|
||||
}
|
||||
[data-reveal="clip"].reveal-pending.is-visible img {
|
||||
transform: none;
|
||||
transition: transform 1.8s var(--ease-out-expo) var(--reveal-wait);
|
||||
}
|
||||
|
||||
/* Groups: the container stays put while its children rise in sequence. */
|
||||
[data-reveal="stagger"].reveal-pending {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
[data-reveal="stagger"].reveal-pending > * {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 28px, 0);
|
||||
}
|
||||
[data-reveal="stagger"].reveal-pending.is-visible > * {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition:
|
||||
opacity 0.9s var(--ease-out-expo),
|
||||
transform 1.1s var(--ease-out-expo);
|
||||
transition-delay: calc(var(--reveal-wait) + var(--i, 0) * 80ms);
|
||||
}
|
||||
|
||||
/* Small pill labels need more contrast than large headings on cream. */
|
||||
.theme-light .eyebrow {
|
||||
color: oklch(0.47 0.1 65);
|
||||
}
|
||||
|
||||
/* Soft gold glow drawn with a gradient: much cheaper to paint than a blurred element. */
|
||||
@utility glow {
|
||||
background: radial-gradient(
|
||||
closest-side,
|
||||
color-mix(in oklab, var(--primary) 28%, transparent),
|
||||
transparent
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Tactile press feedback for buttons and button-like links. */
|
||||
@utility press {
|
||||
transition-property: transform, filter, background-color, color, border-color, box-shadow;
|
||||
transition-duration: 250ms;
|
||||
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero entrance: staggered rise on first paint. Pure CSS, so it also runs before hydration. */
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
}
|
||||
@keyframes hero-zoom {
|
||||
from {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
.rise-in {
|
||||
animation: rise-in 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: var(--rise-delay, 0ms);
|
||||
}
|
||||
.hero-zoom {
|
||||
animation: hero-zoom 2.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
/* Reading progress bar driven by the scroll position itself (no JS). */
|
||||
@keyframes grow-x {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
.scroll-progress {
|
||||
display: none;
|
||||
}
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.scroll-progress {
|
||||
display: block;
|
||||
transform-origin: left;
|
||||
animation: grow-x linear both;
|
||||
animation-timeline: scroll(root);
|
||||
}
|
||||
}
|
||||
|
||||
/* Gentle sideways nudge for "scroll to explore" hints. */
|
||||
@keyframes nudge-x {
|
||||
50% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
}
|
||||
.nudge-x {
|
||||
animation: nudge-x 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes nudge-y {
|
||||
50% {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
.nudge-y {
|
||||
animation: nudge-y 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Mission rules: the gold line draws across once its list is revealed. Full by default. */
|
||||
[data-reveal].reveal-pending [data-draw] {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
[data-reveal].reveal-pending.is-visible [data-draw] {
|
||||
transform: scaleX(1);
|
||||
transition: transform 1.4s var(--ease-out-expo);
|
||||
transition-delay: calc(var(--reveal-wait) + var(--i, 0) * 80ms + 150ms);
|
||||
}
|
||||
|
||||
/* Cursor spotlight inside a tile; --mx/--my are set from pointermove. */
|
||||
.spotlight::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
background: radial-gradient(
|
||||
280px circle at var(--mx, 50%) var(--my, 50%),
|
||||
color-mix(in oklab, var(--primary) 18%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
.spotlight:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Scroll-driven effects. Browsers without view timelines simply show the final state. */
|
||||
@keyframes word-fill {
|
||||
from {
|
||||
opacity: 0.16;
|
||||
}
|
||||
}
|
||||
@keyframes hero-media-drift {
|
||||
to {
|
||||
transform: translate3d(0, 28%, 0) scale(1.08);
|
||||
}
|
||||
}
|
||||
@keyframes hero-copy-lift {
|
||||
to {
|
||||
transform: translate3d(0, 16vh, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes wordmark-fill {
|
||||
to {
|
||||
color: color-mix(in oklab, var(--primary) 26%, transparent);
|
||||
}
|
||||
}
|
||||
/* Brand mesh: layered gold, amber and bronze light on ink. Drifts on hover. */
|
||||
.mesh-gold {
|
||||
background:
|
||||
radial-gradient(42% 48% at 82% 18%, oklch(0.8 0.13 82 / 0.55), transparent 70%),
|
||||
radial-gradient(38% 42% at 18% 32%, oklch(0.62 0.12 62 / 0.4), transparent 72%),
|
||||
radial-gradient(55% 45% at 58% 96%, oklch(0.42 0.08 50 / 0.5), transparent 70%),
|
||||
radial-gradient(30% 30% at 45% 55%, oklch(0.7 0.1 85 / 0.18), transparent 70%), var(--ink);
|
||||
background-size: 140% 140%;
|
||||
background-position: 0% 0%;
|
||||
transition: background-position 1.6s var(--ease-out-expo);
|
||||
}
|
||||
.group:hover .mesh-gold {
|
||||
background-position: 100% 60%;
|
||||
}
|
||||
/* Fine dot grid, strongest near the light and fading toward the edges. */
|
||||
.dot-grid {
|
||||
background-image: radial-gradient(rgb(255 255 255 / 0.14) 1px, transparent 1.2px);
|
||||
background-size: 18px 18px;
|
||||
mask-image: radial-gradient(ellipse 70% 60% at 75% 25%, black, transparent 75%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Mobile / tablet menu: a circle that grows out of the menu button and shrinks back into it.
|
||||
--menu-x / --menu-y are set to the button's centre just before toggling. */
|
||||
html.menu-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu-overlay {
|
||||
--menu-x: calc(100% - 3rem);
|
||||
--menu-y: 2.25rem;
|
||||
clip-path: circle(0px at var(--menu-x) var(--menu-y));
|
||||
visibility: hidden;
|
||||
transition:
|
||||
clip-path 0.6s cubic-bezier(0.7, 0, 0.84, 0),
|
||||
visibility 0s linear 0.6s;
|
||||
}
|
||||
.menu-overlay.is-open {
|
||||
clip-path: circle(150vmax at var(--menu-x) var(--menu-y));
|
||||
visibility: visible;
|
||||
transition:
|
||||
clip-path 0.9s var(--ease-out-expo),
|
||||
visibility 0s;
|
||||
}
|
||||
/* Links rise through their row's mask one after another; they drop away quickly on close. */
|
||||
.menu-link {
|
||||
transform: translate3d(0, 105%, 0);
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform 0.35s ease-in,
|
||||
opacity 0.25s ease;
|
||||
}
|
||||
.menu-overlay.is-open .menu-link {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.9s var(--ease-out-expo),
|
||||
opacity 0.6s ease;
|
||||
transition-delay: calc(0.18s + var(--i, 0) * 0.055s);
|
||||
}
|
||||
.menu-foot {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 18px, 0);
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
transform 0.35s ease;
|
||||
}
|
||||
.menu-overlay.is-open .menu-foot {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition:
|
||||
opacity 0.7s ease 0.55s,
|
||||
transform 0.9s var(--ease-out-expo) 0.55s;
|
||||
}
|
||||
|
||||
/* Hero brand mark: tilt comes from --rx/--ry set on pointermove. */
|
||||
.hero-mark {
|
||||
transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
|
||||
transition: transform 0.7s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
@keyframes media-parallax {
|
||||
from {
|
||||
transform: translateY(-7%) scale(1.16);
|
||||
}
|
||||
to {
|
||||
transform: translateY(7%) scale(1.16);
|
||||
}
|
||||
}
|
||||
/* Cream sections open from an inset rounded sheet to full width as they enter. */
|
||||
@keyframes sheet-open {
|
||||
from {
|
||||
clip-path: inset(0 3% round 3rem);
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 round 0);
|
||||
}
|
||||
}
|
||||
@keyframes panel-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 72px, 0);
|
||||
}
|
||||
}
|
||||
@keyframes glow-drift {
|
||||
from {
|
||||
translate: 0 -18%;
|
||||
}
|
||||
to {
|
||||
translate: 0 18%;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sheet-enter {
|
||||
animation: sheet-open linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% entry 55%;
|
||||
}
|
||||
.panel-rise {
|
||||
animation: panel-rise linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 5% cover 30%;
|
||||
}
|
||||
/* Side-by-side on desktop: offset each panel's range so they rise left to right. */
|
||||
@media (min-width: 1024px) {
|
||||
.panel-rise {
|
||||
animation-range: entry calc(var(--i, 0) * 7%) cover calc(24% + var(--i, 0) * 3%);
|
||||
}
|
||||
}
|
||||
.glow-drift {
|
||||
animation: glow-drift linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
}
|
||||
.media-parallax {
|
||||
animation: media-parallax linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
}
|
||||
/* Statement words brighten line by line as they rise through the viewport. */
|
||||
.fill-word {
|
||||
animation: word-fill linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 8% cover 42%;
|
||||
}
|
||||
.hero-timeline {
|
||||
view-timeline-name: --hero;
|
||||
}
|
||||
.hero-media {
|
||||
animation: hero-media-drift linear both;
|
||||
animation-timeline: --hero;
|
||||
animation-range: exit 0% exit 100%;
|
||||
}
|
||||
.hero-copy {
|
||||
animation: hero-copy-lift linear both;
|
||||
animation-timeline: --hero;
|
||||
animation-range: exit 0% exit 70%;
|
||||
}
|
||||
.wordmark-fill {
|
||||
animation: wordmark-fill linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 20% cover 55%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.menu-overlay,
|
||||
.menu-overlay.is-open,
|
||||
.menu-link,
|
||||
.menu-overlay.is-open .menu-link,
|
||||
.menu-foot,
|
||||
.menu-overlay.is-open .menu-foot {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
.hero-mark {
|
||||
transform: none;
|
||||
}
|
||||
[data-reveal].reveal-pending [data-draw] {
|
||||
transform: none;
|
||||
}
|
||||
.nudge-y,
|
||||
.nudge-x,
|
||||
.marquee-track,
|
||||
.rise-in,
|
||||
.hero-zoom {
|
||||
animation: none;
|
||||
}
|
||||
[data-reveal].reveal-pending,
|
||||
[data-reveal].reveal-pending > *,
|
||||
[data-reveal].reveal-pending img {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
clip-path: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user