feat(web): redesign the landing page as one scroll-driven story

"Everything you've done, on one page." A single resume page is assembled,
written, restyled, checked, tailored and shared as the visitor scrolls,
followed by live community numbers, languages, a support receipt, a
closing call to action and the footer. It replaces the previous homepage
and its playgrounds.

Motion: a small scroll engine (features/homepage/scroll.ts) writes each
section's progress as --p on every animation frame, and the scenes derive
their motion from it with CSS calc(). React only re-renders on coarse
steps held in a zustand store. Reduced motion collapses every pinned
scene to one screen at its end state and stops all ambient motion.

Server-rendered copy: the web build now prerenders the homepage once per
locale (Vite app builder, features/homepage/prerender.tsx) into
apps/web/dist-prerender, which the server sends for "/" by `?locale=`,
then the saved locale cookie. main.tsx waits for the route to load before
React replaces the prerendered page, so it never flashes a loading screen.
dist-prerender is added to turbo outputs, the Docker image and the Vercel
function files.

SEO: localized title and description, a canonical per locale, hreflang
alternates for every locale over `/?locale=` addresses (the app now reads
that parameter), and SoftwareApplication JSON-LD. The FAQ structured data
is dropped because the page shows no FAQ.

Also: self-hosted Anybody and Martian Mono (landing only) and Newsreader
italic, graphite doodles as WebP, new Material Symbols in the icon subset,
the pull-cord theme switch on the app's theme cookie, and new catalog
strings extracted for translation.
This commit is contained in:
Amruth Pillai
2026-09-29 22:34:16 +02:00
parent 708a956174
commit b399e289d6
117 changed files with 35008 additions and 43557 deletions
+1
View File
@@ -4,6 +4,7 @@ node_modules
# Build Outputs
dist
dist-prerender
.vercel
.wrangler
+2 -1
View File
@@ -26,7 +26,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ ./
RUN rm -rf apps/web/dist apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
RUN rm -rf apps/web/dist apps/web/dist-prerender apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
FROM base AS runtime-pruner
COPY . .
@@ -63,6 +63,7 @@ COPY --from=pruner --chown=node:node /app/package.json /app/pnpm-lock.yaml /app/
COPY --from=runtime-deps --chown=node:node /app/apps/server/package.json ./apps/server/package.json
COPY --from=runtime-deps --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
COPY --from=builder --chown=node:node /app/apps/web/dist ./apps/web/dist
COPY --from=builder --chown=node:node /app/apps/web/dist-prerender ./apps/web/dist-prerender
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
COPY --from=pruner --chown=node:node /app/migrations ./migrations
+24 -175
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined },
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
serveStatic: vi.fn(() => vi.fn()),
getPublicResumeSocialMeta: vi.fn(),
}));
@@ -29,18 +29,7 @@ vi.mock("@reactive-resume/env/server", () => ({
env: mocks.env,
}));
type StaticOptions = {
onFound?: (
path: string,
context: {
req: { path: string };
header: (name: string, value: string) => void;
},
) => void | Promise<void>;
};
const { handleWebApp } = await import("./web");
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
describe("web app fallback classification", () => {
beforeEach(() => {
@@ -50,15 +39,6 @@ describe("web app fallback classification", () => {
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
});
it("serves the shell for the root app route without noindex", async () => {
const response = await handleWebApp(new Request("https://example.com/"));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
expect(await response.text()).toBe("<html>app</html>");
});
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
vi.mocked(fs.readFile).mockResolvedValue(`
<!doctype html>
@@ -78,18 +58,37 @@ describe("web app fallback classification", () => {
const html = await response.text();
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
expect(html).toContain('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
expect(html).toContain('id="reactive-resume-structured-data"');
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
expect(html).toContain('"url":"https://rxresu.me/"');
expect(html).not.toContain("utm_source");
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
});
it("serves the homepage prerendered in the requested or saved locale, with hreflang alternates", async () => {
vi.mocked(fs.readFile).mockImplementation((path) => {
const locale = String(path).match(/dist-prerender\/(.+)\.html$/)?.[1];
return Promise.resolve(`<html><head></head><body><div id="app">${locale ?? "shell"}</div></body></html>`);
});
const german = await handleWebApp(new Request("https://example.com/?locale=de-DE"));
const html = await german.text();
expect(html).toContain('<div id="app">de-DE</div>');
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/?locale=de-DE">');
expect(html).toContain('<link rel="alternate" hreflang="x-default" href="https://rxresu.me/">');
expect(german.headers.get("Vary")).toBe("Cookie");
const saved = new Request("https://example.com/", { headers: { cookie: "theme=dark; locale=ar-SA" } });
const savedHtml = await (await handleWebApp(saved)).text();
expect(savedHtml).toContain('<div id="app">ar-SA</div>');
expect(savedHtml).toContain('<link rel="canonical" href="https://rxresu.me/">');
// Only known locales name a file, so the parameter can't point anywhere else.
const unknown = await handleWebApp(new Request("https://example.com/?locale=../../index"));
expect(await unknown.text()).toContain('<div id="app">en-US</div>');
});
describe("the ATS checker page", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
@@ -102,61 +101,11 @@ describe("web app fallback classification", () => {
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
});
it("replaces the shell metadata with the checker's own", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/ats-checker"))).text();
expect(html).toContain("<title>ATS Checker - Reactive Resume</title>");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
expect(html).toContain('id="ats-checker-structured-data"');
expect(html).not.toContain("Marketing copy.");
});
it("answers HEAD without a body", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker", { method: "HEAD" }));
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
});
it("does not treat the checker path as a public resume owner", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker/anything"));
expect(response.status).toBe(404);
expect(mocks.getPublicResumeSocialMeta).not.toHaveBeenCalled();
});
});
describe("public resume social cards", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("injects resume-specific social metadata and replaces the shell title", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: "Jane Doe",
title: "Jane Doe — Staff Engineer",
description: "Builds resilient distributed systems.",
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" });
expect(html).toContain("<title>Jane Doe - Reactive Resume</title>");
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
expect(html).not.toContain("Marketing copy.");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
expect(html).toContain('<meta property="og:type" content="profile">');
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/opengraph/banner.jpg">');
});
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
@@ -188,43 +137,6 @@ describe("web app fallback classification", () => {
expect(html).toContain("<title>Jane $&amp; $&#39; Doe - Reactive Resume</title>");
expect(html).toContain('<meta name="description" content="Costs $$ and $` nothing">');
});
it("serves the plain shell when the resume is not publicly shareable", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text();
expect(html).toBe(shell);
});
it("serves the plain shell when the lookup fails", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable"));
const response = await handleWebApp(new Request("https://example.com/jane/resume"));
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(shell);
});
});
it("caches versioned homepage media immutably", async () => {
const headers = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse-v1.mp4" },
header: (name, value) => headers.set(name, value),
});
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
const unversionedHeaders = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse.mp4" },
header: (name, value) => unversionedHeaders.set(name, value),
});
expect(unversionedHeaders.get("Cache-Control")).toBeNull();
});
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
@@ -247,72 +159,9 @@ describe("web app fallback classification", () => {
expect(await response.text()).toBe("<html>app</html>");
},
);
it("serves noindex shell for public resume shaped routes", async () => {
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
expect(response.status).toBe(200);
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await response.text()).toBe("<html>app</html>");
});
it("returns noindex 404 for unknown non-asset routes", async () => {
const response = await handleWebApp(new Request("https://example.com/unknown/extra/path"));
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
});
it.each(["/api/foo", "/mcp/foo", "/uploads/foo"])(
"does not treat reserved two-segment path %s as a public resume",
async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
},
);
it("returns plain 404 for missing asset-looking paths", async () => {
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
expect(response.status).toBe(404);
expect(response.headers.get("X-Robots-Tag")).toBeNull();
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
});
it("mirrors fallback status and headers for HEAD without a body", async () => {
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
const unknownResponse = await handleWebApp(
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
);
expect(knownResponse.status).toBe(200);
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(knownResponse.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await knownResponse.text()).toBe("");
expect(unknownResponse.status).toBe(404);
expect(unknownResponse.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(unknownResponse.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await unknownResponse.text()).toBe("");
});
});
describe("configured root shell", () => {
it.each(["GET", "HEAD"])("serves no-store noindex headers for %s", async (method) => {
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
const response = await handleWebApp(new Request("https://attacker.example/", { method }));
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
it("uses configured canonical root without leaking ID or marketing metadata", async () => {
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
vi.mocked(fs.readFile).mockResolvedValue(
+36 -53
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { serveStatic } from "@hono/node-server/serve-static";
import { env } from "@reactive-resume/env/server";
import { defaultLocale, getLocaleAlternates, isLocale, localizedUrl } from "@reactive-resume/utils/locale";
function resolveWebDistPath() {
const candidates = [
@@ -19,6 +20,9 @@ function resolveWebDistPath() {
const staticRoot = resolveWebDistPath();
const indexHtmlPath = `${staticRoot}/index.html`;
// The homepage prerendered per locale by the web build (apps/web/vite.config.ts), kept beside dist/ so it's never
// served at an address of its own.
const prerenderRoot = `${staticRoot}-prerender`;
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
/**
* Marketing pages the SPA owns that search engines should index.
@@ -72,40 +76,8 @@ const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ROOT_DESCRIPTION =
"Free, open-source resume builder. Create, update, and share your resume, with no ads and no paywall.";
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
const ROOT_FAQ_ITEMS = [
{
question: "Is Reactive Resume really free?",
answer:
"Yes. Reactive Resume is free to use, with no hidden costs, premium tiers, or subscription fees. It's open source, and it will stay free.",
},
{
question: "How is my data protected?",
answer:
"Your data is stored securely and never shared with third parties. If you want full control over it, you can self-host Reactive Resume on your own servers.",
},
{
question: "Can I export my resume to PDF?",
answer: "Yes. One click exports your resume to PDF, with your formatting and styling intact.",
},
{
question: "Is Reactive Resume available in multiple languages?",
answer:
"Yes. Pick your language on the settings page, or with the language switcher in the top right corner. If your language is missing, or the existing translation could be better, you can contribute to the translations on Crowdin.",
},
{
question: "What makes Reactive Resume different from other resume builders?",
answer:
"Reactive Resume is open source, private, and free. It shows no ads, doesn't track what you do, and doesn't lock features behind a paywall.",
},
{
question: "How do I share my resume?",
answer: "Share it with a public URL, put a password on that URL, or download the PDF and send it yourself.",
},
] as const;
function createRootSeoMarkup(canonicalUrl: string) {
const origin = new URL(canonicalUrl).origin;
function createRootSeoMarkup(rootUrl: string, canonicalUrl: string) {
const origin = new URL(rootUrl).origin;
const imageUrl = `${origin}/opengraph/banner.jpg`;
const structuredData = {
"@context": "https://schema.org",
@@ -113,12 +85,12 @@ function createRootSeoMarkup(canonicalUrl: string) {
{
"@type": "WebSite",
name: "Reactive Resume",
url: canonicalUrl,
url: rootUrl,
},
{
"@type": ["SoftwareApplication", "WebApplication"],
name: "Reactive Resume",
url: canonicalUrl,
url: rootUrl,
description: ROOT_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
@@ -133,31 +105,24 @@ function createRootSeoMarkup(canonicalUrl: string) {
{
"@type": "Project",
name: "Reactive Resume",
url: canonicalUrl,
url: rootUrl,
sameAs: ["https://github.com/reactive-resume/reactive-resume"],
},
{
"@type": "FAQPage",
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
},
],
};
const alternates = getLocaleAlternates(rootUrl)
.map(({ hreflang, href }) => `<link rel="alternate" hreflang="${hreflang}" href="${escapeAttribute(href)}">`)
.join("");
return `
<link rel="canonical" href="${canonicalUrl}">
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
<link rel="canonical" href="${escapeAttribute(canonicalUrl)}">
${alternates}
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ROOT_TITLE}">
<meta property="og:description" content="${ROOT_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:url" content="${escapeAttribute(canonicalUrl)}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ROOT_TITLE}">
@@ -281,6 +246,18 @@ function getFallbackResponseHeaders(pathname: string) {
return null;
}
/**
* The homepage's language: a `?locale=` address first (its hreflang alternates), then the visitor's saved choice, in
* the order the app reads them (apps/web/src/libs/locale.ts).
*/
function getHomepageLocale(request: Request) {
const requested = new URL(request.url).searchParams.get("locale");
if (isLocale(requested)) return { locale: requested, requested: true };
const saved = request.headers.get("cookie")?.match(/(?:^|;\s*)locale=([^;]*)/)?.[1] ?? "";
return { locale: isLocale(saved) ? saved : defaultLocale, requested: false };
}
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
@@ -322,9 +299,15 @@ export async function handleWebApp(request: Request) {
}
if (pathname === "/") {
const { locale, requested } = getHomepageLocale(request);
// Without a prerendered page (a build that skipped it), the app renders the homepage in the browser.
const page = await fs.readFile(`${prerenderRoot}/${locale}.html`, "utf-8").catch(() => html);
const pageUrl = requested ? localizedUrl(canonicalUrl, locale) : canonicalUrl;
return new Response(
html.replace("</head>", () => `${createRootSeoMarkup(canonicalUrl)}</head>`),
{ headers },
page.replace("</head>", () => `${createRootSeoMarkup(canonicalUrl, pageUrl)}</head>`),
// The saved locale changes what this address shows, so caches have to key on the cookie.
{ headers: { ...headers, Vary: "Cookie" } },
);
}
+1 -1
View File
@@ -34,7 +34,7 @@
<title>Reactive Resume — A free and open-source resume builder</title>
</head>
<body>
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
<!-- Empty here; the server prerenders the homepage into it (apps/server/src/static/web.ts). -->
<div id="app"></div>
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
<div id="initial-loader">
+575 -740
View File
File diff suppressed because it is too large Load Diff
+574 -739
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+577 -742
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+574 -739
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+574 -739
View File
File diff suppressed because it is too large Load Diff
+574 -739
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+575 -740
View File
File diff suppressed because it is too large Load Diff
+542 -707
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -32,7 +32,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/manrope": "^5.3.0",
"@fontsource-variable/anybody": "^5.3.0",
"@fontsource-variable/martian-mono": "^5.3.0",
"@lezer/highlight": "^1.2.4",
"@lingui/core": "^6.8.0",
"@lingui/react": "^6.8.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -1,199 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ArrowUpRightIcon, FilePdfIcon, SpinnerGapIcon } from "@phosphor-icons/react";
import { useEffect, useId, useRef, useState } from "react";
const focusRing = "focus-visible:outline-2 focus-visible:outline-(--home-accent) focus-visible:outline-offset-4";
const message = "max-w-[300px] text-[14px] leading-[1.8]";
/** The extracted text of the chosen file, or of the bundled sample; null when the read was cancelled midway. */
async function readPdf(selected: File | null, signal: AbortSignal) {
let source = selected;
if (!source) {
const response = await fetch("/templates/pdf/rhyhorn.pdf", { signal });
if (!response.ok) throw new Error("Sample PDF unavailable");
source = new File([await response.blob()], "rhyhorn.pdf", { type: "application/pdf" });
}
if (signal.aborted) return null;
const { runAtsCheck } = await import("@/features/ats-checker/run-ats-check");
if (signal.aborted) return null;
const result = await runAtsCheck(source, { signal });
return signal.aborted ? null : result.fullText;
}
export default function AtsPlayground() {
const inputId = useId();
const controllerRef = useRef<AbortController | null>(null);
const [file, setFile] = useState<File | null>(null);
const [phase, setPhase] = useState<"idle" | "reading" | "done" | "error">("idle");
const [text, setText] = useState("");
const [error, setError] = useState("");
useEffect(() => () => controllerRef.current?.abort(), []);
const reset = () => {
controllerRef.current?.abort();
setFile(null);
setText("");
setError("");
setPhase("idle");
};
const read = async (selected: File | null) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setFile(selected);
setText("");
setError("");
if (selected && selected.type !== "application/pdf" && !/\.pdf$/i.test(selected.name)) {
setError(t`Choose a PDF file to read its text.`);
setPhase("error");
return;
}
setPhase("reading");
try {
const fullText = await readPdf(selected, controller.signal);
if (fullText === null) return;
setText(fullText);
setPhase("done");
} catch (caught) {
if (controller.signal.aborted) return;
const pdfErrors: Record<string, string> = {
PdfPasswordRequiredError: t`This PDF is password protected. Save an unprotected copy and try again.`,
PdfTooLargeError: t`This PDF is too large. Choose a file smaller than 25 MB.`,
PdfUnreadableError: t`This file could not be read as a PDF. Export a new copy and try again.`,
};
setError(
(caught instanceof Error && pdfErrors[caught.name]) ||
t`Something went wrong while reading this file. Please try again.`,
);
setPhase("error");
}
};
const isReading = phase === "reading";
return (
<div className="grid grid-cols-[minmax(180px,0.8fr)_auto_minmax(260px,1.25fr)] items-center gap-[38px] text-(--home-ink) max-[600px]:grid-cols-1 max-[900px]:grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] max-[600px]:gap-6 max-[900px]:gap-[25px]">
<div className="min-w-0 text-center max-[900px]:col-[1]">
<div className="transform-[perspective(1000px)_rotateY(9deg)_rotateZ(-5deg)] mx-auto mt-3 mb-[27px] aspect-[210/297] max-w-[235px] overflow-hidden bg-[#f5f4ef] text-[#414145] shadow-[12px_20px_36px_#0006] max-[600px]:max-w-[165px] max-[900px]:max-w-[180px]">
{file ? (
<div className="flex h-full flex-col items-center justify-center gap-[18px] text-[13px]">
<FilePdfIcon size={58} weight="thin" aria-hidden="true" />
<span>
<Trans>Your selected file</Trans>
</span>
</div>
) : (
<img
className="size-full object-cover"
src="/templates/jpg/rhyhorn.jpg"
alt={t`Rhyhorn sample resume`}
width="510"
height="720"
loading="lazy"
/>
)}
</div>
<p className="wrap-anywhere text-[14px]">{file?.name ?? "rhyhorn.pdf"}</p>
<span className="mt-[5px] block text-(--home-muted) text-[12px]">
{file ? t`Stays in your browser` : t`A real sample PDF`}
</span>
</div>
<div className="flex flex-col items-center gap-[15px] max-[900px]:col-[1] max-[600px]:row-auto max-[900px]:row-[2] max-[600px]:flex-row max-[600px]:flex-wrap max-[600px]:justify-center max-[600px]:gap-2.5">
<button
className={`motion-safe:active:not-disabled:not-focus-visible:transform-[scale(0.97)] flex min-w-[140px] flex-col items-center gap-[14px] rounded border border-(--home-line) bg-(--home-panel) px-4 py-5 text-(--home-ink) text-[13px] [transition:transform_140ms_var(--ease-out-strong),border-color_150ms_ease] hover:not-disabled:border-(--home-accent) disabled:cursor-progress max-[900px]:flex-row max-[900px]:gap-2.5 max-[900px]:py-[13px] ${focusRing}`}
type="button"
disabled={isReading}
onClick={() => void read(file)}
>
{isReading ? (
<SpinnerGapIcon className="animate-spin text-(--home-accent)" size={22} aria-hidden="true" />
) : (
<ArrowRightIcon className="text-(--home-accent)" size={22} aria-hidden="true" />
)}
{isReading
? t`Reading PDF`
: phase === "error"
? t`Try again`
: phase === "done"
? t`Read again`
: t`Read this resume`}
</button>
<label
className="relative inline-flex min-h-[44px] cursor-pointer items-center p-2 text-[13px] underline underline-offset-[5px] focus-within:outline-(--home-accent) focus-within:outline-2 focus-within:outline-offset-4"
htmlFor={inputId}
>
<Trans>Try your PDF</Trans>
<input
className="absolute inset-0 w-full cursor-pointer opacity-0"
id={inputId}
type="file"
accept="application/pdf,.pdf"
onChange={(event) => {
const selected = event.target.files?.[0];
event.target.value = "";
if (selected) void read(selected);
}}
/>
</label>
{phase !== "idle" && (
<button
className={`min-h-9 px-3 py-1.5 text-(--home-muted) text-[12px] ${focusRing}`}
type="button"
onClick={reset}
>
{isReading ? t`Cancel` : t`Reset`}
</button>
)}
</div>
<div className="min-w-0 overflow-hidden rounded border border-(--home-line) bg-(--home-panel) max-[600px]:col-[1] max-[900px]:col-[2] max-[600px]:row-auto max-[900px]:row-[1/3]">
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-2 border-(--home-line) border-b px-[22px] py-[18px] text-[13px]">
<span>
<Trans>Extracted text</Trans>
</span>
<span className="text-(--home-muted) text-[11px]" role="status">
{isReading
? t`Reading locally`
: phase === "done"
? text.trim()
? t`Text extracted`
: t`No text found`
: t`PDF text layer`}
</span>
</div>
<section
className="h-80 overflow-auto overscroll-contain p-[23px] focus-visible:outline-(--home-accent) focus-visible:outline-2 focus-visible:outline-offset-[-3px] max-[600px]:h-[280px] max-[600px]:p-5"
aria-label={t`Extracted text`}
aria-busy={isReading}
// biome-ignore lint/a11y/noNoninteractiveTabindex: The extracted text overflows and must be keyboard scrollable.
tabIndex={0}
>
{phase === "error" ? (
<p className={`${message} text-[#e1bda8]`} role="alert">
{error}
</p>
) : phase === "done" && text.trim() ? (
<pre className="wrap-anywhere whitespace-pre-wrap font-[IBM_Plex_Mono,monospace] text-[12px] leading-[1.9]">
{text}
</pre>
) : (
<p className={`${message} text-(--home-muted)`}>
{isReading
? t`Reading this PDF in your browser. Your file is not uploaded.`
: phase === "done"
? t`No readable text found. This PDF may contain scanned images. Export a PDF with selectable text and try again.`
: t`Read the sample to see its text without the layout. Or try a PDF of your own.`}
</p>
)}
</section>
<a
className={`flex items-center justify-between gap-[15px] border-(--home-line) border-t px-[22px] py-[18px] text-(--home-ink) text-[12px] hover:underline hover:underline-offset-4 ${focusRing}`}
href="/ats-checker"
>
<Trans>Open the full ATS checker</Trans> <ArrowUpRightIcon size={17} aria-hidden="true" />
</a>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import type { CSSProperties } from "react";
/** A seeded Park–Miller generator, so the motes land in the same places on the server and in the browser. */
function seeded(seed: number) {
let state = seed;
return () => {
state = (state * 16807) % 2147483647;
return state / 2147483647;
};
}
const random = seeded(11);
const motes = Array.from({ length: 18 }, () => ({
left: `${4 + random() * 50}%`,
top: `${8 + random() * 74}%`,
"--size": `${1.5 + random() * 2.4}px`,
"--mote-duration": `${(16 + random() * 18).toFixed(1)}s`,
"--mote-delay": `${(-random() * 34).toFixed(1)}s`,
"--dx": `${20 + random() * 70}px`,
"--dy": `${-(70 + random() * 150)}px`,
}));
/**
* The desk the page sits on. In light mode, warm window light breathes, the blurred shadow of the window's mullions
* drifts and dust floats up through it. In dark mode, a neutral lamp follows the cursor. All of it is still under
* reduced motion.
*/
export function LandingBackground() {
return (
<>
<div
data-lamp
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-0 bg-[radial-gradient(circle_620px_at_var(--lx,72%)_var(--ly,24%),oklch(0.92_0_0/.06),transparent_70%)] opacity-0 transition-opacity duration-[.8s] dark:opacity-100"
/>
<div aria-hidden="true" className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<div className="transition-opacity duration-[.8s] dark:opacity-0">
<div className="absolute -inset-[20%] bg-[radial-gradient(38%_34%_at_24%_20%,oklch(0.995_0.04_85/.8),transparent_72%)] motion-safe:animate-breathe" />
<div className="window-shadow absolute -top-[30%] -left-[12%] h-[140%] w-[78%] motion-safe:animate-drift" />
</div>
{motes.map((style) => (
<span
key={style.left}
className="absolute size-(--size) rounded-full bg-[oklch(0.5_0.04_80/.35)] opacity-0 motion-safe:animate-mote dark:bg-[oklch(0.9_0.06_80/.45)]"
style={style as CSSProperties}
/>
))}
</div>
</>
);
}
+222
View File
@@ -0,0 +1,222 @@
import type { PointerEvent } from "react";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Link } from "@tanstack/react-router";
import { useRef } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { SCENE, useLanding } from "./scroll";
import { designSystemBullet, ProfileSummary, Sheet } from "./sheet";
import { Doodle, labelClass, SceneCaption } from "./ui";
const field = "text-[oklch(0.62_0.08_150)]";
/** What the Check lens reveals: the page as a parser sees it, fields and sections, flagging the mixed dates. */
function ParsedText() {
const { i18n } = useLingui();
const section = (name: string, count?: number) => (
<span className={field}>
[section] <span className="uppercase">{name}</span>
{count !== undefined && ` · ${i18n.number(count)}`}
</span>
);
return (
<>
<div>
<span className={field}>name</span> <span className="text-[2.6cqw]">{t`Alex Morgan`}</span>
<br />
<span className={field}>title</span> {t`Senior Product Designer`}
<br />
<span className={field}>email</span> alex@morgan.design <span className={field}>loc</span> {t`Lisbon, Portugal`}{" "}
<span className={field}>url</span> morgan.design
</div>
<div>
{section(t`Profile`)}
<br />
<ProfileSummary />
</div>
<div>
{section(t`Experience`, 2)}
<br />
<span className={field}>role</span> {t`Lead Product Designer`} <span className={field}>org</span>{" "}
{t`Northwind Labs`}
<br />
<span className={field}>dates</span> {t`2021 – Present`}
<span className="block truncate">· {designSystemBullet()}</span>
<span className={field}>role</span> {t`Product Designer`} <span className={field}>org</span> {t`Parcel & Co.`}
<br />
<span className={field}>dates</span>{" "}
<span className="text-[oklch(0.85_0.13_85)]">
{t`06/2017 – 2021`} ⚠ {t`mixed format`}
</span>
</div>
<div>
{section(t`Education`, 1)}
<br />
{t`BA, Communication Design`} · {t`University of Porto`} · {t`2013 – 2017`}
</div>
<div>
{section(t`Skills`, 6)}
<br />
<span className="lowercase">
{[t`Design systems`, t`User research`, t`Accessibility`, t`Prototyping`, t`Figma, HTML and CSS`].join(", ")}
</span>
</div>
<div>
{section(t`Languages`, 3)}
<br />
<span className="lowercase">{[t`English (native)`, t`Portuguese (native)`, t`Spanish (B2)`].join(", ")}</span>
</div>
</>
);
}
/**
* 03 Check. A scan line turns "Check." from display type into monospace while the score climbs and the checklist
* ticks off. A lens drifts down the page, showing what hiring software reads; with a mouse, it follows the cursor.
*/
export function Check() {
const { i18n } = useLingui();
const progress = useLanding((state) => state.checkProgress);
const sectionRef = useRef<HTMLElement>(null);
const check = t`Check`;
const score = Math.round(58 + 36 * progress);
const items = [
{ label: t`Contact details found`, done: progress > 0.08, warning: false },
{ label: t`Text is selectable`, done: progress > 0.2, warning: false },
{ label: t`Standard section headings`, done: progress > 0.36, warning: false },
{
label: progress > 0.72 ? t`Dates use one format` : t`Two date formats found`,
done: progress > 0.72,
warning: progress > 0.42,
},
];
// The pointer steers the lens while it's over the page; on leaving, the scroll engine takes it back.
const moveLens = (event: PointerEvent<HTMLDivElement>) => {
const section = sectionRef.current;
if (!section || event.pointerType !== "mouse") return;
const rect = event.currentTarget.getBoundingClientRect();
section.dataset.lensHover = "";
section.style.setProperty("--lx", `${(((event.clientX - rect.left) / rect.width) * 100).toFixed(2)}%`);
section.style.setProperty("--ly", `${(((event.clientY - rect.top) / rect.height) * 100).toFixed(2)}%`);
};
const releaseLens = () => {
if (sectionRef.current) delete sectionRef.current.dataset.lensHover;
window.dispatchEvent(new Event("scroll"));
};
return (
<section
ref={sectionRef}
id="check"
data-scene={SCENE.check}
data-pin
aria-labelledby="check-title"
className="relative h-[260vh] motion-reduce:h-svh min-[900px]:h-[320vh]"
>
<div className="sticky top-0 h-svh overflow-hidden [--chk:var(--sp)] [--dw:calc(clamp(0,(var(--sp)-.42)*20,1)*(1-clamp(0,(var(--sp)-.7)*20,1)))] [--lo:clamp(0,(var(--p)-.04)*10,1)] [--pw:48vw] [--sp:clamp(0,(var(--p)-.06)/.8,1)] [--sv:clamp(0,var(--p)/.24,1)] min-[900px]:[--pw:min(28vw,58vh)]">
<Doodle
name="magnifier"
wipe="clamp(0, (var(--p) - .04) / .2, 1)"
className="start-[46vw] top-[62vh] w-[10vw] translate-y-[calc(var(--p)*-50px)] rotate-[-16deg] max-[900px]:hidden"
/>
<h2
id="check-title"
className="absolute start-(--gutter) top-[10vh] whitespace-nowrap font-normal text-[16vw] text-ink leading-[.82] min-[900px]:top-[11vh] min-[900px]:text-[clamp(72px,9vw,160px)]"
>
<span className="block font-anybody font-light tracking-[-.03em] [clip-path:inset(calc(var(--sv)*120%-20%)_0_-20%_0)]">
{check}
<span className="text-accent">.</span>
</span>
<span
aria-hidden="true"
className="absolute start-0 top-0 block font-light font-martian text-accent-text tracking-[-.05em] [clip-path:inset(-20%_0_calc((1-var(--sv))*120%)_0)]"
>
{check}.
</span>
<span
aria-hidden="true"
className="absolute inset-x-0 top-[calc(var(--sv)*120%-20%)] h-0.5 bg-accent opacity-[calc(clamp(0,var(--sv)*30,1)*clamp(0,(1-var(--sv))*30,1))] shadow-[0_0_18px_2px_oklch(0.6_0.12_150/.5)]"
/>
</h2>
<div className="absolute start-(--gutter) top-[calc(10vh+18vw)] bottom-[clamp(20px,5vh,44px)] flex w-[40vw] flex-col justify-between gap-6 min-[900px]:top-[calc(11vh+clamp(72px,9vw,160px)*.95+4vh)] min-[900px]:w-[min(34em,36vw)]">
<div className="flex flex-col gap-3.5">
<span className={cn(labelClass, "text-ink-3")}>{t`ATS readability`}</span>
<p className="flex items-baseline gap-2.5">
<span className="font-anybody font-light text-[48px] text-ink tabular-nums leading-[.9] tracking-[-.03em] min-[900px]:text-[clamp(52px,5vw,92px)]">
{i18n.number(score)}
</span>
<span className="font-martian text-[13px] text-ink-3">/ {i18n.number(100)}</span>
</p>
<ul className="flex flex-col gap-2 font-medium font-ui text-sm leading-[1.3]">
{items.map((item, index) => (
<li
key={index}
className={cn(
"flex items-center gap-[9px] transition-colors duration-300",
item.done || item.warning ? "text-ink" : "text-ink-3",
)}
>
<Icon
filled
size={18}
name={item.done ? "check_circle" : item.warning ? "error" : "radio_button_unchecked"}
className={cn(
"transition-colors duration-300",
item.done ? "text-accent" : item.warning ? "text-[oklch(0.72_0.14_80)]" : "text-line-2",
)}
/>
{item.label}
</li>
))}
</ul>
</div>
<SceneCaption
number="03"
title={check}
className="hidden min-[900px]:flex min-[900px]:short:hidden"
action={
<Link
to="/ats-checker"
className="flex items-center gap-1.5 self-start font-semibold font-ui text-[15px] text-accent-text transition-colors hover:text-accent-hover"
>
{t`Check any PDF, no account needed`}
<Icon name="arrow_forward" size={19} />
</Link>
}
>
{t`Check reads your resume the way hiring software does and explains what to fix in plain words. It runs in your browser, so the file stays with you.`}
</SceneCaption>
</div>
<div
onPointerMove={moveLens}
onPointerLeave={releaseLens}
className="@container absolute end-[5vw] top-[60%] aspect-[612/792] w-(--pw) cursor-crosshair rounded-[2px] shadow-paper [transform:translateY(-50%)_rotate(1deg)] min-[900px]:end-[10vw] min-[900px]:top-[52%]"
>
<Sheet />
<div
aria-hidden="true"
className="check-lens absolute inset-0 flex flex-col gap-[2.4cqw] overflow-hidden rounded-[2px] p-[8cqw] font-martian text-[1.55cqw] text-[oklch(0.88_0.09_150)] leading-[1.75] opacity-(--lo)"
>
<ParsedText />
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute top-[var(--ly,16%)] left-[var(--lx,50%)] aspect-square w-[32cqw] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[oklch(0.62_0.13_150)] opacity-(--lo) shadow-[inset_0_0_0_1px_oklch(1_0_0/.35),0_20px_50px_-12px_oklch(0_0_0/.5)]"
>
<span className="absolute -top-2.5 left-1/2 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-full bg-[oklch(0.2_0.03_160)] px-[9px] py-[5px] font-martian font-medium text-[10px] text-[oklch(0.88_0.09_150)] uppercase leading-none tracking-[.08em]">
{t`What software reads`}
</span>
</div>
</div>
</div>
</section>
);
}
-12
View File
@@ -1,12 +0,0 @@
// Shared layout/typography utilities used across the homepage components.
export const wrap =
"mx-auto w-[min(1184px,calc(100%_-_112px))] max-[1100px]:w-[calc(100%_-_72px)] max-[900px]:w-[calc(100%_-_48px)] max-[540px]:w-[calc(100%_-_40px)]";
export const section = "scroll-mt-[30px] pt-[100px] pb-[112px] max-[900px]:py-[76px] max-[540px]:py-[61px]";
export const sectionHeading =
"mb-[50px] flex items-end justify-between gap-10 max-[900px]:items-start max-[900px]:gap-[30px] max-[540px]:mb-8 max-[540px]:flex-col max-[540px]:gap-[21px]";
export const sectionTitle =
"font-[Manrope_Variable,sans-serif] font-semibold text-[clamp(36px,4.2vw,57px)] leading-[1.1] tracking-[-0.057em] max-[900px]:text-[42px] max-[540px]:text-[40px]";
export const sectionText =
"max-w-[355px] pb-[3px] text-(--home-muted) text-[16px] leading-[1.7] max-[900px]:max-w-[275px] max-[900px]:text-[14px] max-[540px]:max-w-[340px]";
export const textLink =
"inline-flex min-h-[44px] items-center gap-[9px] text-[14px] underline-offset-[5px] hover:underline";
@@ -1,144 +0,0 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { AnimatePresence, m, useInView, useReducedMotion } from "motion/react";
import { useRef } from "react";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { wrap } from "./classes";
// The statistics service caches these totals for six hours.
const refreshInterval = 6 * 60 * 60 * 1000;
export function CommunityStats() {
const { i18n } = useLingui();
const ref = useRef<HTMLElement>(null);
const entered = useInView(ref, { once: true, amount: 0.2 });
const { data, isError } = useQuery(
orpc.statistics.getTotals.queryOptions({
enabled: entered,
staleTime: refreshInterval,
refetchInterval: refreshInterval,
}),
);
const labels = [t`Users`, t`Resumes`];
const ids = ["users", "resumes"] as const;
const totals = data;
const cachedAt = totals?.cachedAt;
const cachedDate = cachedAt == null ? null : new Date(cachedAt);
const formattedDate = cachedDate ? i18n.date(cachedDate, { dateStyle: "medium", timeStyle: "short" }) : "";
return (
<section
ref={ref}
className={cn(wrap, "pt-12 pb-[54px] max-[600px]:pt-[34px] max-[600px]:pb-10")}
id="community"
aria-labelledby="community-title"
>
<div className="mb-8 max-[600px]:mb-6">
<h2
id="community-title"
className="font-[Manrope_Variable,sans-serif] font-semibold text-[26px] leading-[1.3] tracking-[-0.04em] max-[600px]:text-[24px]"
>
<Trans>You’re in good company.</Trans>
</h2>
</div>
<dl className="grid grid-cols-2 border-(--home-line) border-y pt-[30px] pb-[26px] max-[600px]:grid-cols-1 max-[600px]:gap-6 max-[600px]:py-6">
{ids.map((id, index) => (
<div
className="@container min-w-0 not-first:border-(--home-line) not-first:border-l not-first:pl-[12%] max-[600px]:not-first:border-t max-[600px]:not-first:border-l-0 max-[600px]:not-first:pt-6 max-[600px]:not-first:pl-0"
key={id}
>
<dt className="flex items-center gap-3 text-(--home-muted) text-[13px]">
<Mark id={id} />
{labels[index]}
</dt>
<dd className="mt-[18px] font-[Manrope_Variable,sans-serif] font-extrabold text-[clamp(44px,6.4vw,92px)] leading-[1.1] tracking-[-0.09em] max-[600px]:mt-[11px] max-[600px]:text-[clamp(40px,14cqw,64px)]">
{totals ? (
<RollingTotal value={totals[id]} entered={entered} />
) : (
<span className="text-[#636367]">
<span aria-hidden="true">···</span>
<span className="sr-only">{isError ? t`Total unavailable` : t`Loading total`}</span>
</span>
)}
</dd>
</div>
))}
</dl>
<p className="mt-[14px] text-[#85858c] text-[11px]">
{cachedDate ? (
<Trans>
As of <time dateTime={cachedDate.toISOString()}>{formattedDate}</time>.
</Trans>
) : isError && !totals ? (
t`Some totals are unavailable right now.`
) : (
t`Totals are refreshed periodically.`
)}
</p>
</section>
);
}
type MarkProps = { id: "users" | "resumes" };
function Mark({ id }: MarkProps) {
const users = id === "users";
const sheet = cn(
"absolute left-[7px] border bg-(--home-bg)",
users ? "top-[7px] h-3 w-3 rounded-full" : "top-[3px] h-[19px] w-[14px] rounded-sm",
);
return (
<span className="relative block h-[25px] w-7" aria-hidden="true">
<i className={`${sheet} -translate-x-[5px] translate-y-[2px] -rotate-[13deg] border-[#68606f]`} />
<i className={cn(sheet, users ? "border-[#c7b5a5]" : "border-[#baafc4]")} />
<i className={`${sheet} translate-x-[5px] -translate-y-px rotate-12 border-[#9c8caa]`} />
</span>
);
}
type RollingTotalProps = { value: number; entered: boolean };
function RollingTotal({ value, entered }: RollingTotalProps) {
const { i18n } = useLingui();
const reducedMotion = useReducedMotion();
const formatted = i18n.number(value, { maximumFractionDigits: 0 });
const characters = Array.from(formatted);
return (
<>
<span className="sr-only">{formatted}</span>
<span className="inline-flex [direction:ltr] [unicode-bidi:isolate]" aria-hidden="true">
{characters.map((character, index) => (
<span
// The roll needs a vertical mask, but overflow-clip also cuts horizontally, and the negative
// tracking shrinks each glyph's box below its ink width. Padding widens the clip box; the
// matching negative margin keeps the tightened spacing.
className="relative -mx-[0.12em] inline-block h-[1.16em] shrink-0 overflow-clip px-[0.12em]"
key={characters.length - index}
>
<AnimatePresence mode="popLayout">
<m.span
className="block"
key={character}
initial={reducedMotion ? false : { y: "105%", opacity: 0 }}
animate={entered || reducedMotion ? { y: "0%", opacity: 1 } : { y: "105%", opacity: 0 }}
exit={{ y: "-105%", opacity: 0 }}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 190, damping: 27, delay: index * 0.025 }
}
>
{character}
</m.span>
</AnimatePresence>
</span>
))}
</span>
</>
);
}
+258
View File
@@ -0,0 +1,258 @@
import type { CSSProperties } from "react";
import { i18n } from "@lingui/core";
import { t } from "@lingui/core/macro";
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
import { cn } from "@reactive-resume/utils/style";
import { goToScene, SCENE, useLanding } from "./scroll";
import { Sheet, sheetTemplates } from "./sheet";
import { Doodle, SceneCaption } from "./ui";
/** Where each template tab scrolls the scene to: just past its wipe. */
const TEMPLATE_AT = [0.05, 0.2, 0.36, 0.52, 0.7];
const miniAccents = [
"oklch(0.48 0.1 150)",
"oklch(0.45 0.1 250)",
"oklch(0.5 0.12 40)",
"oklch(0.25 0.01 95)",
"oklch(0.55 0.1 200)",
"oklch(0.5 0.14 330)",
"oklch(0.6 0.12 80)",
];
/**
* The contact sheet the scene zooms out to: fifteen thumbnails, five by three. The middle one is left empty for the
* stack of full pages, which shrinks into its place.
*/
const minis = Array.from({ length: 15 }, (_, index) => {
const kind = (index * 7 + 3) % 6;
const accent = miniAccents[(index * 5 + 1) % miniAccents.length] as string;
return {
index,
number: index < 7 ? index + 2 : index + 1,
accent,
banded: kind === 1 || kind === 4,
sidebar: kind === 2 || kind === 4 || kind === 5,
centred: kind === 3 || kind === 4,
ruled: kind === 0,
wideName: kind === 5,
sidebarEnd: kind === 5,
rise: 30 + ((index * 37) % 60),
};
});
const bar = "h-[1.6cqw] bg-[oklch(0.88_0.006_95)]";
function MiniPage({ mini }: { mini: (typeof minis)[number] }) {
const accentBar = { background: mini.accent };
return (
<div className="@container absolute inset-0 flex flex-col overflow-hidden rounded-[2px] bg-paper shadow-paper">
<div
className="flex flex-col gap-[2.6cqw] px-[9cqw] pt-[9cqw] pb-[6cqw]"
style={{
background: mini.banded ? mini.accent : undefined,
alignItems: mini.centred ? "center" : "flex-start",
borderBottom: mini.ruled ? `0.4cqw solid ${mini.accent}` : undefined,
}}
>
<span
className="h-[5.5cqw] rounded-[1cqw]"
style={{ width: mini.wideName ? "76%" : "54%", background: mini.banded ? "#fff" : "oklch(0.3 0.01 95)" }}
/>
<span className="h-[2.4cqw] w-[34%] rounded-[1cqw]" style={accentBar} />
</div>
<div
className="grid flex-1"
style={{ gridTemplateColumns: mini.sidebar ? "minmax(0,34fr) minmax(0,66fr)" : "minmax(0,1fr)" }}
>
{mini.sidebar && (
<div
className="flex flex-col gap-[2.6cqw] px-[5cqw] py-[7cqw]"
style={{ order: mini.sidebarEnd ? 2 : 0, background: `color-mix(in oklch, ${mini.accent} 10%, white)` }}
>
<span className="h-[2.2cqw] w-[60%]" style={accentBar} />
<span className={bar} />
<span className={bar} />
<span className={cn(bar, "w-[70%]")} />
<span className="mt-[3cqw] h-[2.2cqw] w-1/2" style={accentBar} />
<span className={bar} />
</div>
)}
<div className="order-1 flex flex-col gap-[2.6cqw] px-[8cqw] py-[7cqw]">
<span className="h-[2.2cqw] w-[32%]" style={accentBar} />
<span className={bar} />
<span className={bar} />
<span className={cn(bar, "w-[80%]")} />
<span className="mt-[3cqw] h-[2.2cqw] w-[40%]" style={accentBar} />
<span className={bar} />
<span className={bar} />
<span className={cn(bar, "w-[64%]")} />
<span className={bar} />
<span className={cn(bar, "w-[72%]")} />
<span className="mt-[3cqw] h-[2.2cqw] w-[36%]" style={accentBar} />
<span className={bar} />
<span className={cn(bar, "w-[55%]")} />
</div>
</div>
</div>
);
}
/** "No. 02", or "No. 02 · Sidebar" with a template name. */
function pageNumber(value: number, name?: string) {
const number = i18n.number(value, { minimumIntegerDigits: 2 });
return name ? t`No. ${number} · ${name}` : t`No. ${number}`;
}
const pageLabel = "font-martian font-medium text-[calc(var(--pw)*.045)] leading-none tracking-[.06em] text-ink-3";
/**
* 02 Design. The word "Design." changes its type as five templates wipe across the page in turn, then the scene
* zooms out to a contact sheet of all fifteen. The template tabs jump to each wipe.
*/
export function Design() {
const step = useLanding((state) => state.designStep);
const zoomed = useLanding((state) => state.designZoomed);
const active = useLanding((state) => state.activeScene === SCENE.design);
const design = t`Design`;
const templateNames = sheetTemplates.map((template) => i18n._(template.name));
return (
<section
id="design"
data-scene={SCENE.design}
data-pin
aria-labelledby="design-title"
className="relative h-[380vh] motion-reduce:h-svh min-[900px]:h-[440vh]"
>
<div className="sticky top-0 h-svh overflow-hidden [--dpt:55%] [--pw:min(42vw,30vh)] [--w1:clamp(0,(var(--p)-.1)/.1,1)] [--w2:clamp(0,(var(--p)-.26)/.1,1)] [--w3:clamp(0,(var(--p)-.42)/.1,1)] [--w4:clamp(0,(var(--p)-.58)/.1,1)] [--z:clamp(0,(var(--p)-.76)/.16,1)] min-[900px]:[--dpt:57%] min-[900px]:[--pw:min(24vw,44vh)]">
<Doodle
name="curve"
wipe="clamp(0, (var(--p) - .03) / .2, 1)"
className="start-[79vw] top-[62vh] w-[16vw] translate-y-[calc(var(--p)*-50px)] rotate-[8deg] max-[900px]:hidden"
style={{ "--doodle-fade": "calc(1 - var(--z))" } as CSSProperties}
/>
<h2
id="design-title"
className="absolute inset-x-0 top-[11vh] h-[1.05em] font-normal text-[14vw] text-ink opacity-[calc(1-var(--z))] [transform:translateY(calc(var(--z)*-10vh))] min-[900px]:top-[9vh] min-[900px]:text-[clamp(64px,7.5vw,140px)]"
>
<span className="absolute inset-0 text-center font-display leading-none tracking-[-.03em] opacity-[calc(1-var(--w1))]">
{design}
<span className="text-accent">.</span>
</span>
<span
aria-hidden="true"
className="absolute inset-0 text-center font-medium font-ui text-[.9em] text-[oklch(0.5_0.11_250)] leading-[1.1] tracking-[-.04em] opacity-[calc(var(--w1)*(1-var(--w2)))] dark:text-[oklch(0.74_0.11_250)]"
>
{design}.
</span>
<span
aria-hidden="true"
className="absolute inset-0 text-center font-display text-[oklch(0.56_0.13_40)] italic leading-none tracking-[-.03em] opacity-[calc(var(--w2)*(1-var(--w3)))] dark:text-[oklch(0.74_0.12_40)]"
>
{design}.
</span>
<span
aria-hidden="true"
className="absolute inset-0 text-center font-light font-martian text-[.66em] uppercase leading-[1.5] tracking-[-.02em] opacity-[calc(var(--w3)*(1-var(--w4)))]"
>
{design}_
</span>
<span
aria-hidden="true"
className="absolute inset-0 text-center font-anybody font-light font-stretch-[90%] uppercase leading-none tracking-[-.03em] opacity-(--w4)"
>
{design}.
</span>
</h2>
<div
aria-hidden="true"
className="absolute top-[calc(var(--dpt)-var(--z)*4vh)] left-1/2 grid grid-cols-[repeat(5,var(--pw))] gap-[calc(var(--pw)*.12)] [transform:translate(-50%,-50%)_scale(calc(1-var(--z)*(1-var(--smin,.5))))] [transition:transform_.35s_var(--ease)]"
>
{minis.map((mini) => (
<div
key={mini.index}
className={cn(
"relative aspect-[612/792] opacity-(--z) [transform:translateY(calc((1-var(--z))*var(--rise)))] [transition:transform_.5s_var(--ease)]",
mini.index === 7 && "invisible",
)}
style={{ "--rise": `${mini.rise}px` } as CSSProperties}
>
<MiniPage mini={mini} />
<span className={cn(pageLabel, "absolute start-0 top-[calc(100%+10px)]")}>{pageNumber(mini.number)}</span>
</div>
))}
</div>
<div
data-stack
className="absolute top-[calc(var(--dpt)-var(--z)*4vh)] left-1/2 aspect-[612/792] w-(--pw) [transform:translate(-50%,-50%)_scale(calc(1-var(--z)*(1-var(--smin,.5))))] [transition:transform_.35s_var(--ease)]"
>
<div className="absolute inset-0 rounded-[2px] shadow-paper" />
{sheetTemplates.map((template, index) => (
<div
key={template.name.id}
className="absolute inset-0"
style={{ "--w": index === 0 ? 1 : `var(--w${index})` } as CSSProperties}
>
<div className="absolute inset-0 [clip-path:inset(0_0_0_calc((1-var(--w))*100%))]">
<Sheet template={template} />
</div>
{index > 0 && (
<div
aria-hidden="true"
className="absolute -top-[6%] -bottom-[6%] left-[calc((1-var(--w))*100%)] -ml-px w-0.5 bg-ink opacity-[calc(clamp(0,var(--w)*30,1)*clamp(0,(1-var(--w))*30,1))]"
>
<span className="absolute -top-1 left-1/2 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-full bg-ink px-[9px] py-[5px] font-martian font-medium text-[10.5px] text-bg leading-none tracking-[.06em]">
{pageNumber(index + 1, templateNames[index])}
</span>
</div>
)}
</div>
))}
<span
aria-hidden="true"
className={cn(pageLabel, "absolute start-0 top-[calc(100%+10px)] whitespace-nowrap opacity-(--z)")}
>
{pageNumber(1, templateNames[step])}
</span>
</div>
<SegmentedControl
aria-label={t`Templates`}
value={step}
onValueChange={(value) => goToScene(SCENE.design, TEMPLATE_AT[value as number] ?? 0)}
inert={zoomed}
className={cn(
"absolute bottom-[5vh] left-1/2 flex h-auto max-w-[calc(100vw-2*var(--gutter))] -translate-x-1/2 overflow-x-auto whitespace-nowrap rounded-full border border-line bg-[color-mix(in_oklch,var(--surface)_85%,transparent)] p-1 opacity-[calc(1-var(--z)*2)] backdrop-blur-[8px] [scrollbar-width:none] min-[900px]:bottom-[clamp(20px,5vh,44px)]",
!active && "pointer-events-none",
)}
>
{templateNames.map((name, index) => (
<SegmentedControlItem
key={name}
value={index}
className="h-8 flex-none rounded-full px-3 font-ui duration-300 data-checked:bg-ink data-checked:text-bg data-checked:shadow-none min-[900px]:px-[13px]"
>
{name}
</SegmentedControlItem>
))}
</SegmentedControl>
<SceneCaption
number="02"
title={design}
className="absolute start-(--gutter) bottom-[clamp(20px,5vh,44px)] hidden max-w-[22em] opacity-[calc(1-var(--z)*2)] min-[1100px]:flex"
>
{t`Pick a template, then set the type, color and spacing. The words stay put, so try as many looks as you like.`}
</SceneCaption>
<p className="pointer-events-none absolute inset-x-(--gutter) bottom-[8vh] text-balance text-center text-[8vw] leading-none opacity-(--z) [transform:translateY(calc((1-var(--z))*30px))] min-[900px]:bottom-[6vh] min-[900px]:text-[clamp(40px,5vw,90px)]">
<span className="font-anybody font-light text-ink tracking-[-.02em]">{t`Fifteen templates.`}</span>{" "}
<span className="font-display text-accent-text italic tracking-[-.02em]">{t`Make any of them yours.`}</span>
</p>
</div>
</section>
);
}
@@ -1,270 +0,0 @@
import type { Template } from "@reactive-resume/schema/templates";
import type { CSSProperties } from "react";
import { i18n } from "@lingui/core";
import { msg, t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { ArrowDownIcon, ArrowUpRightIcon } from "@phosphor-icons/react";
import { useId, useMemo, useState } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { toast } from "@reactive-resume/ui/components/toast";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { resolveLocale } from "@/libs/locale";
// Shared by every button inside the playground (press feedback, busy state, focus ring).
const buttonState =
"active:not-disabled:transform-[scale(0.98)] disabled:cursor-wait disabled:opacity-55 focus-visible:outline-2 focus-visible:outline-(--home-ink) focus-visible:outline-offset-4 focus-visible:transition-none motion-reduce:active:transform-none";
type ExportPlaygroundProps = {
name: string;
accent: string;
typeface: "sans" | "serif";
template: Template;
};
function buildExportSample({ name, accent, typeface, template }: ExportPlaygroundProps, locale = i18n.locale) {
const data = structuredClone(defaultResumeData);
data.basics = {
...data.basics,
name: name.trim() || "Alex Morgan",
headline: t`Product designer`,
email: "alex@example.com",
location: "London, UK",
};
data.picture.hidden = true;
data.summary.title = t`Summary`;
data.summary.content = t`<p>I design software for teams, from early research to the details people use every day.</p>`;
data.sections.experience.title = t`Experience`;
data.sections.experience.items = [
{
id: "sample-experience",
hidden: false,
company: "Northstar Studio",
position: t`Senior product designer`,
location: "London, UK",
period: t`2022 - Present`,
website: { url: "", label: "", inlineLink: false },
roles: [],
description: t`<ul><li>Led research and interface design for a collaborative planning tool.</li><li>Built a shared component library with engineers and documented accessibility patterns.</li><li>Tested prototypes with customers and used their feedback to simplify onboarding.</li></ul>`,
},
{
id: "sample-experience-previous",
hidden: false,
company: "Form & Field",
position: t`Product designer`,
location: "Bristol, UK",
period: "2019 - 2022",
website: { url: "", label: "", inlineLink: false },
roles: [],
description: t`<ul><li>Designed responsive websites and mobile experiences for small businesses.</li><li>Worked with developers from first sketches through release.</li></ul>`,
},
];
data.sections.education.title = t`Education`;
data.sections.education.items = [
{
id: "sample-education",
hidden: false,
school: "University of the West of England",
degree: t`BA`,
area: t`Graphic Design`,
grade: "",
location: "Bristol, UK",
period: "2015 - 2019",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
data.sections.skills.title = t`Skills`;
data.sections.skills.items = [
{
id: "sample-skills",
hidden: false,
icon: "",
iconColor: "",
name: t`Design`,
proficiency: "",
level: 0,
keywords: [t`User research`, t`Interaction design`, t`Prototyping`, t`Design systems`, t`Accessibility`],
},
];
data.metadata.template = template;
data.metadata.page.locale = resolveLocale(locale);
data.metadata.design.colors.primary = accent;
data.metadata.typography.body.fontFamily = typeface === "sans" ? "Helvetica" : "Times-Roman";
data.metadata.typography.heading.fontFamily = data.metadata.typography.body.fontFamily;
data.metadata.typography.body.fontWeights = ["400", "700"];
data.metadata.typography.heading.fontWeights = ["700"];
data.metadata.layout.pages = [
{ fullWidth: false, main: ["summary", "experience", "education"], sidebar: ["skills"] },
];
return data;
}
const formats = [
{
id: "pdf",
label: "PDF",
extension: ".pdf",
description: msg`Keep your layout intact. Ready to send, upload, or print.`,
note: msg`For your next application`,
downloadLabel: msg`Download sample PDF`,
color: "#dbb5a1",
},
{
id: "docx",
label: "Word",
extension: ".docx",
description: msg`Keep editing in Word, Google Docs, or Pages. Layout may differ from the PDF.`,
note: msg`For a few more edits`,
downloadLabel: msg`Download sample DOCX`,
color: "#a7bddd",
},
{
id: "md",
label: "Markdown",
extension: ".md",
description: msg`Your words as a plain-text document, with headings, lists, and links.`,
note: msg`For your words, anywhere`,
downloadLabel: msg`Download sample Markdown`,
color: "#b7c5ad",
},
{
id: "json",
label: "JSON",
extension: ".json",
description: msg`Keep a backup of your content and settings. Import it into Reactive Resume later.`,
note: msg`For keeping a copy`,
downloadLabel: msg`Download sample JSON`,
color: "#c6b7d7",
},
] as const;
export default function ExportPlayground({ name, accent, typeface, template }: ExportPlaygroundProps) {
const { i18n } = useLingui();
const [format, setFormat] = useState<(typeof formats)[number]["id"]>("pdf");
const [busy, setBusy] = useState(false);
const descriptionId = useId();
const data = useMemo(
() => buildExportSample({ name, accent, typeface, template }, i18n.locale),
[name, accent, typeface, template, i18n.locale],
);
const { onDownloadPDF, onDownloadDOCX, onDownloadMarkdown, onDownloadJSON } = useResumeExport({
name: t`${data.basics.name} Sample Resume`,
slug: "sample-resume",
data,
});
const selected = formats.find((item) => item.id === format) ?? formats[0];
// A Map rather than an object literal: static analysis flags computed member access as object injection,
// even though `format` is a closed union set only from `formats`.
const actions = new Map<typeof format, () => void | Promise<void>>([
["pdf", onDownloadPDF],
["docx", onDownloadDOCX],
["md", onDownloadMarkdown],
["json", onDownloadJSON],
]);
const download = async () => {
const action = actions.get(format);
setBusy(true);
try {
if (action) await action();
} catch {
toast.add({ type: "error", description: t`Could not prepare the sample. Please try again.` });
}
setBusy(false);
};
return (
<div
className="group grid grid-cols-[1.1fr_1fr] items-center gap-[clamp(32px,7vw,104px)] text-(--home-ink) max-[700px]:grid-cols-1 max-[700px]:gap-[10px]"
data-format={format}
style={{ "--home-export-color": selected.color } as CSSProperties}
>
<div
className="perspective-[1000px] grid justify-items-center px-[30px] pt-[44px] pb-4 max-[700px]:pt-[35px]"
aria-hidden="true"
>
<div className="transform-3d transform-[rotate(-7deg)_rotateY(-12deg)] group-data-[format=docx]:transform-[rotate(-3deg)_rotateY(8deg)] group-data-[format=json]:transform-[rotate(7deg)_rotateY(9deg)] group-data-[format=md]:transform-[rotate(4deg)_rotateY(-7deg)] motion-reduce:transform-[rotate(-4deg)] relative aspect-[0.76] w-[min(100%,285px)] [transition:transform_280ms_var(--ease-out-strong)] group-has-focus-visible:transition-none max-[700px]:w-[225px]">
<div className="transform-[translate(-26px,21px)_rotate(-10deg)] absolute inset-0 rounded border border-[#535154] bg-[#242427]" />
<div className="transform-[translate(-14px,10px)_rotate(-5deg)] absolute inset-0 rounded border border-[#535154] bg-[#383739]" />
<div className="absolute inset-0 flex flex-col overflow-hidden rounded bg-(--home-export-color) p-[25px] text-[#29272c] shadow-[1px_1px_0_#ffffff60_inset,0_24px_48px_#00000050] max-[700px]:p-5">
<div className="absolute top-0 right-0 size-8 rounded-bl bg-[#ffffff40] shadow-[-2px_3px_4px_#0000000f]" />
<div className="flex items-center justify-between gap-3 pr-1.5 font-medium text-[10px]">
<span>Reactive Resume</span>
<ArrowUpRightIcon size={18} />
</div>
<div className="wrap-anywhere mt-8 font-semibold text-[23px] leading-[1.2] max-[700px]:mt-[25px] max-[700px]:text-[20px]">
{data.basics.name}
<span className="mt-[5px] block font-normal text-[11px]">
<Trans>Product designer</Trans>
</span>
</div>
<div className="mt-[21px] grid gap-1.5">
<i className="h-0.5 bg-current opacity-20" />
<i className="h-0.5 w-[91%] bg-current opacity-20" />
<i className="h-0.5 bg-current opacity-20" />
<i className="h-0.5 w-[62%] bg-current opacity-20" />
</div>
<div className="mt-auto pt-[14px] pb-[15px] font-[ui-monospace,monospace] text-[length:clamp(42px,6vw,72px)] leading-none tracking-[-0.08em] max-[700px]:text-[55px]">
{selected.extension}
</div>
<div className="flex items-center justify-between gap-3 border-[#29272c30] border-t pt-2.5 font-medium text-[10px] max-[700px]:text-[8px]">
<span>
<Trans>Sample resume</Trans>
</span>
<ArrowDownIcon size={20} />
</div>
</div>
</div>
<p className="mt-[45px] text-center text-(--home-muted) text-[13px] max-[700px]:mt-[37px]">
{i18n._(selected.note)}
</p>
</div>
<div className="py-5">
<fieldset className="grid border-(--home-line) border-t" aria-label={t`Sample download format`}>
{formats.map((item) => (
<button
key={item.id}
type="button"
className={`group flex min-h-16 w-full items-center gap-[17px] border-(--home-line) border-b py-3 pr-[15px] pl-2 text-left text-(--home-muted) text-[17px] [transition:color_160ms_ease,background-color_160ms_ease,transform_120ms_var(--ease-out-strong)] hover:not-disabled:bg-[#ffffff08] hover:not-disabled:text-(--home-ink) aria-pressed:bg-[#ffffff04] aria-pressed:text-(--home-ink) aria-pressed:hover:not-disabled:bg-[#ffffff08] ${buttonState}`}
aria-pressed={format === item.id}
disabled={busy}
onClick={() => setFormat(item.id)}
>
<span
className="grid h-[37px] min-w-[43px] place-items-center rounded-[3px_4px_3px_3px] border border-current font-[ui-monospace,monospace] text-[10px] opacity-80"
aria-hidden="true"
>
{item.extension}
</span>
<span>{item.label}</span>
<span
className="ml-auto size-[9px] rounded-full border border-[#67676b] group-aria-pressed:border-(--home-export-color) group-aria-pressed:bg-(--home-export-color) group-aria-pressed:shadow-[0_0_0_4px_#ffffff05]"
aria-hidden="true"
/>
</button>
))}
</fieldset>
<p
className="my-[25px] min-h-12 text-(--home-muted) text-[15px] leading-[1.6] max-[700px]:min-h-0"
id={descriptionId}
>
{i18n._(selected.description)}
</p>
<button
type="button"
className={`flex min-h-14 w-full items-center justify-between gap-5 rounded border border-transparent bg-(--home-export-color) px-5 py-4 font-[550] text-[#252329] text-[15px] [transition:transform_120ms_var(--ease-out-strong),opacity_160ms_ease] hover:not-disabled:opacity-90 ${buttonState}`}
disabled={busy}
aria-describedby={descriptionId}
onClick={() => void download()}
>
<span>{busy ? <Trans>Preparing sample…</Trans> : i18n._(selected.downloadLabel)}</span>
<ArrowDownIcon aria-hidden="true" size={19} />
</button>
<p className="mt-[13px] text-(--home-muted) text-[12px] leading-[1.6]">
<Trans>Uses the name, template, color, and type you chose above.</Trans>
</p>
</div>
</div>
);
}
@@ -1,414 +0,0 @@
import type { CSSProperties } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArrowRightIcon,
ArrowUpRightIcon,
CheckIcon,
CodeIcon,
GlobeIcon,
KanbanIcon,
LockSimpleIcon,
PauseIcon,
PencilSimpleIcon,
PlayIcon,
} from "@phosphor-icons/react";
import { useInView, useReducedMotion } from "motion/react";
import { useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { textLink } from "./classes";
const demoClass =
"min-h-[492px] animate-[home-feature-enter_220ms_var(--ease-out-strong)_both] px-[30px] py-6 group-data-[instant=true]/explorer:animate-none max-[1100px]:p-[23px] max-[900px]:p-[27px] max-[540px]:px-4 max-[540px]:py-5";
const demoTopClass = "flex items-center justify-between gap-5 text-[12px] text-(--home-muted)";
const demoCaptionClass = "mt-[25px] text-[12px] leading-[1.7] text-(--home-muted) max-[540px]:text-[11px]";
const pressTransition =
"[transition:transform_140ms_var(--ease-out-strong),background-color_180ms_ease,color_180ms_ease] active:transform-[scale(0.97)]";
const jobCardClass =
"min-h-[191px] rounded-[4px] border px-3 py-[14px] max-[540px]:min-h-[183px] max-[540px]:px-2 max-[540px]:py-[13px]";
const toolBoxClass =
"flex h-[122px] w-[132px] shrink-0 flex-col items-center justify-center gap-[15px] rounded-[4px] border border-[#4d4d50] bg-[#242426] shadow-[0_5px_0_#111112,0_6px_0_#3b3b3f] max-[540px]:h-[105px] max-[540px]:w-[110px]";
const toolLabelClass = "text-[11px] max-[540px]:text-[10px]";
const stageDotColors = ["bg-[#8898b7]", "bg-[#d9b67b]", "bg-[#9cbd9e]"];
export function FeatureExplorer() {
const [feature, setFeature] = useState("writing");
const [accepted, setAccepted] = useState(false);
const [sharing, setSharing] = useState("public");
const [stage, setStage] = useState(0);
const [instant, setInstant] = useState(false);
const [flowPlaying, setFlowPlaying] = useState(true);
const ref = useRef<HTMLDivElement>(null);
const visible = useInView(ref);
const reducedMotion = useReducedMotion();
// State lives here so a demo keeps its place when you switch away and back.
const features = [
{
id: "writing",
label: t`A hand with the writing`,
hint: t`AI, when you want it`,
icon: PencilSimpleIcon,
demo: <WritingDemo accepted={accepted} onAcceptedChange={setAccepted} />,
},
{
id: "sharing",
label: t`A link worth sharing`,
hint: t`Choose who can see it`,
icon: GlobeIcon,
demo: <SharingDemo sharing={sharing} onSharingChange={setSharing} />,
},
{
id: "tracking",
label: t`Know where things stand`,
hint: t`Your applications, together`,
icon: KanbanIcon,
demo: <TrackingDemo stage={stage} onStageChange={setStage} />,
},
{
id: "tools",
label: t`Fits into your workflow`,
hint: t`API and MCP access`,
icon: CodeIcon,
demo: <ToolsDemo playing={flowPlaying} onPlayingChange={setFlowPlaying} visible={visible} />,
},
];
return (
<div
ref={ref}
className="group/explorer grid grid-cols-[320px_minmax(0,1fr)] gap-[55px] max-[1100px]:grid-cols-[280px_minmax(0,1fr)] max-[900px]:grid-cols-1 max-[1100px]:gap-[26px] max-[900px]:gap-6"
data-instant={instant || reducedMotion}
onClickCapture={(event) => setInstant(event.detail === 0)}
>
<fieldset
className="flex flex-col gap-2 max-[900px]:grid max-[900px]:grid-cols-2 max-[540px]:gap-[6px]"
aria-label={t`Explore features`}
>
{features.map(({ id, label, hint, icon: Icon }) => (
<button
key={id}
type="button"
className="group/item flex w-full items-center gap-[15px] rounded-[4px] border border-transparent bg-transparent px-4 py-[21px] text-left text-(--home-muted) [transition:background-color_150ms_ease,color_150ms_ease] hover:bg-[#1b1b1d] aria-pressed:border-(--home-line) aria-pressed:bg-[#1e1e20] aria-pressed:text-(--home-ink) aria-pressed:hover:bg-[#1b1b1d] max-[540px]:gap-[9px] max-[900px]:p-4 max-[540px]:px-2.5 max-[540px]:py-[13px]"
aria-pressed={feature === id}
onClick={() => setFeature(id)}
>
<Icon size={23} aria-hidden="true" className="max-[540px]:w-[19px]" />
<span className="flex-1">
<strong className="block font-medium text-[16px] max-[540px]:text-[12px] max-[900px]:text-[14px]">
{label}
</strong>
<small className="mt-1 block text-(--home-muted) text-[13px] max-[540px]:text-[10px] max-[900px]:text-[12px]">
{hint}
</small>
</span>
<ArrowRightIcon
size={17}
aria-hidden="true"
className="opacity-0 group-aria-pressed/item:opacity-100 max-[540px]:hidden"
/>
</button>
))}
</fieldset>
<div className="min-h-[492px] overflow-hidden rounded-[4px] border border-(--home-line) bg-[#19191b]">
{features.find((item) => item.id === feature)?.demo}
</div>
</div>
);
}
type WritingDemoProps = { accepted: boolean; onAcceptedChange: (accepted: boolean) => void };
function WritingDemo({ accepted, onAcceptedChange }: WritingDemoProps) {
const revisedText = t`Designed a shared component library that six product teams use to build consistent interfaces.`;
return (
<div className={demoClass}>
<div className={demoTopClass}>
<span>
<Trans>Example edit</Trans>
</span>
<span>Alex Morgan</span>
</div>
<div className="transform-[perspective(1000px)_rotateX(3deg)_rotateY(-3deg)] mx-auto mt-[25px] min-h-[224px] max-w-[430px] rounded-[2px] border border-[#d9d6d0] bg-[#efeee9] px-[30px] pt-7 pb-[38px] text-[#343239] shadow-[0_10px_18px_#0002] max-[540px]:px-6 max-[540px]:pt-[23px] max-[540px]:pb-8">
<span className="text-[#735c9a] text-[11px]">
<Trans>Experience</Trans>
</span>
<h3 className="mt-[9px] font-[Manrope_Variable,sans-serif] font-semibold text-[18px] leading-[1.3] tracking-[-0.03em]">
<Trans>Product designer</Trans>
</h3>
<p
className={cn(
"-mx-[5px] mt-3 rounded-[2px] px-[5px] py-1 text-[12px] leading-[1.8]",
accepted && "bg-[#d6e5d6]",
)}
>
{accepted ? (
<>
<span className="sr-only" role="status">
{revisedText}
</span>
<span aria-hidden="true">
{revisedText.split(" ").map((word, index) => (
<span
key={`${index}-${word}`}
className="animate-[home-word-arrive_180ms_ease-out_both] group-data-[instant=true]/explorer:animate-none"
style={{ animationDelay: `${index * 45}ms` }}
>
{word}{" "}
</span>
))}
</span>
</>
) : (
t`Responsible for working on the design system and helping other teams with their designs.`
)}
</p>
<div className="mt-[15px] h-[3px] w-[95%] bg-[#dad8d4]" />
<div className="mt-[7px] h-[3px] w-[66%] bg-[#dad8d4]" />
</div>
<div className="relative -mt-4 flex items-start gap-3 rounded-[4px] border border-[#514b59] bg-[#2c2732] px-[17px] py-[19px] shadow-[0_10px_20px_#0003] max-[1100px]:flex-wrap max-[540px]:flex-wrap max-[900px]:flex-nowrap max-[540px]:gap-[9px] max-[540px]:px-3 max-[540px]:py-4">
<PencilSimpleIcon size={20} aria-hidden="true" className="mt-[2px] shrink-0 text-[#c9b7dc]" />
<div className="max-[540px]:flex-[1_0_calc(100%_-_30px)]">
<strong className="block font-medium text-[13px]">
{accepted ? t`Your edit is in.` : t`Say what you actually did.`}
</strong>
<p className="mt-[5px] max-w-[250px] text-[#b8b0c0] text-[12px]">
{accepted
? t`You can always go back to the original.`
: t`Lead with the work. Add a concrete detail so someone can picture it.`}
</p>
</div>
<button
type="button"
className={`ml-auto flex min-h-11 shrink-0 items-center gap-[9px] rounded-[4px] border border-[#655971] bg-[#3e3449] px-2.5 py-2 text-[#efebf2] text-[12px] ${pressTransition} max-[1100px]:ml-[31px] max-[540px]:ml-[29px] max-[900px]:ml-auto`}
onClick={() => onAcceptedChange(!accepted)}
>
{accepted ? t`Undo` : t`Use this wording`}
{accepted ? <CheckIcon size={16} aria-hidden="true" /> : <ArrowRightIcon size={16} aria-hidden="true" />}
</button>
</div>
<p className={demoCaptionClass}>
<Trans>
This is a sample edit. In the app, connect your own AI provider. Turn on Review edits to approve changes
before they’re applied.
</Trans>
</p>
</div>
);
}
type SharingDemoProps = { sharing: string; onSharingChange: (sharing: string) => void };
function SharingDemo({ sharing, onSharingChange }: SharingDemoProps) {
const sharingOptions = [
{ id: "public", label: t`Public` },
{ id: "password", label: t`Password` },
{ id: "private", label: t`Private` },
];
return (
<div className={demoClass}>
<div className={demoTopClass}>
<span>
<Trans>Sharing preview</Trans>
</span>
<GlobeIcon size={18} aria-hidden="true" />
</div>
<div className="mt-[25px] mb-5 flex justify-center gap-[3px]">
{sharingOptions.map(({ id, label }) => (
<button
type="button"
key={id}
className={`min-h-11 rounded-[4px] border border-transparent bg-transparent px-[18px] py-2 text-(--home-muted) text-[13px] ${pressTransition} aria-pressed:border-[#47474a] aria-pressed:bg-[#323235] aria-pressed:text-(--home-ink) group-data-[instant=true]/explorer:transition-none`}
aria-pressed={sharing === id}
onClick={() => onSharingChange(id)}
>
{label}
</button>
))}
</div>
<div className="m-auto max-w-[430px] overflow-hidden rounded-[4px] border border-[#404043] shadow-[0_12px_30px_#0002]">
<div className="flex items-center justify-center gap-2 bg-[#252527] p-2.5 text-[#939399] text-[10px]">
<LockSimpleIcon size={13} aria-hidden="true" />
<Trans>Your resume link</Trans>
</div>
<div className="grid bg-[#202022]">
{sharingOptions.map(({ id }) => (
<div
key={id}
className="invisible col-start-1 row-start-1 flex min-h-[215px] flex-col items-center justify-center p-6 text-center opacity-0 [transition:opacity_180ms_ease,visibility_180ms] data-[active=true]:visible data-[active=true]:opacity-100 group-data-[instant=true]/explorer:transition-none"
data-active={sharing === id}
aria-hidden={sharing !== id}
>
{id === "public" ? (
<>
<div className="grid size-11 place-items-center rounded-full bg-[#45382e] text-[#decfbf] text-[12px]">
AM
</div>
<h3 className="mt-3 font-medium text-[19px] tracking-[-0.03em]">Alex Morgan</h3>
<p className="mt-1.5 text-(--home-muted) text-[12px]">
<Trans>Product designer</Trans>
</p>
<div className="mt-[15px] grid w-[110px] gap-[5px]">
<i className="h-[2px] bg-[#444447]" />
<i className="h-[2px] bg-[#444447]" />
<i className="h-[2px] w-[70%] bg-[#444447]" />
</div>
</>
) : (
<>
<LockSimpleIcon size={34} weight="light" aria-hidden="true" />
<h3 className="mt-3 font-medium text-[19px] tracking-[-0.03em]">
{id === "password" ? t`Password required` : t`Resume not found`}
</h3>
<p className="mt-1.5 text-(--home-muted) text-[12px]">
{id === "password"
? t`Visitors need your password to open the resume.`
: t`A private resume does not appear at its public link.`}
</p>
</>
)}
</div>
))}
</div>
</div>
<p className={demoCaptionClass}>
<Trans>
Try the settings to see what visitors can access. When you create your resume, you decide how to share it.
</Trans>
</p>
</div>
);
}
type TrackingDemoProps = { stage: number; onStageChange: (stage: number) => void };
function TrackingDemo({ stage, onStageChange }: TrackingDemoProps) {
const stages = [t`Applied`, t`Interview`, t`Offer`];
return (
<div className={demoClass}>
<div className={demoTopClass}>
<span>
<Trans>Example application</Trans>
</span>
<button
type="button"
className="-my-1.5 min-h-8 px-2 py-1 text-(--home-muted)"
onClick={() => onStageChange(0)}
>
<Trans>Reset</Trans>
</button>
</div>
<div className="mt-[34px] [--home-board-gap:12px] max-[540px]:[--home-board-gap:7px]">
<div className="grid grid-cols-3 gap-(--home-board-gap)">
{stages.map((label, index) => (
<div
key={label}
className="flex items-center gap-1.5 text-(--home-muted) text-[11px] max-[540px]:gap-1 max-[540px]:text-[9px]"
>
<i className={cn("size-1.5 rounded-full", stageDotColors[index])} />
{label}
<span className="ml-auto text-[#85858c]">{stage === index ? 1 : 0}</span>
</div>
))}
</div>
<div
className="relative mt-[15px] grid grid-cols-3 gap-(--home-board-gap)"
style={{ "--stage": stage } as CSSProperties}
>
{stages.map((label) => (
<div
key={label}
className={`${jobCardClass} border-[#323237] border-dashed text-transparent`}
aria-hidden="true"
/>
))}
<div
className={`${jobCardClass} transform-[translateX(calc(var(--stage)_*_(100%_+_var(--home-board-gap))))_rotate(calc(var(--stage)_*_3deg_-_2deg))] absolute top-0 left-0 w-[calc((100%_-_2_*_var(--home-board-gap))_/_3)] border-[#4b4b51] bg-[#2a2a2e] text-(--home-ink) shadow-[0_5px_12px_#0002] [transition:transform_460ms_var(--ease-out-strong)] group-data-[instant=true]/explorer:transition-none`}
>
<div className="mb-4 grid size-[30px] place-items-center rounded-[4px] bg-[#d8c4ac] font-[Georgia,serif] font-semibold text-[#34302a] text-[19px] leading-none">
n.
</div>
<strong className="block font-medium text-[12px] max-[540px]:text-[10px]">
<Trans>Product designer</Trans>
</strong>
<p className="mt-[5px] text-(--home-muted) text-[10px] max-[540px]:text-[9px]">Northstar Studio</p>
<span className="mt-[18px] block text-[#bab4c7] text-[9px] max-[540px]:text-[8px]">
<Trans>Resume attached</Trans>
</span>
</div>
</div>
</div>
<div className="mt-7 flex items-center justify-between text-(--home-muted) text-[12px]">
<span role="status">{stages[stage]}</span>
<button
type="button"
className={`inline-flex min-h-11 items-center gap-2.5 rounded-[4px] border border-[#4b4b51] bg-[#2b2b2e] px-[13px] py-2 text-(--home-ink) text-[12px] ${pressTransition}`}
onClick={() => onStageChange((stage + 1) % stages.length)}
>
{stage === 0 ? t`Move to interview` : stage === 1 ? t`Move to offer` : t`Start again`}
<ArrowRightIcon size={17} aria-hidden="true" />
</button>
</div>
<p className={demoCaptionClass}>
<Trans>
Keep the company, role, resume, and follow-up date together. Switch between a board, table, and insights in
the app.
</Trans>
</p>
</div>
);
}
type ToolsDemoProps = { playing: boolean; onPlayingChange: (playing: boolean) => void; visible: boolean };
function ToolsDemo({ playing, onPlayingChange, visible }: ToolsDemoProps) {
const reducedMotion = useReducedMotion();
return (
<div className={demoClass}>
<div className={demoTopClass}>
<span>
<Trans>Your tools, connected</Trans>
</span>
{!reducedMotion && (
<button
type="button"
className={`-my-3 grid min-h-11 min-w-11 place-items-center rounded-[4px] px-2 py-1 text-(--home-muted) ${pressTransition} hover:bg-[#323235]`}
aria-label={playing ? t`Pause data flow animation` : t`Play data flow animation`}
onClick={() => onPlayingChange(!playing)}
>
{playing ? <PauseIcon size={16} aria-hidden="true" /> : <PlayIcon size={16} aria-hidden="true" />}
</button>
)}
</div>
<div className="mx-auto mt-[55px] mb-[38px] flex max-w-[410px] items-center">
<div className={toolBoxClass}>
<CodeIcon size={32} weight="light" aria-hidden="true" />
<span className={toolLabelClass}>
<Trans>Your AI app</Trans>
</span>
</div>
<div className="group/wire relative h-px flex-1 bg-[#66666d]" data-playing={playing && visible}>
<span className={`absolute -top-[25px] w-full text-center text-(--home-muted) ${toolLabelClass}`}>MCP</span>
<i
className="absolute inset-x-0 -top-[3px] h-[7px] animate-[home-data-flow_3.5s_cubic-bezier(0.45,0,0.55,1)_infinite_alternate_paused] after:-ml-[3px] after:block after:size-[7px] after:rounded-full after:bg-[#ddc8eb] after:shadow-[0_0_0_4px_#c5a4db14,0_0_11px_#c5a4db50] after:content-[''] group-data-[playing=true]/wire:[animation-play-state:running]"
aria-hidden="true"
/>
</div>
<div className={toolBoxClass}>
<img src="/icon/dark.svg" width="38" height="38" alt="" />
<span className={toolLabelClass}>Reactive Resume</span>
</div>
</div>
<h3 className="font-[Manrope_Variable,sans-serif] font-medium text-[23px] leading-[1.3] tracking-[-0.035em] max-[540px]:text-[22px]">
<Trans>Work on your resume from your AI app.</Trans>
</h3>
<p className="mt-[15px] text-(--home-muted) text-[14px] leading-[1.7]">
<Trans>
Connect through MCP to create and update resumes in a conversation. Or use the API to build your own workflow.
</Trans>
</p>
<a href="https://docs.rxresu.me/guides/using-the-mcp-server" className={cn(textLink, "mt-4")}>
<Trans>See how to connect</Trans>
<ArrowUpRightIcon size={17} aria-hidden="true" />
</a>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import { cn } from "@reactive-resume/utils/style";
import { SCENE } from "./scroll";
import { labelClass } from "./ui";
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
const licenseUrl = `${githubUrl}/blob/main/LICENSE`;
const linkClass = "text-ink transition-colors hover:text-accent-text";
type FooterLink = { label: string; href: string } | { label: string; to: "/ats-checker" | "/dashboard" };
const getColumns = (): { id: string; title: string; links: FooterLink[] }[] => [
{
id: "product",
title: t`Product`,
links: [
{ label: t`Templates`, href: "#design" },
{ label: t`Features`, href: "#write" },
{ label: t`ATS checker`, to: "/ats-checker" },
{ label: t`Get started`, to: "/dashboard" },
],
},
{
id: "resources",
title: t`Resources`,
links: [
{ label: t`Documentation`, href: "https://docs.rxresu.me" },
{ label: t`Changelog`, href: "https://docs.rxresu.me/changelog" },
{ label: t`Source code`, href: githubUrl },
{ label: t`Self-hosting`, href: "https://docs.rxresu.me" },
],
},
{
id: "community",
title: t`Community`,
links: [
{ label: t`Discord`, href: "https://discord.gg/aSyA5ZSxpb" },
{ label: t`Subreddit`, href: "https://reddit.com/r/reactiveresume" },
{ label: t`Translations`, href: "https://crowdin.com/project/reactive-resume" },
{ label: t`Report an issue`, href: `${githubUrl}/issues` },
],
},
{
id: "legal",
title: t`Legal`,
links: [
{ label: t`Privacy policy`, href: "https://docs.rxresu.me/legal/privacy-policy" },
{ label: t`MIT License`, href: licenseUrl },
],
},
];
function FooterLinkItem({ link }: { link: FooterLink }): ReactNode {
if ("to" in link) {
return (
<Link to={link.to} className={linkClass}>
{link.label}
</Link>
);
}
return (
<a href={link.href} className={linkClass}>
{link.label}
</a>
);
}
/** 10 Footer, ending on the wordmark rising into view, cropped by the page's edge. */
export function LandingFooter() {
return (
<footer data-scene={SCENE.footer} className="relative overflow-hidden border-line border-t">
<div className="mx-auto grid max-w-[1440px] grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-x-8 gap-y-10 px-(--gutter) pt-16">
<div className="col-span-2 flex min-w-0 flex-col gap-3.5">
<img
src="/logo/light.svg"
alt="Reactive Resume"
width={140}
height={140}
className="-ms-[18px] -mt-[22px] -mb-[18px] size-[140px] dark:hidden"
/>
<img
src="/logo/dark.svg"
alt="Reactive Resume"
width={140}
height={140}
className="-ms-[18px] -mt-[22px] -mb-[18px] hidden size-[140px] dark:block"
/>
<div className="flex flex-col gap-1 font-ui text-[13px] text-ink-3 leading-normal">
<p>{t`By the community, for the community.`}</p>
<p>
<Trans>
Licensed under{" "}
<a
href={licenseUrl}
className="text-accent-text underline underline-offset-[3px] transition-colors hover:text-accent-hover"
>
MIT License
</a>
</Trans>
</p>
</div>
</div>
{getColumns().map((column) => (
<nav
key={column.id}
aria-labelledby={`footer-${column.id}`}
className="flex flex-col gap-2.5 font-ui text-sm"
>
<h2 id={`footer-${column.id}`} className={cn(labelClass, "mb-1 text-ink-3")}>
{column.title}
</h2>
<ul className="flex flex-col gap-2.5">
{column.links.map((link) => (
<li key={link.label}>
<FooterLinkItem link={link} />
</li>
))}
</ul>
</nav>
))}
</div>
{/* It fades in by sliding up out of the mask's faded bottom; an opacity fade would fail contrast checks. */}
<div aria-hidden="true" className="@container mx-auto mt-14 max-w-[1440px] px-(--gutter)">
<div className="h-[23cqw] overflow-hidden [mask-image:linear-gradient(to_bottom,#000_35%,transparent_100%)]">
<div className="select-none whitespace-nowrap pb-[.06em] font-anybody font-extrabold font-stretch-[78%] text-[17.5cqw] text-ink leading-[.84] tracking-[-.035em] [transform:translateY(calc((1-clamp(0,(var(--p)-.1)/.8,1))*60%))] [transition:transform_.35s_var(--ease)]">
<span className="block">Reactive</span>
<span className="block text-accent-text">Resume</span>
</div>
</div>
</div>
</footer>
);
}
+214
View File
@@ -0,0 +1,214 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { useTheme } from "@/features/theme/provider";
import { orpc } from "@/libs/orpc/client";
import { goToScene, prefersReducedMotion, SCENE, useLanding } from "./scroll";
import { CtaLink } from "./ui";
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
/** Header ink during Share's night scene, whatever the theme. */
const nightInk = "text-[oklch(0.95_0.01_95)]";
const getStoryScenes = () => [
{ scene: SCENE.write, label: t`Write` },
{ scene: SCENE.design, label: t`Design` },
{ scene: SCENE.check, label: t`Check` },
{ scene: SCENE.tailor, label: t`Tailor` },
{ scene: SCENE.share, label: t`Share` },
];
export function LandingHeader() {
const scrolled = useLanding((state) => state.scrolled);
const night = useLanding((state) => state.night);
const activeScene = useLanding((state) => state.activeScene);
return (
<header
className={cn(
"fixed inset-x-0 top-0 z-70 flex h-16 items-center gap-7 whitespace-nowrap ps-(--gutter) pe-[calc(var(--gutter)+30px)] transition-colors duration-[.4s]",
night ? nightInk : "text-ink",
)}
>
<div
aria-hidden="true"
className={cn(
"header-taper pointer-events-none absolute inset-x-0 top-0 -z-1 h-28 transition-opacity duration-[.4s]",
scrolled && !night ? "opacity-100" : "opacity-0",
)}
/>
<a href="#top" className="flex items-center gap-2.5 rounded-md">
<img
src="/icon/light.svg"
alt=""
width={30}
height={30}
className={cn("size-[30px] dark:hidden", night && "hidden")}
/>
<img
src="/icon/dark.svg"
alt=""
width={30}
height={30}
className={cn("hidden size-[30px] dark:block", night && "block")}
/>
{/* The wordmark stays in the DOM for screen readers and crawlers; the logomark is what shows. */}
<span className="sr-only">Reactive Resume</span>
</a>
<nav aria-label={t`Story`} className="mx-auto hidden items-center gap-0.5 min-[1240px]:flex">
{getStoryScenes().map(({ scene, label }) => {
const active = activeScene === scene;
return (
<button
key={scene}
type="button"
aria-current={active ? "step" : undefined}
onClick={() => goToScene(scene, 0.04)}
className={cn(
"flex h-[34px] items-center gap-[7px] rounded-full px-[11px] font-martian font-medium font-stretch-[87.5%] text-[11px] uppercase leading-none tracking-[.08em] transition-colors duration-300",
night
? active
? nightInk
: "text-[oklch(0.95_0.01_95/.72)] hover:text-[oklch(0.95_0.01_95)]"
: active
? "text-ink"
: "text-ink-3 hover:text-ink",
)}
>
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full transition-colors duration-300", active && "bg-accent")}
/>
{label}
</button>
);
})}
</nav>
<div className="ms-auto flex items-center gap-2">
<GithubLink />
<CtaLink size="header" />
</div>
</header>
);
}
/** The repository's live star count, counted up from zero once it arrives. */
function GithubLink() {
const { i18n } = useLingui();
const { data: stars } = useQuery(orpc.statistics.github.getStarCount.queryOptions());
const countRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const element = countRef.current;
if (!element || stars == null) return;
const compact = new Intl.NumberFormat(i18n.locale, { notation: "compact", maximumFractionDigits: 1 });
if (prefersReducedMotion()) {
element.textContent = compact.format(stars);
return;
}
let frame = 0;
const start = performance.now();
const step = (now: number) => {
const progress = Math.min(1, (now - start) / 1600);
element.textContent = compact.format(Math.round(stars * (1 - (1 - progress) ** 3)));
if (progress < 1) frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}, [stars, i18n.locale]);
const count = stars == null ? null : i18n.number(stars);
const label = count ? t`Star Reactive Resume on GitHub, ${count} stars` : t`Star Reactive Resume on GitHub`;
return (
<a
href={githubUrl}
aria-label={label}
className="hidden h-[38px] items-center gap-2 rounded-md px-2.5 font-martian font-medium font-stretch-[87.5%] text-[12px] tabular-nums leading-none transition-colors hover:bg-hover lg:flex"
>
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
<span className="flex items-center gap-1">
<Icon name="star" filled size={15} className="text-[oklch(0.72_0.14_80)]" />
<span ref={countRef} className="min-w-[4ch]" />
</span>
</a>
);
}
/**
* The only theme switch: a pull cord with a line-drawn bulb, hanging at the top corner. It sways every few seconds
* until it's first pulled, and the bulb lights up in dark mode.
*/
export function ThemeCord() {
const { resolvedTheme, toggleTheme } = useTheme();
const night = useLanding((state) => state.night);
const [pulling, setPulling] = useState(false);
const [pulledOnce, setPulledOnce] = useState(false);
const timeout = useRef(0);
useEffect(() => () => window.clearTimeout(timeout.current), []);
const pull = () => {
setPulling(true);
setPulledOnce(true);
window.clearTimeout(timeout.current);
// The theme changes as the cord reaches the bottom of its pull.
timeout.current = window.setTimeout(() => {
setPulling(false);
toggleTheme();
}, 170);
};
const label = resolvedTheme === "dark" ? t`Switch to light mode` : t`Switch to dark mode`;
return (
<button
type="button"
onClick={pull}
aria-label={label}
title={label}
className={cn(
"fixed end-[clamp(8px,1.2vw,18px)] top-0 z-72 flex w-[30px] origin-top flex-col items-center [transition:height_.45s_cubic-bezier(.3,1.7,.5,1),color_.4s]",
pulling ? "h-[124px]" : "h-[92px] hover:h-[112px]",
!pulledOnce && "motion-safe:animate-swing",
night ? nightInk : "text-ink",
)}
>
<span aria-hidden="true" className="w-[1.5px] flex-1 bg-current opacity-50" />
<svg aria-hidden="true" width="30" height="40" viewBox="0 0 30 40" className="flex-none overflow-visible">
<circle
cx="15"
cy="22"
r="16"
className="fill-[oklch(0.9_0.14_90)] opacity-0 blur-[6px] transition-opacity duration-500 dark:opacity-35 dark:motion-safe:animate-flicker"
/>
<rect x="11.5" y="2" width="7" height="6" rx="1.2" fill="currentColor" opacity="0.75" />
<path d="M11.8 5h6.4M11.8 7h6.4" strokeWidth="0.8" opacity="0.6" className="stroke-bg" />
<path
d="M11.5 8 C11.5 12 5 15 5 22 a10 10 0 0 0 20 0 C25 15 18.5 12 18.5 8 Z"
stroke="currentColor"
strokeWidth="1.3"
className="fill-[color-mix(in_oklch,var(--bg)_60%,transparent)] transition-[fill] duration-[.4s] dark:fill-[oklch(0.95_0.11_92)]"
/>
<path
d="M12.5 10 V16 M17.5 10 V16 M12.5 16 c0 3 1 4 1.25 5 c.25-1 .5-2 1.25-2 c.75 0 1 1 1.25 2 c.25-1 1.25-2 1.25-5"
fill="none"
strokeWidth="1"
strokeLinecap="round"
className="stroke-current opacity-55 dark:stroke-[oklch(0.7_0.16_60)] dark:opacity-100"
/>
</svg>
</button>
);
}
+258
View File
@@ -0,0 +1,258 @@
import type { CSSProperties } from "react";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { useEffect, useRef } from "react";
import { cn } from "@reactive-resume/utils/style";
import { prefersReducedMotion, SCENE } from "./scroll";
import { Sheet } from "./sheet";
import { CtaLink, Doodle, labelClass } from "./ui";
const fragmentKind = {
display: "font-anybody leading-none",
serif: "font-display leading-none",
mono: "font-martian text-[12px] uppercase leading-none",
pill: "rounded-full px-3.5 py-[9px] font-semibold font-ui text-[15px] leading-none",
};
// The field is dimmed to 78%, so the light pills take a deeper green to keep their text at 4.5:1.
const pillAccent = "bg-accent-soft text-[oklch(0.36_0.1_150)] dark:text-accent-text";
/**
* The eighteen career fragments that converge into the page. Each starts at (x, y), drifts to (tx, ty) and scales
* down as it lands; z is its depth, for the mouse parallax.
*/
const getFragments = () => [
{
text: t`Lead Product Designer`,
at: ["-30vw", "-28vh", "-6vw", "-14vh", "-4deg", 1, 0.9],
className: cn(fragmentKind.display, "text-[2.3vw] text-ink-2"),
},
{
text: t`Design systems`,
at: ["-39vw", "4vh", "-4vw", "2vh", "-8deg", 1.1, 1.2],
className: cn(fragmentKind.pill, pillAccent),
},
{
text: t`Lisbon`,
at: ["41vw", "20vh", "6vw", "10vh", "-3deg", 1, 0.5],
className: cn(fragmentKind.serif, "text-[2.8vw] text-ink-3"),
},
{
text: t`+13 pts activation`,
at: ["-17vw", "33vh", "-2vw", "12vh", "4deg", 1, 1],
className: cn(fragmentKind.display, "font-light font-stretch-[92%] text-[2.8vw] text-accent-text"),
},
{
text: t`Figma`,
at: ["21vw", "37vh", "4vw", "14vh", "10deg", 1.1, 1.3],
className: cn(fragmentKind.pill, "bg-raised text-ink shadow-e2"),
},
{
text: t`WCAG 2.1 AA`,
at: ["-44vw", "-12vh", "-7vw", "-4vh", "0deg", 1, 0.3],
className: cn(fragmentKind.mono, "text-ink-3"),
},
{
text: t`BA, Communication Design`,
at: ["10vw", "-40vh", "1vw", "-11vh", "-2deg", 1, 0.6],
className: cn(fragmentKind.serif, "text-[1.5vw] text-ink-2"),
},
{
text: t`University of Porto`,
at: ["-9vw", "-43vh", "-3vw", "-16vh", "2deg", 1, 0.35],
className: cn(fragmentKind.mono, "text-ink-3"),
},
{
text: t`Parcel & Co.`,
at: ["39vw", "3vh", "5vw", "1vh", "-6deg", 1, 0.8],
className: cn(fragmentKind.display, "font-stretch-[108%] text-[1.9vw] text-ink-2"),
},
{
text: t`Shipped checkout for iOS`,
at: ["-27vw", "23vh", "-5vw", "8vh", "3deg", 1, 0.7],
className: cn(fragmentKind.serif, "text-[1.7vw] text-ink-2 italic"),
},
{
text: t`Prototyping`,
at: ["6vw", "42vh", "0vw", "16vh", "-5deg", 1.1, 1.1],
className: cn(fragmentKind.pill, pillAccent),
},
{
text: "alex@morgan.design",
at: ["25vw", "29vh", "4vw", "9vh", "2deg", 1, 0.4],
className: cn(fragmentKind.mono, "text-ink-3"),
},
{
text: t`Barista, 2012`,
at: ["-44vw", "38vh", "-6vw", "15vh", "-7deg", 1, 0.8],
className: cn(fragmentKind.serif, "text-[2.2vw] text-ink-3 italic"),
},
{
text: t`40% faster reviews`,
at: ["44vw", "38vh", "6vw", "13vh", "-4deg", 1, 1],
className: cn(fragmentKind.display, "font-light font-stretch-[94%] text-[2.4vw] text-ink-2"),
},
{
text: t`Promoted to lead, 2021`,
at: ["-38vw", "-38vh", "-5vw", "-13vh", "4deg", 1, 0.5],
className: cn(fragmentKind.serif, "text-[1.7vw] text-ink-2"),
},
{
text: t`Spanish, B2`,
at: ["-26vw", "43vh", "-3vw", "16vh", "0deg", 1, 0.3],
className: cn(fragmentKind.mono, "text-ink-3"),
},
{
text: t`Six product teams`,
at: ["30vw", "45vh", "5vw", "17vh", "-2deg", 1, 0.6],
className: cn(fragmentKind.display, "font-stretch-[110%] text-[1.5vw] text-ink-3"),
},
{
text: t`Accessibility`,
at: ["-13vw", "-33vh", "-2vw", "-10vh", "7deg", 1.1, 1.2],
className: cn(fragmentKind.pill, pillAccent),
},
];
/**
* 00 Hero, "Everything you've done, on one page." Career fragments float in, converge and become a page; the
* headline splits to either side of it. The paragraph and CTA paint first, with no dependency on the scroll engine.
*/
export function Hero() {
const { i18n } = useLingui();
const ref = useRef<HTMLElement>(null);
// The pencil guides draw in once, shortly after load.
useEffect(() => {
const section = ref.current;
if (!section) return;
if (prefersReducedMotion()) return void section.style.setProperty("--ld", "1");
const timer = window.setTimeout(() => section.style.setProperty("--ld", "1"), 120);
return () => window.clearTimeout(timer);
}, []);
const headline = [
t({ message: "Everything", comment: "Hero headline, line 1 of 4: “Everything / you’ve done, / on one / page.”" }),
t({ message: "you’ve done,", comment: "Hero headline, line 2 of 4: “Everything / you’ve done, / on one / page.”" }),
t({ message: "on one", comment: "Hero headline, line 3 of 4: “Everything / you’ve done, / on one / page.”" }),
t({ message: "page.", comment: "Hero headline, line 4 of 4: “Everything / you’ve done, / on one / page.”" }),
] as const;
return (
<section
ref={ref}
id="top"
data-scene={SCENE.hero}
data-pin
aria-labelledby="hero-title"
className="relative h-[190vh] motion-reduce:h-svh min-[900px]:h-[260vh]"
>
{/* --pt is the page's centre. Below 900px it sits under the stacked headline (128px + three 10vw lines). */}
<div className="sticky top-0 h-svh overflow-hidden [--e2:clamp(0,(var(--p)-.14)/.36,1)] [--e:clamp(0,(var(--p)-.03)/.36,1)] [--pt:calc(144px+30vw+var(--pw)*.647)] [--pw:min(46vw,30vh)] motion-reduce:[--ld:1] min-[900px]:[--pt:50%] min-[900px]:[--pw:min(24vw,46vh)] min-[900px]:short:[--pw:min(24vw,40vh)]">
<svg aria-hidden="true" width="0" height="0" className="absolute">
<filter id="landing-pencil" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.035" numOctaves={2} seed={4} />
<feDisplacementMap in="SourceGraphic" scale={3.5} />
</filter>
</svg>
{/* Construction guides, drawn in pencil around where the page will land. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 text-graphite [filter:url(#landing-pencil)]"
>
<span className="absolute top-[7vh] bottom-[7vh] left-[calc(50%-var(--pw)/2)] w-px bg-current [transform:scaleY(var(--ld,0))] [transition:transform_1.6s_var(--ease)_.2s]" />
<span className="absolute top-[7vh] bottom-[7vh] left-[calc(50%+var(--pw)/2)] w-px bg-current [transform:scaleY(var(--ld,0))] [transition:transform_1.6s_var(--ease)_.35s]" />
<span className="absolute inset-x-[3vw] top-[calc(var(--pt)-var(--pw)*.647)] h-px bg-current [transform:scaleX(var(--ld,0))] [transition:transform_1.8s_var(--ease)_.5s]" />
<span className="absolute inset-x-[3vw] top-[calc(var(--pt)+var(--pw)*.647)] h-px bg-current [transform:scaleX(var(--ld,0))] [transition:transform_1.8s_var(--ease)_.65s]" />
<span className="absolute top-(--pt) left-1/2 aspect-square w-[calc(var(--pw)*2.1)] rounded-full border border-current opacity-[calc(var(--ld,0)*.7)] [transform:translate(-50%,-50%)_scale(calc(.9+var(--ld,0)*.1))] [transition:opacity_2.4s_ease_1s,transform_2.4s_var(--ease)_1s]" />
</div>
{/* Outside the filtered layer: a filter makes the browser count this label as a screen-sized paint. */}
<span
aria-hidden="true"
className="pointer-events-none absolute top-[calc(var(--pt)-var(--pw)*.647-20px)] left-[calc(50%+var(--pw)/2+10px)] font-martian text-[10.5px] text-graphite leading-none tracking-[.06em] opacity-[var(--ld,0)] transition-opacity delay-[1.4s] duration-1000"
>
{i18n.number(612)} × {i18n.number(792)}
</span>
<Doodle
name="pencil"
eager
wipe="var(--ld, 0)"
className="start-[74vw] top-[max(15vh,110px)] w-[12vw] [transform:translate(calc(var(--mx,0)*-22px),calc(var(--my,0)*-14px))_rotate(calc(-22deg+var(--mx,0)*3deg))_translateY(calc(var(--p)*-20px))] [transition:mask-position_1.8s_var(--ease)_.9s,transform_.9s_var(--ease)]"
/>
<Doodle
name="paperclip"
eager
wipe="var(--ld, 0)"
className="start-[4vw] top-[61%] w-[6vw] [transform:translate(calc(var(--mx,0)*22px),calc(var(--my,0)*16px))_rotate(calc(16deg+var(--mx,0)*-4deg))_translateY(calc(var(--p)*-30px))] [transition:mask-position_1.4s_var(--ease)_1.3s,transform_1.1s_var(--ease)] max-[900px]:hidden"
/>
<p className={cn(labelClass, "absolute inset-x-(--gutter) top-[92px] z-2 text-ink-3")}>
{t`An open-source resume builder`}
</p>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-78 [mask-image:linear-gradient(to_bottom,transparent_0,transparent_120px,#000_200px,#000_calc(100%-250px),transparent_calc(100%-170px))]"
>
{getFragments().map(({ text, at: [x, y, tx, ty, r, s, z], className }) => (
<span
key={text}
className={cn("hero-fragment", className)}
style={{ "--x": x, "--y": y, "--tx": tx, "--ty": ty, "--r": r, "--s": s, "--z": z } as CSSProperties}
>
{text}
</span>
))}
</div>
<div className="hero-page absolute top-(--pt) left-1/2 aspect-[612/792] w-(--pw) rounded-[2px] shadow-paper">
<Sheet />
</div>
<h1 id="hero-title" className="sr-only">
{t`Everything you’ve done, on one page.`}
</h1>
{/* The headline, drawn as two halves either side of the page (below 900px, stacked above it). */}
<div
aria-hidden="true"
className="hero-headline-start absolute end-[calc(50%+var(--pw)/2+2.6vw)] top-1/2 hidden whitespace-nowrap text-end text-[4.4vw] text-ink leading-none min-[900px]:block"
>
<span className="block font-anybody font-light tracking-[-.025em] [font-stretch:calc(112%-var(--e)*12%)]">
{headline[0]}
</span>
<span className="block font-anybody font-normal tracking-[-.025em] [font-stretch:calc(90%+var(--e)*10%)]">
{headline[1]}
</span>
</div>
<div
aria-hidden="true"
className="absolute start-[calc(50%+var(--pw)/2+2.6vw)] top-1/2 hidden whitespace-nowrap text-[4.4vw] leading-none opacity-[clamp(0,(var(--e)-.5)*3,1)] [transform:translate(calc(var(--dir)*(1-var(--e))*6vw),-50%)] [transition:transform_.45s_var(--ease)] min-[900px]:block"
>
<span className="block font-display text-accent-text italic tracking-[-.02em]">{headline[2]}</span>
<span className="block font-anybody text-ink tracking-[-.025em]">{headline[3]}</span>
</div>
<div
aria-hidden="true"
className="absolute inset-x-(--gutter) top-32 text-[10vw] text-ink leading-none min-[900px]:hidden"
>
<span className="block font-anybody font-light [font-stretch:calc(110%-var(--e)*10%)]">{headline[0]}</span>
<span className="block font-anybody font-stretch-[96%]">{headline[1]}</span>
<span className="block font-display text-accent-text italic opacity-[clamp(0,(var(--e)-.4)*3,1)]">
{headline[2]} {headline[3]}
</span>
</div>
<div className="absolute inset-x-(--gutter) bottom-[clamp(20px,4vh,40px)] z-2 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<p className="max-w-full text-pretty font-display text-base text-ink-2 leading-[1.45] min-[900px]:max-w-[min(25em,calc(50vw-var(--pw)/2-5vw))] min-[900px]:text-[clamp(17px,1.3vw,20px)]">
{t`Reactive Resume is a free, open-source resume builder. Write it, design it, check it and tailor it for every job.`}
</p>
<CtaLink size="hero" />
</div>
</div>
</section>
);
}
+274
View File
@@ -0,0 +1,274 @@
/*
* The landing page (features/homepage). It is one scroll-driven story: the scroll engine (scroll.ts) writes each
* section's progress as --p (0 to 1) and every motion below is derived from it with calc(), so React only re-renders
* on coarse steps. Imported by index.css, not the route, so a server-prerendered page is styled at first paint.
*/
@import "@fontsource-variable/anybody/wdth.css";
@import "@fontsource-variable/martian-mono/wdth.css";
/* Short viewports hide the secondary captions of the pinned scenes so nothing overlaps. */
@custom-variant short (@media (height < 720px));
@theme {
--font-anybody: "Anybody Variable", "Noto Sans", system-ui, sans-serif;
--font-martian: "Martian Mono Variable", ui-monospace, monospace;
--animate-breathe: landing-breathe 16s ease-in-out infinite alternate;
--animate-drift: landing-drift 90s ease-in-out infinite alternate;
--animate-mote: landing-mote var(--mote-duration) linear var(--mote-delay) infinite;
--animate-swing: landing-swing 7s ease-in-out 2s infinite;
--animate-flicker: landing-flicker 0.6s ease-out 1;
@keyframes landing-breathe {
from {
opacity: 0.7;
}
to {
opacity: 1;
}
}
@keyframes landing-drift {
from {
transform: skewX(-16deg) rotate(-8deg) translate3d(-3%, -2%, 0);
}
to {
transform: skewX(-11deg) rotate(-4deg) translate3d(6%, 4%, 0);
}
}
@keyframes landing-mote {
0% {
transform: translate(0, 0);
opacity: 0;
}
15% {
opacity: 1;
}
85% {
opacity: 0.8;
}
100% {
transform: translate(var(--dx), var(--dy));
opacity: 0;
}
}
/* The theme cord sways over the last 14% of each cycle, so it reads as a nudge rather than constant motion. */
@keyframes landing-swing {
0%,
86%,
100% {
rotate: 0deg;
}
89% {
rotate: 7deg;
}
92% {
rotate: -5deg;
}
95% {
rotate: 3deg;
}
98% {
rotate: -1deg;
}
}
/* The bulb's halo flashing as it catches, before it settles to its resting glow. */
@keyframes landing-flicker {
0%,
100% {
opacity: 1;
}
40% {
opacity: 0.55;
}
45% {
opacity: 1;
}
70% {
opacity: 0.8;
}
}
}
@theme inline {
--shadow-paper: var(--paper-shadow);
--color-graphite: var(--graphite);
}
.landing {
--gutter: clamp(18px, 3vw, 44px);
--paper-shadow:
0 1px 2px oklch(0.2 0.01 95 / 0.12), 0 40px 80px -30px oklch(0.2 0.01 95 / 0.5), 0 0 0 1px oklch(0.2 0.01 95 / 0.04);
--graphite: oklch(0.38 0.01 95 / 0.3);
/* Horizontal motion that points somewhere (slides, flights) is multiplied by this so it mirrors in RTL. */
--dir: 1;
}
.dark .landing {
--paper-shadow: 0 1px 2px oklch(0 0 0 / 0.5), 0 40px 80px -30px oklch(0 0 0 / 0.8);
--graphite: oklch(0.9 0.01 95 / 0.18);
}
[dir="rtl"] .landing {
--dir: -1;
}
.landing [data-scene] {
--p: 0;
}
/* Reduced motion renders every scene at its end state; the pinned scenes also collapse to one screen. */
@media (prefers-reduced-motion: reduce) {
.landing [data-scene] {
--p: 1;
}
}
@media (prefers-reduced-motion: no-preference) {
html:has(.landing) {
scroll-behavior: smooth;
}
}
/* A graphite illustration that sketches in with a diagonal mask wipe as --wipe goes from 0 to 1. */
@utility doodle {
position: absolute;
pointer-events: none;
user-select: none;
height: auto;
opacity: calc(0.72 * var(--doodle-fade, 1));
mask-image: linear-gradient(110deg, #000 40%, transparent 60%);
mask-size: 300% 100%;
mask-repeat: no-repeat;
mask-position: calc((1 - var(--wipe, 1)) * 100%) 0;
transition: mask-position 0.35s linear;
.dark & {
opacity: calc(0.42 * var(--doodle-fade, 1));
filter: invert(1) brightness(1.1);
}
}
/* One of the hero's career fragments: it drifts from (--x, --y) to (--tx, --ty) as the page assembles (--e). */
@utility hero-fragment {
position: absolute;
top: 50%;
left: 50%;
white-space: nowrap;
opacity: calc(1 - clamp(0, (var(--e) - 0.7) * 3.4, 1));
transform: translate(-50%, -50%)
translate(
calc(var(--x) * (1 - var(--e)) + var(--tx) * var(--e) + var(--mx, 0) * var(--z) * 28px),
calc(var(--y) * 0.78 * (1 - var(--e)) + var(--ty) * var(--e) + var(--my, 0) * var(--z) * 20px)
)
rotate(calc(var(--r) * (1 - var(--e)))) scale(calc(var(--s) + (0.3 - var(--s)) * var(--e)));
transition: transform 0.7s var(--ease);
}
/* The hero page lies back in perspective and settles flat as --e2 goes to 1. */
@utility hero-page {
opacity: var(--e2);
transform: translate(-50%, -50%) perspective(1600px) rotateX(calc((1 - var(--e2)) * 50deg))
rotateZ(calc((1 - var(--e2)) * -6deg)) scale(calc(0.6 + var(--e2) * 0.4));
transition: transform 0.5s var(--ease);
}
/* "Everything you've done," starts centred and enlarged, then slides to sit beside the page. */
@utility hero-headline-start {
transform-origin: 100% 50%;
transform: translate(
calc(var(--dir) * (1 - var(--e)) * ((1 + (1 - var(--e)) * 0.45) * 50% + var(--pw) / 2 + 2.6vw)),
-50%
)
scale(calc(1 + (1 - var(--e)) * 0.45));
transition: transform 0.45s var(--ease);
[dir="rtl"] & {
transform-origin: 0% 50%;
}
}
/* A character of typed text: it appears once --typed passes its position --f in the string. */
@utility typed {
opacity: clamp(0, calc((var(--typed) - var(--f)) * var(--typed-rate, 90)), 1);
}
/* A character of Write's suggested line: highlighted and underlined until the suggestion is accepted (--cl). */
@utility typed-suggestion {
background: color-mix(in oklch, oklch(0.93 0.05 150) calc((1 - var(--cl)) * 100%), transparent);
text-decoration: underline 2px color-mix(in oklch, oklch(0.5 0.1 150) calc((1 - var(--cl)) * 100%), transparent);
text-underline-offset: 0.22em;
}
/* A keyword on the demo resume that lights up when the Tailor scene matches it (--k from 0 to 1). */
@utility sheet-keyword {
margin-inline: -0.3cqw;
border-radius: 0.4cqw;
padding-inline: 0.3cqw;
background: color-mix(in oklch, oklch(0.88 0.1 150) calc(var(--k, 0) * 100%), transparent);
}
/* A keyword in the job posting, highlighted in step with its match on the resume. */
@utility posting-keyword {
border-radius: 3px;
padding-inline: 2px;
color: inherit;
background: color-mix(in oklch, var(--accent-soft) calc(var(--k, 0) * 100%), transparent);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) calc(var(--k, 0) * 60%), transparent);
}
/* The Check lens: the parsed text layer, revealed through a circle at (--lx, --ly). */
@utility check-lens {
background-color: oklch(0.2 0.03 160);
background-image:
repeating-linear-gradient(0deg, oklch(0.6 0.1 150 / 0.1) 0 1px, transparent 1px 4cqw),
repeating-linear-gradient(90deg, oklch(0.6 0.1 150 / 0.1) 0 1px, transparent 1px 4cqw);
clip-path: circle(16cqw at var(--lx, 50%) var(--ly, 16%));
}
/* The header's tapered backdrop: solid under the bar, fading out below it. */
@utility header-taper {
background: linear-gradient(
to bottom,
var(--bg) 0%,
color-mix(in oklch, var(--bg) 82%, transparent) 45%,
transparent 100%
);
backdrop-filter: blur(10px);
mask-image: linear-gradient(to bottom, #000 40%, transparent);
}
/* The blurred shadow of a window's mullions falling across the desk. */
@utility window-shadow {
background-image:
linear-gradient(
90deg,
transparent 0 30%,
oklch(0.35 0.02 80 / 0.075) 30% 33%,
transparent 33% 63%,
oklch(0.35 0.02 80 / 0.075) 63% 66%,
transparent 66%
),
linear-gradient(0deg, transparent 0 46%, oklch(0.35 0.02 80 / 0.065) 46% 49%, transparent 49%);
filter: blur(28px);
transform-origin: 0 0;
transform: skewX(-16deg) rotate(-8deg);
}
/* The receipt's torn, zig-zag bottom edge. */
@utility receipt-paper {
mask: conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) 50% / 16px 100%;
}
@utility receipt-barcode {
background: repeating-linear-gradient(
90deg,
oklch(0.22 0.01 95) 0 2px,
transparent 2px 4px,
oklch(0.22 0.01 95) 4px 5px,
transparent 5px 8px,
oklch(0.22 0.01 95) 8px 11px,
transparent 11px 12px,
oklch(0.22 0.01 95) 12px 13px,
transparent 13px 17px
);
}
@@ -1,20 +0,0 @@
@keyframes home-language-appear {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes home-language-unfold {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -1,196 +0,0 @@
import type { Locale } from "@reactive-resume/utils/locale";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { ArrowUpRightIcon, CheckIcon, DownloadSimpleIcon, PlusIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { isLocale, isRTL } from "@reactive-resume/utils/locale";
import { cn } from "@reactive-resume/utils/style";
import { changeLocale, localeMap, resolveLocale } from "@/libs/locale";
import { section, sectionHeading, sectionText, sectionTitle, textLink, wrap } from "./classes";
import "./languages-showcase.css";
const featuredLocales = [
{ locale: "en-US", name: "English" },
{ locale: "de-DE", name: "Deutsch" },
{ locale: "fr-FR", name: "Français" },
{ locale: "es-ES", name: "Español" },
{ locale: "pt-BR", name: "Português" },
{ locale: "ar-SA", name: "العربية" },
{ locale: "ja-JP", name: "日本語" },
{ locale: "hi-IN", name: "हिन्दी" },
] satisfies { locale: Locale; name: string }[];
// A locale name can exist before its catalog ships. Only offer files the app can load.
const availableLocales = Object.keys(import.meta.glob("../../../locales/*.po"))
.map((path) => path.split("/").at(-1)?.replace(/\.po$/, "") ?? "")
.filter(isLocale);
const wordClass = "animate-[home-language-appear_190ms_ease-out_both] group-data-[instant=true]/languages:animate-none";
const headingClass = `${wordClass} mb-[15px] font-semibold text-[13px] leading-[1.6] wrap-anywhere`;
const paperClass =
"language-paper relative mx-auto w-full max-w-[440px] rotate-2 rounded border border-[#e0dcd5] bg-[#efede7] font-[Arial,Noto_Sans,sans-serif] text-[#29272b] text-start shadow-[4px_5px_0_#d5d0c8,5px_6px_0_#77716b,11px_16px_0_-4px_#b9b1a7,10px_28px_42px_#0005]";
const optionClass =
"group/option flex min-h-[62px] items-center justify-between gap-2 rounded border border-[#454345] bg-[#222224] px-[17px] py-[14px] text-[#e3dfda] text-[clamp(16px,1.6vw,21px)] shadow-[0_3px_0_#101011] transition-[background-color,color,translate,box-shadow] duration-[160ms] ease-[ease] hover:not-aria-pressed:-translate-y-[2px] hover:not-aria-pressed:bg-[#343031] hover:not-aria-pressed:shadow-[0_5px_0_#101011] focus-visible:outline-2 focus-visible:outline-offset-[5px] focus-visible:outline-(--home-accent) active:translate-y-[2px] active:shadow-[0_1px_0_#101011] aria-pressed:translate-y-[2px] aria-pressed:border-[#d0b9a5] aria-pressed:bg-[#d0b9a5] aria-pressed:text-[#24201d] aria-pressed:shadow-[0_1px_0_#101011] group-data-[instant=true]/languages:transition-none max-[420px]:min-h-[56px] max-[420px]:px-[11px] max-[420px]:py-[10px] max-[420px]:text-[16px]";
type LinesProps = { count: 2 | 3; className?: string };
function Lines({ count, className }: LinesProps) {
return (
<div className={cn("grid gap-[7px]", className)} aria-hidden="true">
<i className="block h-1 w-[51%] bg-[#9d9183]" />
{count === 3 && <i className="block h-1 bg-[#d2ccc3]" />}
<i className="block h-1 w-[79%] bg-[#d2ccc3]" />
</div>
);
}
export default function LanguagesShowcase() {
const { i18n } = useLingui();
const selectedLocale = resolveLocale(i18n.locale);
const [instant, setInstant] = useState(false);
return (
<section
className={cn(section, wrap, "group/languages")}
id="languages"
aria-labelledby="languages-title"
data-instant={instant}
onClickCapture={(event) => setInstant(event.detail === 0)}
>
<div className={sectionHeading}>
<h2 id="languages-title" className={sectionTitle}>
<Trans>
A little more
<br />
at home.
</Trans>
</h2>
<p className={sectionText}>
<Trans>
Use the app in a language you’re comfortable with. Try a few of the translations made by the community.
</Trans>
</p>
</div>
<div className="grid grid-cols-[0.9fr_1.1fr] items-center gap-[clamp(32px,6vw,88px)] rounded border border-(--home-line) bg-[#19191b] p-[clamp(24px,5vw,64px)] shadow-[inset_0_1px_0_#ffffff05] max-[760px]:grid-cols-1 max-[760px]:gap-3 max-[420px]:px-4 max-[420px]:pt-[22px] max-[420px]:pb-[25px]">
<div className="min-w-0 max-[760px]:mx-auto max-[760px]:w-full max-[760px]:max-w-[460px]">
<p className="mb-[26px] text-(--home-ink) text-[17px]">
<Trans>Pick a language. See it on the page.</Trans>
</p>
<fieldset
className="grid min-w-0 grid-cols-2 gap-[10px] max-[420px]:gap-2"
aria-label={t`Choose app language`}
>
{featuredLocales
.filter(({ locale }) => availableLocales.includes(locale))
.map(({ locale, name }) => (
<button
key={locale}
type="button"
className={optionClass}
aria-label={name}
aria-pressed={selectedLocale === locale}
onClick={() => changeLocale(locale)}
>
<bdi lang={locale}>{name}</bdi>
<CheckIcon
className="shrink-0 opacity-0 group-aria-pressed/option:opacity-100"
size={16}
aria-hidden="true"
/>
</button>
))}
</fieldset>
<p className="mt-6 max-w-[31ch] text-(--home-muted) text-[13px] leading-[1.6] max-[760px]:max-w-none">
<Trans>Your choice applies across the app.</Trans>
</p>
</div>
<div className="relative min-w-0 px-2 pt-3 max-[760px]:mx-auto max-[760px]:w-full max-[760px]:max-w-[430px] max-[760px]:px-[6px] max-[760px]:pt-2">
<div className={paperClass} lang={selectedLocale} dir={isRTL(selectedLocale) ? "rtl" : "ltr"}>
<div className="flex min-h-[56px] items-center gap-[9px] border-b border-b-[#d8d3cb] px-[17px] py-[10px] text-[12px] max-[420px]:gap-[6px] max-[420px]:p-[10px] max-[420px]:text-[10px]">
<img className="shrink-0" src="/icon/light.svg" alt="Reactive Resume" width="25" height="25" />
<span className={wordClass}>
<Trans>Resumes</Trans>
</span>
<div className="ms-auto flex items-center gap-[5px] text-[#62594f] text-[10px] max-[420px]:gap-[3px] max-[420px]:text-[9px]">
<DownloadSimpleIcon className="shrink-0 max-[420px]:hidden" size={14} aria-hidden="true" />
<span className={wordClass}>
<Trans>Download</Trans>
</span>
</div>
</div>
<div className="p-[clamp(23px,3vw,40px)] max-[420px]:px-5 max-[420px]:py-[23px]">
<p className="font-[Georgia,Times_New_Roman,serif] text-[clamp(26px,3vw,36px)] tracking-[-0.06em]">
<bdi>Alex Morgan</bdi>
</p>
<div className="mt-[13px] mb-7 flex gap-[11px]" aria-hidden="true">
<i className="block h-[3px] w-[23%] bg-[#c5bfb6]" />
<i className="block h-[3px] w-[36%] bg-[#c5bfb6]" />
</div>
<div>
<h3
className={`${wordClass} wrap-anywhere mb-[15px] font-normal text-[clamp(22px,3vw,34px)] leading-[1.6] tracking-[-0.035em]`}
>
<Trans>Experience</Trans>
</h3>
<Lines count={3} className="mb-[23px]" />
<Lines count={3} className="mb-[23px]" />
</div>
<div className="grid grid-cols-[1.2fr_1fr] gap-[27px] border-t border-t-[#d6d0c6] pt-[15px] max-[420px]:gap-[18px]">
<div>
<h3 className={headingClass}>
<Trans>Education</Trans>
</h3>
<Lines count={2} />
</div>
<div>
<h3 className={headingClass}>
<Trans>Skills</Trans>
</h3>
<Lines count={2} />
</div>
</div>
</div>
</div>
<p className="mt-[31px] text-center text-(--home-muted) text-[11px] leading-[1.5]">
{i18n._(localeMap[selectedLocale])}
<span aria-hidden="true"> · </span>
<Trans>App language</Trans>
</p>
</div>
</div>
<div className="grid grid-cols-[1fr_auto] items-start gap-10 pt-[25px] max-[760px]:grid-cols-1 max-[760px]:gap-[22px]">
<details className="group/all min-w-0">
<summary className="flex min-h-[44px] w-fit cursor-pointer list-none items-center justify-between gap-5 py-[5px] text-(--home-ink) text-[14px] focus-visible:outline-(--home-accent) focus-visible:outline-2 focus-visible:outline-offset-[5px] [&::-webkit-details-marker]:hidden">
<Trans>Show all languages</Trans>
<PlusIcon
className="transition-transform duration-[180ms] ease-[ease-out] group-open/all:rotate-45 group-data-[instant=true]/languages:transition-none"
size={18}
aria-hidden="true"
/>
</summary>
<fieldset
className="mt-[23px] grid min-w-0 grid-cols-3 gap-x-3 gap-y-1 group-data-[instant=false]/languages:group-open/all:animate-[home-language-unfold_220ms_ease-out_both] max-[420px]:grid-cols-2"
aria-label={t`All app languages`}
>
{availableLocales.map((locale) => (
<button
key={locale}
type="button"
className="min-h-[44px] rounded-[3px] border border-transparent bg-transparent px-[10px] py-[9px] text-start text-(--home-muted) text-[13px] leading-[1.5] hover:bg-[#242225] hover:text-(--home-ink) focus-visible:outline-(--home-accent) focus-visible:outline-2 focus-visible:outline-offset-[5px] aria-pressed:border-[#756253] aria-pressed:bg-[#211e1c] aria-pressed:text-[#d0b9a5] aria-pressed:hover:bg-[#242225] aria-pressed:hover:text-(--home-ink)"
aria-pressed={selectedLocale === locale}
onClick={() => changeLocale(locale)}
>
{i18n._(localeMap[locale])}
</button>
))}
</fieldset>
</details>
<a href="https://crowdin.com/project/reactive-resume" className={cn(textLink, "mt-[5px]")}>
<Trans>Help translate the app</Trans>
<ArrowUpRightIcon size={17} aria-hidden="true" />
</a>
</div>
</section>
);
}
@@ -1,48 +0,0 @@
import { m, useReducedMotion, useScroll, useTransform } from "motion/react";
export function PageBackground() {
const reducedMotion = useReducedMotion();
return reducedMotion ? null : <ParallaxPlanes />;
}
const planeClass =
"absolute aspect-[210/297] origin-[50%_75%] rounded-[3px] border before:absolute before:inset-[7px_-8px_-8px_7px] before:rounded-[inherit] before:border before:border-current before:content-[''] after:absolute after:top-[14%] after:left-[13%] after:h-[15%] after:w-[30%] after:border-y after:border-current after:content-['']";
function ParallaxPlanes() {
const { scrollYProgress } = useScroll();
const far = useTransform(
scrollYProgress,
[0, 1],
["translate3d(0, 4vh, 0) rotate(-19deg)", "translate3d(0, -9vh, 0) rotate(-13deg)"],
);
const middle = useTransform(
scrollYProgress,
[0, 1],
["translate3d(0, 15vh, 0) rotate(24deg)", "translate3d(0, -25vh, 0) rotate(15deg)"],
);
const near = useTransform(
scrollYProgress,
[0, 1],
["translate3d(0, 27vh, 0) rotate(-27deg)", "translate3d(0, -48vh, 0) rotate(-15deg)"],
);
return (
<div
className="pointer-events-none fixed inset-0 z-0 select-none overflow-clip contain-paint motion-reduce:hidden"
aria-hidden="true"
>
<m.div
className={`${planeClass} top-[3%] left-[31%] w-[clamp(150px,18vw,270px)] border-[rgb(241_240_235/4%)] bg-transparent text-[rgb(241_240_235/4%)] max-[600px]:left-[47%]`}
style={{ transform: far }}
/>
<m.div
className={`${planeClass} top-[22%] -right-[7%] w-[clamp(250px,29vw,440px)] border-[rgb(241_240_235/7%)] border-r-[rgb(131_154_137/19%)] bg-[rgb(111_139_117/1.3%)] text-[rgb(131_154_137/10%)] max-[600px]:-right-[43%]`}
style={{ transform: middle }}
/>
<m.div
className={`${planeClass} top-[49%] -left-[10%] w-[clamp(310px,34vw,540px)] border-[rgb(241_240_235/7%)] border-l-[rgb(166_143_184/20%)] bg-[rgb(152_125_174/1.4%)] text-[rgb(166_143_184/9%)] max-[600px]:-left-[58%]`}
style={{ transform: near }}
/>
</div>
);
}
+40 -319
View File
@@ -1,330 +1,51 @@
import type { Template } from "@reactive-resume/schema/templates";
import type { ReactNode } from "react";
import type { SculptureTemplate } from "./resume-sculpture";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowDownIcon, ArrowRightIcon, ArrowUpRightIcon, GithubLogoIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { useInView } from "motion/react";
import { lazy, Suspense, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { GithubStarsButton } from "@/components/input/github-stars-button";
import { section, sectionHeading, sectionText, sectionTitle, textLink, wrap } from "./classes";
import { CommunityStats } from "./community-stats";
import { FeatureExplorer } from "./feature-explorer";
import LanguagesShowcase from "./languages-showcase";
import { PageBackground } from "./page-background";
import { ResumeSculpture } from "./resume-sculpture";
import { SiteFooter } from "./site-footer";
import { TemplateShelf } from "./template-shelf";
import "./styles.css";
const ExportPlayground = lazy(() => import("./export-playground"));
const AtsPlayground = lazy(() => import("./ats-playground"));
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
const buttonClass =
"inline-flex min-h-[52px] items-center justify-center gap-3 rounded-[4px] border border-[#f1f0eb] bg-[#f1f0eb] px-[19px] py-[14px] text-[14px] font-[550] text-[#151516] [transition:background-color_150ms_ease,transform_150ms_var(--ease-out-strong)] hover:bg-[#d9d8d2] active:transform-[scale(0.97)] max-[540px]:min-h-[49px] max-[540px]:gap-[18px] max-[540px]:px-[15px] max-[540px]:py-3 max-[540px]:text-[13px]";
const brandClass = "inline-flex shrink-0 items-center gap-[11px] font-[550] tracking-[-0.04em] max-[540px]:gap-2";
const paperLinkClass =
"group/link flex min-h-[65px] items-center gap-[14px] border-t border-[#2d2e3038] text-[14px] [transition:background-color_160ms_ease] focus-visible:outline-[#37383a]";
const paperArrowClass =
"ml-auto [transition:transform_180ms_var(--ease-out-strong)] group-hover/link:transform-[translate(2px,-2px)]";
const contributeLinkClass =
"inline-flex min-h-11 items-center gap-2 text-(--home-ink) underline-offset-4 hover:underline";
type DeferredDemoProps = { children: ReactNode };
function DeferredDemo({ children }: DeferredDemoProps) {
const ref = useRef<HTMLDivElement>(null);
const visible = useInView(ref, { once: true, margin: "300px" });
return (
<div ref={ref} className="min-h-[460px]">
{visible && (
<Suspense
fallback={
<p className="py-[140px] text-center text-(--home-muted)">
<Trans>Loading the playground…</Trans>
</p>
}
>
{children}
</Suspense>
)}
</div>
);
}
import { useRef } from "react";
import { LandingBackground } from "./background";
import { Check } from "./check";
import { Design } from "./design";
import { LandingFooter } from "./footer";
import { LandingHeader, ThemeCord } from "./header";
import { Hero } from "./hero";
import { useScrollScenes } from "./scroll";
import { Closing, Languages, Numbers, Support } from "./sections";
import { Share } from "./share";
import { Tailor } from "./tailor";
import { Write } from "./write";
/**
* The public landing page: one scroll-driven story, "Everything you've done, on one page." A single resume page is
* assembled, written, restyled, checked, tailored and sent as the visitor scrolls, followed by community proof,
* languages, support, a closing call to action and the footer. The server prerenders it (prerender.tsx), so every
* word is in the first response.
*/
export function Homepage() {
const [name, setName] = useState("Alex Morgan");
const [accent, setAccent] = useState("#735c9a");
const [typeface, setTypeface] = useState<"sans" | "serif">("sans");
const [template, setTemplate] = useState<Template>("ditgar");
const [heroTemplate, setHeroTemplate] = useState<SculptureTemplate>("ditgar");
const root = useRef<HTMLDivElement>(null);
useScrollScenes(root);
return (
<div className="homepage scheme-dark relative isolate overflow-clip bg-(--home-bg) font-[IBM_Plex_Sans_Variable,IBM_Plex_Sans,sans-serif] text-(--home-ink) text-[16px] leading-[1.5] [--home-accent:#c4a68c] [--home-bg:#101011] [--home-ink:#f1f0eb] [--home-line:#323235] [--home-muted:#a5a5ab] [--home-panel:#19191b] selection:bg-[#d5c2df] selection:text-[#101011]">
<PageBackground />
<div ref={root} className="landing relative isolate overflow-x-clip bg-bg font-display text-ink">
<LandingBackground />
<a
href="#main-content"
className="fixed top-3 left-5 z-100 -translate-y-[150%] rounded-[4px] bg-(--home-ink) px-[18px] py-3 text-(--home-bg) focus:translate-y-0"
href="#main"
className="fixed start-4 top-2.5 z-100 -translate-y-[160%] rounded-md bg-ink px-3.5 py-2.5 font-semibold font-ui text-bg text-sm transition-transform focus:translate-y-0"
>
<Trans>Skip to main content</Trans>
{t`Skip to content`}
</a>
<header
className={cn(
wrap,
"relative z-1 flex h-[104px] items-center justify-between gap-3 max-[540px]:h-20 max-[900px]:h-[88px]",
)}
>
<Link
to="/"
className={`${brandClass} text-[17px] max-[360px]:gap-[6px] max-[360px]:text-[13px] max-[540px]:text-[15px]`}
aria-label="Reactive Resume"
>
<img src="/icon/dark.svg" alt="" width="34" height="34" className="block max-[540px]:size-[29px]" />
</Link>
<nav
className="ml-auto flex gap-[30px] text-(--home-muted) text-[14px] max-[1100px]:gap-5"
aria-label={t`Main navigation`}
>
<a
href="https://docs.rxresu.me"
className="inline-flex min-h-[38px] items-center gap-[5px] [transition:color_150ms_ease] hover:text-(--home-ink) max-[900px]:hidden"
>
<Trans>Docs</Trans>
</a>
<div className="[&>a]:hover:[&_svg:last-child]:transform-[rotate(20deg)_scale(1.15)] [&>a>span]:min-w-[6ch] [&>a>span]:border-s [&>a>span]:border-s-[#3d444d] [&>a>span]:ps-2 [&>a>span]:text-center [&>a>span]:font-semibold max-[540px]:[&>a>span]:min-w-0 max-[540px]:[&>a>span]:ps-[6px] [&>a]:inline-flex [&>a]:min-h-[38px] [&>a]:min-w-[128px] [&>a]:items-center [&>a]:gap-2 [&>a]:rounded-[4px] [&>a]:border [&>a]:border-[#3d444d] [&>a]:bg-[#161b22] [&>a]:px-[11px] [&>a]:py-0 [&>a]:text-[#f0f6fc] [&>a]:text-[13px] [&>a]:tabular-nums [&>a]:shadow-[inset_0_1px_0_#ffffff0d,0_3px_0_#09090966] [&>a]:[transition:background-color_180ms_ease,border-color_180ms_ease,box-shadow_180ms_ease] [&>a]:hover:border-[#6e7681] [&>a]:hover:bg-[#21262d] [&>a]:hover:text-(--home-ink) [&>a]:hover:shadow-[inset_0_1px_0_#ffffff14,0_3px_0_#09090966,0_0_18px_#ffffff08] [&>a]:focus-visible:border-[#3d444d] [&>a]:focus-visible:ring-0 max-[540px]:[&>a]:min-w-24 max-[540px]:[&>a]:gap-[5px] max-[540px]:[&>a]:px-[6px] max-[540px]:[&>a]:text-[11px] [&_svg:last-child]:text-[#f0f6fc] [&_svg:last-child]:[transition:transform_220ms_var(--ease-out-strong)] max-[540px]:[&_svg:not([class*='size-'])]:size-[14px]">
<GithubStarsButton />
</div>
</nav>
</header>
<main id="main-content" className="relative z-1">
<section
className={cn(
wrap,
"grid min-h-[710px] grid-cols-[1fr_1.12fr] items-center gap-[6px] pt-[34px] pb-[62px] max-[1100px]:min-h-[655px] max-[1100px]:grid-cols-[1fr_1.1fr] max-[900px]:grid-cols-1 max-[540px]:gap-[21px] max-[900px]:gap-[30px] max-[540px]:pt-[30px] max-[900px]:pt-10 max-[540px]:pb-8 min-[1500px]:min-h-[750px]",
)}
aria-labelledby="hero-title"
>
<div className="relative z-1 pb-[22px] max-[900px]:max-w-[590px]">
<h1
id="hero-title"
className="font-[Manrope_Variable,sans-serif] font-semibold text-[clamp(52px,5.85vw,82px)] leading-[1.035] tracking-[-0.064em] max-[1100px]:text-[64px] max-[540px]:text-[clamp(48px,11.65vw,63px)] max-[900px]:text-[clamp(55px,10vw,77px)]"
>
<Trans>
Make yourself <br className="max-[540px]:block max-[900px]:hidden" />
look good
<br />
<span className="text-[#b7b6b8]">on paper.</span>
</Trans>
</h1>
<p className="mt-7 max-w-[356px] text-(--home-muted) text-[17px] leading-[1.65] max-[540px]:mt-6 max-[540px]:max-w-[330px] max-[900px]:max-w-[410px] max-[540px]:text-[15px]">
<Trans>
A free, open-source resume builder. Put your experience on the page, without fighting the formatting.
</Trans>
</p>
<div className="mt-[30px] flex flex-wrap items-center gap-9 max-[540px]:mt-[25px] max-[1100px]:gap-7 max-[540px]:gap-7 max-[900px]:gap-9">
<Link to="/dashboard" className={buttonClass}>
<Trans>Get Started</Trans>
<ArrowRightIcon size={20} aria-hidden="true" />
</Link>
<a
href="#templates"
className={cn(textLink, "max-[540px]:gap-[6px] max-[1100px]:text-[13px] max-[540px]:text-[12px]")}
>
<Trans>Explore templates</Trans>
<ArrowDownIcon size={16} aria-hidden="true" />
</a>
</div>
<p className="mt-[17px] text-[#a5a5ab] text-[12px] leading-[1.9] max-[540px]:mt-[15px] max-[540px]:text-[11px]">
<Trans>
<strong className="font-medium text-[#d4d3cf]">Free forever and open source.</strong>
<br />
No ads, paywalls, or tracking.
</Trans>
</p>
</div>
<ResumeSculpture
template={heroTemplate}
onTemplateChange={(next) => {
setHeroTemplate(next);
setTemplate(next);
}}
name={name}
onNameChange={setName}
accent={accent}
onAccentChange={setAccent}
typeface={typeface}
onTypefaceChange={setTypeface}
/>
</section>
<CommunityStats />
<section className={cn(section, "bg-[rgb(23_23_24/55%)]")} id="templates" aria-labelledby="templates-title">
<div className={wrap}>
<div className={sectionHeading}>
<h2 id="templates-title" className={sectionTitle}>
<Trans>
Same story.
<br />A different look.
</Trans>
</h2>
<p className={sectionText}>
<Trans>
Fifteen templates, all free. Pick one you like. You can change the type, color, and layout in the
editor.
</Trans>
</p>
</div>
<TemplateShelf template={template} onChange={setTemplate} />
</div>
</section>
<section className={cn(section, wrap)} id="playground" aria-labelledby="export-title">
<div className={sectionHeading}>
<h2 id="export-title" className={sectionTitle}>
<Trans>
Take it
<br />
with you.
</Trans>
</h2>
<p className={sectionText}>
<Trans>
A PDF for the application. A Word file for a few more edits. Download a sample using your choices.
</Trans>
</p>
</div>
<DeferredDemo>
<ExportPlayground name={name} accent={accent} typeface={typeface} template={template} />
</DeferredDemo>
</section>
<section className={cn(section, "bg-[rgb(23_23_24/55%)]")} aria-labelledby="ats-title">
<div className={wrap}>
<div className={sectionHeading}>
<h2 id="ats-title" className={sectionTitle}>
<Trans>
See what
<br />
software reads.
</Trans>
</h2>
<p className={sectionText}>
<Trans>
See whether your PDF has readable text. Read a sample or try your own PDF. Everything runs in your
browser, so your file stays with you.
</Trans>
</p>
</div>
<DeferredDemo>
<AtsPlayground />
</DeferredDemo>
</div>
</section>
<section className={cn(section, wrap)} id="features" aria-labelledby="features-title">
<div className={sectionHeading}>
<h2 id="features-title" className={sectionTitle}>
<Trans>
The rest of your
<br />
job search, too.
</Trans>
</h2>
<p className={sectionText}>
<Trans>
Get a second opinion on your writing, share your resume, and keep track of where you’ve applied. It’s
all in one place.
</Trans>
</p>
</div>
<FeatureExplorer />
</section>
<LanguagesShowcase />
<section
className={cn(
wrap,
"grid grid-cols-[1.1fr_1fr] items-center gap-x-[90px] gap-y-[50px] border-(--home-line) border-t pt-[100px] pb-[65px] max-[600px]:grid-cols-1 max-[600px]:gap-[42px] max-[900px]:gap-[45px] max-[900px]:pt-[70px] max-[900px]:pb-[45px]",
)}
id="support"
aria-labelledby="open-title"
>
<div>
<h2 id="open-title" className={sectionTitle}>
<Trans>
A project you can
<br />
be part of.
</Trans>
</h2>
<p className="mt-[26px] max-w-[460px] text-(--home-muted) text-[16px] leading-[1.75] max-[900px]:text-[14px]">
<Trans>
Reactive Resume is free and open source. Amruth and a community of contributors keep it running. If
you’d like to support the work, donations help cover hosting and development.
</Trans>
</p>
<a href={githubUrl} className={cn(textLink, "mt-5")}>
<GithubLogoIcon size={21} aria-hidden="true" />
<Trans>Find us on GitHub</Trans>
<ArrowUpRightIcon size={16} aria-hidden="true" />
</a>
</div>
<div className="transform-[perspective(1000px)_rotateY(-7deg)_rotateZ(2deg)] relative rounded-[2px_4px_2px_2px] bg-[#d9dad5] px-[35px] pt-[29px] pb-6 text-[#2d2e30] shadow-[1px_1px_0_#fafaf3_inset,1px_2px_0_#a9aaa6,2px_4px_0_#6b6c6a,8px_19px_30px_#0005] after:absolute after:top-0 after:right-0 after:size-[26px] after:rounded-[0_0_0_4px] after:bg-[#f4f5ec] after:shadow-[-1px_2px_2px_#0002] after:content-[''] max-[600px]:mx-auto max-[600px]:w-[calc(100%_-_10px)] max-[900px]:p-[25px]">
<div className="flex items-center gap-[11px] pr-5 text-[13px] tracking-[-0.02em]">
<img src="/icon/light.svg" alt="" width="36" height="36" />
<span>Reactive Resume</span>
</div>
<h3 className="mt-[30px] mb-6 font-[Manrope_Variable,sans-serif] font-semibold text-[28px] leading-[1.2] tracking-[-0.045em] max-[900px]:text-[24px]">
<Trans>Support the project</Trans>
</h3>
<a href="https://github.com/sponsors/AmruthPillai" className={paperLinkClass}>
<GithubLogoIcon size={22} aria-hidden="true" />
<span>GitHub Sponsors</span>
<ArrowUpRightIcon size={20} aria-hidden="true" className={paperArrowClass} />
</a>
<a href="https://opencollective.com/reactive-resume/donate" className={paperLinkClass}>
<span
className="block size-[21px] rounded-full border-4 border-current border-r-[#9e9f99]"
aria-hidden="true"
/>
<span>Open Collective</span>
<ArrowUpRightIcon size={20} aria-hidden="true" className={paperArrowClass} />
</a>
<p className="mt-[22px] border-[#2d2e3045] border-t border-dashed pt-5 text-[#62635f] text-[11px]">
<Trans>Thank you for helping keep it free.</Trans>
</p>
</div>
<div className="col-span-full flex items-center justify-between gap-5 pt-3 text-(--home-muted) text-[13px] max-[900px]:flex-col max-[900px]:items-start max-[900px]:gap-3 max-[600px]:text-[12px]">
<p>
<Trans>There are other ways to pitch in.</Trans>
</p>
<div className="flex flex-wrap gap-x-7 max-[600px]:gap-x-5">
<a href="https://crowdin.com/project/reactive-resume" className={contributeLinkClass}>
<Trans>Translate the app</Trans>
<ArrowUpRightIcon size={15} aria-hidden="true" />
</a>
<a href={`${githubUrl}/issues`} className={contributeLinkClass}>
<Trans>Report a bug</Trans>
<ArrowUpRightIcon size={15} aria-hidden="true" />
</a>
<a href="https://docs.rxresu.me/contributing/development" className={contributeLinkClass}>
<Trans>Contribute code</Trans>
<ArrowUpRightIcon size={15} aria-hidden="true" />
</a>
</div>
</div>
</section>
<div
className={cn(
wrap,
"flex items-center justify-between gap-[30px] border-(--home-line) border-y py-[47px] max-[540px]:flex-col max-[540px]:items-start max-[540px]:gap-6 max-[540px]:py-[35px]",
)}
>
<p className="font-[Manrope_Variable,sans-serif] font-semibold text-[clamp(23px,2.4vw,33px)] leading-[1.3] tracking-[-0.045em] max-[540px]:text-[25px]">
<Trans>Ready to put your name on it?</Trans>
</p>
<Link to="/dashboard" className={buttonClass}>
<Trans>Create a resume</Trans>
<ArrowRightIcon size={20} aria-hidden="true" />
</Link>
</div>
<LandingHeader />
<ThemeCord />
<main id="main">
<Hero />
<Write />
<Design />
<Check />
<Tailor />
<Share />
<Numbers />
<Languages />
<Support />
<Closing />
</main>
<SiteFooter />
<LandingFooter />
</div>
);
}
@@ -0,0 +1,47 @@
/**
* Renders the landing page to HTML, one file per locale, at build time (see `prerenderHomepage` in vite.config.ts).
* The server sends the file for the visitor's locale, so every heading and paragraph is in the first response and the
* hero paints before any JavaScript runs; React renders the live page in its place once the route has loaded.
*/
import type { Locale } from "@reactive-resume/utils/locale";
import { DirectionProvider } from "@base-ui/react/direction-provider";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createMemoryHistory, createRootRoute, createRouter, RouterContextProvider } from "@tanstack/react-router";
import { renderToString } from "react-dom/server";
import { isRTL, localeSchema } from "@reactive-resume/utils/locale";
import { ThemeProvider } from "@/features/theme/provider";
import { getLocaleMessages } from "@/libs/locale";
import { getHomepageMeta } from "@/libs/seo";
import { Homepage } from "./page";
export const locales = localeSchema.options;
export async function renderHomepage(locale: Locale) {
const { messages } = await getLocaleMessages(locale);
i18n.loadAndActivate({ locale, messages });
// Links only need a router to build their hrefs; nothing is loaded or navigated.
const router = createRouter({
routeTree: createRootRoute(),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
const dir = isRTL(locale) ? "rtl" : "ltr";
const html = renderToString(
<RouterContextProvider router={router}>
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<ThemeProvider theme="system">
<DirectionProvider direction={dir}>
<Homepage />
</DirectionProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</RouterContextProvider>,
);
return { html, dir, ...getHomepageMeta() };
}
@@ -1,8 +0,0 @@
@keyframes home-sculpture-assemble {
from {
translate: 0 25px 40px;
}
to {
translate: 0 0 0;
}
}
@@ -1,545 +0,0 @@
import type { CSSProperties, PointerEvent } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { m, useReducedMotion, useSpring } from "motion/react";
import { useId, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import "./resume-sculpture.css";
export type SculptureTemplate = "azurill" | "bronzor" | "ditgar";
const templates: SculptureTemplate[] = ["ditgar", "azurill", "bronzor"];
const focusRing =
"focus-visible:outline-2 focus-visible:outline-[#e0c8b2] focus-visible:outline-offset-4 focus-visible:transition-none";
const dockLabel = "mb-[7px] block text-[11px] leading-[1.4] text-[#a5a5ab]";
const dockButton =
"relative grid min-h-[44px] min-w-[44px] cursor-pointer place-items-center bg-transparent text-[12px] text-[#a5a5ab] [transition:transform_140ms_var(--ease-out-strong),color_150ms_ease]";
const templateIcon: Record<SculptureTemplate, string> = {
ditgar: "bg-[linear-gradient(to_right,#aa8ebe_34%,transparent_34%)]",
azurill:
"bg-[linear-gradient(to_bottom,#aa8ebe_20%,transparent_20%),linear-gradient(to_right,transparent_28%,#b6aea7_28%,#b6aea7_32%,transparent_32%)]",
bronzor: "bg-[repeating-linear-gradient(to_bottom,transparent_0_4px,#b6aea7_4px_5px)]",
};
type ResumeSculptureProps = {
template: SculptureTemplate;
onTemplateChange: (template: SculptureTemplate) => void;
name: string;
onNameChange: (name: string) => void;
accent: string;
onAccentChange: (accent: string) => void;
typeface: "sans" | "serif";
onTypefaceChange: (typeface: "sans" | "serif") => void;
};
export function ResumeSculpture({
template,
onTemplateChange,
name,
onNameChange,
accent,
onAccentChange,
typeface,
onTypefaceChange,
}: ResumeSculptureProps) {
const colors = [
{ name: t`Plum`, value: "#735c9a" },
{ name: t`Moss`, value: "#507665" },
{ name: t`Clay`, value: "#a26046" },
];
const displayName = name || t`Your name`;
const nameId = useId();
const [spread, setSpread] = useState(false);
const [keyboardAction, setKeyboardAction] = useState(false);
const [dragging, setDragging] = useState(false);
const dragOrigin = useRef<{ id: number; x: number; y: number; rotateX: number; rotateY: number } | null>(null);
const reducedMotion = useReducedMotion();
const rotateX = useSpring(10, { stiffness: 180, damping: 26 });
const rotateY = useSpring(-19, { stiffness: 180, damping: 26 });
function startDrag(event: PointerEvent<HTMLDivElement>) {
if (event.pointerType === "touch" || event.button !== 0 || dragOrigin.current || reducedMotion) return;
dragOrigin.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
rotateX: rotateX.get(),
rotateY: rotateY.get(),
};
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
}
function turnPages(event: PointerEvent<HTMLDivElement>) {
const origin = dragOrigin.current;
if (!origin || origin.id !== event.pointerId) return;
rotateX.set(origin.rotateX - Math.tanh((event.clientY - origin.y) / 170) * 18);
rotateY.set(origin.rotateY + Math.tanh((event.clientX - origin.x) / 170) * 32);
}
function endDrag(event: PointerEvent<HTMLDivElement>) {
if (dragOrigin.current?.id !== event.pointerId) return;
dragOrigin.current = null;
setDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
rotateX.set(10);
rotateY.set(-19);
}
return (
<div className="@container mx-auto w-full max-w-[620px] text-(--home-ink) max-[960px]:mt-[25px] max-[900px]:max-w-[560px]">
<div className="perspective-[1400px] relative aspect-[620/600]">
<div
className="absolute right-[5%] bottom-0 left-[15%] h-[10%] rounded-full bg-black opacity-70 blur-[18px]"
aria-hidden="true"
/>
<div
className={cn(
"transform-3d absolute inset-0 cursor-grab pointer-coarse:cursor-auto touch-pan-y select-none motion-reduce:cursor-auto",
dragging && "cursor-grabbing",
)}
onPointerDown={startDrag}
onPointerMove={turnPages}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
>
<m.div
className="sculpture-rig group/rig transform-3d absolute inset-0"
data-spread={spread}
data-instant={keyboardAction}
style={{ rotateX, rotateY, rotateZ: -7 }}
>
{templates.map((paperTemplate, index) => {
const position = (index - templates.indexOf(template) + templates.length) % templates.length;
return (
<ResumePaper
key={paperTemplate}
name={displayName}
accent={accent}
typeface={typeface}
template={paperTemplate}
position={position === 0 ? "front" : position === 1 ? "left" : "right"}
/>
);
})}
</m.div>
</div>
</div>
<div className="flex items-center justify-between @max-[450px]:gap-1 gap-3 @max-[450px]:px-0 px-2 pt-[14px] pb-[18px] text-(--home-muted)">
<p className="flex items-center gap-[9px] @max-[450px]:text-[11px] text-[12px]">
<span aria-hidden="true">↔</span>{" "}
<span className="pointer-coarse:hidden motion-reduce:hidden">
<Trans>Drag to turn the pages</Trans>
</span>
<span className="pointer-coarse:inline hidden motion-reduce:inline">
<Trans>Make it your own</Trans>
</span>
</p>
<button
type="button"
className={cn(
"flex min-h-[44px] cursor-pointer items-center gap-[14px] bg-transparent px-2 @max-[450px]:text-[11px] text-(--home-ink) text-[12px] hover:text-white",
focusRing,
)}
aria-pressed={spread}
onClick={(event) => {
setKeyboardAction(event.detail === 0);
setSpread(!spread);
}}
>
{spread ? t`Stack pages` : t`Spread pages`}
<span className="font-light text-[21px]" aria-hidden="true">
{spread ? "−" : "+"}
</span>
</button>
</div>
<fieldset className="mb-4 flex @max-[300px]:gap-[5px] gap-2" aria-label={t`Choose a resume template`}>
{templates.map((option) => (
<button
key={option}
type="button"
className={cn(
"flex min-h-[44px] flex-1 cursor-pointer items-center justify-center @max-[300px]:gap-[5px] gap-[9px] rounded-[4px] border border-[#343437] bg-[#19191b] p-2 @max-[300px]:text-[11px] text-[#a5a5ab] text-[12px] hover:text-white aria-pressed:border-[#8c7b6d] aria-pressed:bg-[#262427] aria-pressed:text-[#f1f0eb] aria-pressed:hover:text-white",
focusRing,
)}
aria-pressed={option === template}
onClick={(event) => {
setKeyboardAction(event.detail === 0);
onTemplateChange(option);
}}
>
<span
className={cn("h-[21px] w-[16px] rounded-[2px] border border-[#b6aea7]", templateIcon[option])}
aria-hidden="true"
/>
{option === "ditgar" ? "Ditgar" : option === "azurill" ? "Azurill" : "Bronzor"}
</button>
))}
</fieldset>
<div className="grid @max-[300px]:grid-cols-1 @max-[450px]:grid-cols-[1fr_auto] grid-cols-[minmax(120px,1fr)_auto_auto] @max-[450px]:gap-[17px] gap-5 rounded-[4px] border border-[#363638] bg-[#1b1b1d] @max-[450px]:p-[17px] px-[21px] py-[19px] shadow-[0_15px_30px_rgb(0_0_0/16%),inset_0_1px_0_rgb(255_255_255/3%)]">
<div className="@max-[450px]:col-span-full">
<label htmlFor={nameId} className={dockLabel}>
<Trans>Your name</Trans>
</label>
<input
id={nameId}
className={cn(
"min-h-[44px] w-full rounded-[4px] border border-[#3b3b3e] bg-[#232326] px-[11px] text-[#f1f0eb] text-[13px]",
focusRing,
)}
value={name}
maxLength={40}
onChange={(event) => onNameChange(event.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
<fieldset className="min-w-0">
<legend className={dockLabel}>
<Trans>Color</Trans>
</legend>
<div className="flex">
{colors.map((color) => (
<button
key={color.value}
type="button"
className={cn(
dockButton,
"active:transform-[scale(0.96)] rounded-[4px] motion-reduce:active:transform-none",
focusRing,
)}
aria-label={color.name}
aria-pressed={accent === color.value}
onClick={() => onAccentChange(color.value)}
>
<span
className={cn(
"size-[23px] rounded-full shadow-[inset_0_0_0_1px_rgb(255_255_255/12%)]",
accent === color.value && "outline-1 outline-[#dedbd6] outline-offset-4",
)}
style={{ backgroundColor: color.value }}
/>
</button>
))}
</div>
</fieldset>
<fieldset className="min-w-0">
<legend className={dockLabel}>
<Trans>Typeface</Trans>
</legend>
<div className="flex @max-[300px]:w-max rounded-[4px] border border-[#3b3b3e]">
{(["sans", "serif"] as const).map((font) => (
<button
key={font}
type="button"
className={cn(
dockButton,
"min-h-[36px] @max-[450px]:min-w-[64px] min-w-[45px] hover:text-white aria-pressed:bg-[#37373b] aria-pressed:text-[#f1f0eb] aria-pressed:hover:text-white",
font === "sans" ? "rounded-s-[4px]" : "rounded-e-[4px] font-[Georgia,serif]",
focusRing,
)}
aria-pressed={typeface === font}
onClick={() => onTypefaceChange(font)}
>
{font === "sans" ? t`Sans` : t`Serif`}
</button>
))}
</div>
</fieldset>
</div>
</div>
);
}
const paperTransform = {
front:
"transform-[translate3d(0,0,45px)_rotateZ(2deg)] group-data-[spread=true]/rig:transform-[translate3d(0,-2%,75px)_rotateZ(0deg)]",
left: "transform-[translate3d(-12%,4%,-65px)_rotateX(-2deg)_rotateY(-3deg)_rotateZ(-13deg)] group-data-[spread=true]/rig:transform-[translate3d(-36%,1%,-80px)_rotateX(-2deg)_rotateY(-3deg)_rotateZ(-24deg)]",
right:
"transform-[translate3d(13%,-1%,-25px)_rotateX(2deg)_rotateY(3deg)_rotateZ(13deg)] group-data-[spread=true]/rig:transform-[translate3d(34%,-4%,-40px)_rotateX(2deg)_rotateY(3deg)_rotateZ(24deg)]",
};
const paperSide =
"absolute inset-0 border border-[#e6e2db] shadow-[8px_15px_20px_rgb(0_0_0/35%),18px_38px_45px_rgb(0_0_0/45%)] backface-hidden";
// Per-template layout of the paper face and its regions.
const face: Record<SculptureTemplate, string> = {
azurill:
"grid grid-cols-[28%_1fr] grid-rows-[auto_auto_auto_auto_1fr] gap-x-[2cqw] gap-y-[1.7cqw] p-[7%] [grid-template-areas:'identity_identity'_'skills_summary'_'skills_experience'_'skills_education'_'skills_projects']",
bronzor: "flex flex-col gap-[1.1cqw] p-[6%]",
ditgar:
"grid grid-cols-[34%_66%] grid-rows-[minmax(28%,max-content)_auto_auto_1fr] gap-0 bg-[linear-gradient(to_right,color-mix(in_srgb,var(--resume-accent)_20%,#fcfaf5)_34%,#fcfaf5_34%)] pb-[7%] [grid-template-areas:'identity_summary'_'skills_experience'_'skills_education'_'skills_projects']",
};
const header: Record<SculptureTemplate, string> = {
azurill: "pb-[1.7cqw] text-center [grid-area:identity]",
bronzor: "pb-[1.1cqw] text-center",
ditgar: "bg-(--resume-accent) p-[2.3cqw] text-left text-white [grid-area:identity]",
};
const bronzorRow = "grid grid-cols-[25%_1fr] gap-[1.4cqw] border-t border-(--resume-accent) pt-[1cqw]";
const sections: Record<
SculptureTemplate,
Record<"summary" | "skills" | "experience" | "education" | "projects", string>
> = {
azurill: {
summary: "[grid-area:summary]",
skills: "border-r border-[color-mix(in_srgb,var(--resume-accent)_35%,white)] pr-[1cqw] [grid-area:skills]",
experience: "[grid-area:experience]",
education: "[grid-area:education]",
projects: "[grid-area:projects]",
},
bronzor: {
summary: bronzorRow,
skills: bronzorRow,
experience: bronzorRow,
education: bronzorRow,
projects: bronzorRow,
},
ditgar: {
summary:
"bg-[color-mix(in_srgb,var(--resume-accent)_20%,#fcfaf5)] p-[2.6cqw] text-[1.3cqw] leading-[1.65] [grid-area:summary]",
skills: "px-[2.3cqw] py-[2.6cqw] [grid-area:skills]",
experience: "px-[2.6cqw] pt-[2.4cqw] [grid-area:experience]",
education: "px-[2.6cqw] pt-[2.2cqw] [grid-area:education]",
projects: "px-[2.6cqw] pt-[2.2cqw] [grid-area:projects]",
},
};
const job: Record<SculptureTemplate, string> = {
azurill: "block",
bronzor: "grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-[0.7cqw] gap-y-[0.25cqw]",
ditgar: "ml-[calc(-0.8cqw_-_2px)] block border-l-2 border-(--resume-accent) pl-[0.8cqw]",
};
const h4 = "text-[1.2cqw] leading-[1.3] font-[650]";
const sectionP = "mt-[0.5cqw] text-[#5c5663]";
const date = "block whitespace-nowrap text-[1cqw] text-[#746d79]";
type ResumePaperProps = {
name: string;
accent: string;
typeface: "sans" | "serif";
template: SculptureTemplate;
position: "front" | "left" | "right";
};
function ResumePaper({ name, accent, typeface, template, position }: ResumePaperProps) {
const bronzor = template === "bronzor";
const ditgar = template === "ditgar";
const h3 = cn(
"mb-[0.9cqw] font-[650] text-(--resume-accent) text-[1.5cqw] leading-[1.2]",
ditgar && "border-(--resume-accent) border-b pb-[0.6cqw]",
);
const jobEntry = cn(
"relative",
template === "azurill" &&
"before:absolute before:top-[0.35cqw] before:-left-[1.75cqw] before:size-[0.85cqw] before:rounded-full before:border before:border-(--resume-accent) before:bg-[#fcfaf5] before:content-['']",
);
const jobStrong = cn("block font-medium", bronzor ? "col-span-full row-2" : "mt-[0.25cqw]");
const jobDate = cn(date, bronzor ? "col-2 row-1" : "mt-[0.25cqw]");
return (
<article
className={cn(
"sculpture-paper transform-3d absolute top-[2%] left-[24%] aspect-[210/297] w-[56%] origin-[50%_80%] animate-[home-sculpture-assemble_900ms_var(--ease-out-strong)_both] transition-[transform] duration-[650ms] ease-out-strong group-data-[instant=true]/rig:transition-none",
paperTransform[position],
)}
data-template={template}
data-position={position}
data-typeface={typeface}
data-long-name={name.length > 18}
aria-hidden={position !== "front"}
aria-label={t`Sample resume for ${name}`}
style={{ "--resume-accent": accent } as CSSProperties}
>
<div
className={cn(
"sculpture-paper-back transform-[rotateY(180deg)_translateZ(1px)] bg-[linear-gradient(105deg,#ebe7dd,#fbf8f0_42%,#eee9e0)]",
paperSide,
)}
aria-hidden="true"
/>
<span
className="sculpture-edge transform-[translateX(-50%)_rotateY(90deg)] absolute top-0 left-0 h-full w-[2px] bg-[linear-gradient(#fffdf7,#b6aea2,#efebe1)]"
aria-hidden="true"
/>
<span
className="sculpture-edge transform-[translateX(-50%)_rotateY(90deg)] absolute top-0 left-full h-full w-[2px] bg-[linear-gradient(#fffdf7,#b6aea2,#efebe1)]"
aria-hidden="true"
/>
<span
className="sculpture-edge transform-[translateY(-50%)_rotateX(90deg)] absolute top-0 left-0 h-[2px] w-full bg-[linear-gradient(#fffdf7,#b6aea2,#efebe1)]"
aria-hidden="true"
/>
<span
className="sculpture-edge transform-[translateY(-50%)_rotateX(90deg)] absolute top-full left-0 h-[2px] w-full bg-[linear-gradient(#fffdf7,#b6aea2,#efebe1)]"
aria-hidden="true"
/>
<div
className={cn(
"sculpture-paper-face transform-[translateZ(1px)] overflow-hidden bg-[#fcfaf5] text-[#24222a] text-[1.2cqw] leading-[1.45] after:pointer-events-none after:absolute after:inset-0 after:bg-[linear-gradient(110deg,rgb(255_255_255/10%),transparent_35%,rgb(62_42_12/3%)_97%,rgb(255_255_255/30%))] after:content-['']",
typeface === "serif" ? "font-[Georgia,serif]" : "font-[Arial,sans-serif]",
paperSide,
face[template],
)}
>
<header className={header[template]}>
<h2
className={cn(
"wrap-anywhere mb-[1cqw] font-[650] leading-[1.1] tracking-[-0.05em]",
name.length > 18 ? "text-[1.9cqw]" : ditgar ? "text-[3.5cqw]" : "text-[3.8cqw]",
)}
>
{name}
</h2>
<p className={cn("mb-[1cqw]", ditgar ? "text-[1.2cqw] text-white" : "text-[#55515c] text-[1.45cqw]")}>
<Trans>Product designer</Trans>
</p>
<div
className={
ditgar
? "wrap-anywhere grid justify-start justify-items-start gap-[0.55cqw] text-[0.82cqw] text-white"
: "flex flex-wrap justify-center gap-x-[1cqw] gap-y-[0.3cqw] text-[#66606c] text-[1cqw]"
}
>
<span>London, UK</span>
<span>alexmorgan.design</span>
<span>alex.morgan@example.com</span>
</div>
</header>
<section className={cn("min-w-0", sections[template].summary)}>
<h3 className={cn(h3, ditgar && "hidden")}>
<Trans>About me</Trans>
</h3>
<div>
<p className={sectionP}>
<Trans>I design software for teams, from early research to the details people use every day.</Trans>
</p>
</div>
</section>
<section className={cn("min-w-0", sections[template].experience)}>
<h3 className={h3}>
<Trans>Experience</Trans>
</h3>
<div className={cn(template === "azurill" && "relative border-(--resume-accent) border-l pl-[1.3cqw]")}>
<div className={jobEntry}>
<div className={job[template]}>
<h4 className={h4}>Northstar Studio</h4>
<strong className={jobStrong}>
<Trans>Senior product designer</Trans>
</strong>
<span className={jobDate}>
<Trans>2022 to now</Trans>
</span>
</div>
<p className={sectionP}>
<Trans>Built a design system used by six product teams.</Trans>
</p>
<ul className="mt-[0.7cqw] list-outside list-disc pl-[1.5cqw] text-[#5c5663]">
<li>
<Trans>Worked with engineers from idea to launch.</Trans>
</li>
</ul>
</div>
<div className={cn(jobEntry, "mt-[1.9cqw]")}>
<div className={job[template]}>
<h4 className={h4}>Form &amp; Field</h4>
<strong className={jobStrong}>
<Trans>Product designer</Trans>
</strong>
<span className={jobDate}>
<Trans>2019 to 2022</Trans>
</span>
</div>
<p className={sectionP}>
<Trans>Designed websites and tools for a growing creative community.</Trans>
</p>
</div>
</div>
</section>
<section className={cn("min-w-0", sections[template].skills)}>
<h3 className={h3}>
<Trans>Skills</Trans>
</h3>
<div className={cn(bronzor && "grid grid-cols-2 gap-x-[1.2cqw] gap-y-[0.6cqw]")}>
<strong className={cn("block font-medium", bronzor && "row-1")}>
<Trans>Design</Trans>
</strong>
<p className={sectionP}>
<Trans>Product strategy</Trans>
<br />
<Trans>Interaction design</Trans>
<br />
<Trans>Design systems</Trans>
</p>
<strong className={cn("block font-medium", bronzor ? "row-1" : "mt-[2cqw]")}>
<Trans>Tools</Trans>
</strong>
<p className={sectionP}>
Figma
<br />
<Trans>Prototyping</Trans>
<br />
<Trans>User research</Trans>
</p>
<h3 className={cn(h3, bronzor ? "hidden" : "mt-[2cqw]")}>
<Trans>Languages</Trans>
</h3>
<p className={cn(sectionP, bronzor && "hidden")}>
<Trans>English</Trans>
<br />
<Trans>French</Trans>
</p>
</div>
</section>
<section className={cn("min-w-0", sections[template].education)}>
<h3 className={h3}>
<Trans>Education</Trans>
</h3>
<div>
<h4 className={h4}>
<Trans>Graphic Design</Trans>
</h4>
<p className={sectionP}>University of the West of England</p>
<span className={cn(date, "mt-[0.25cqw]")}>
<Trans>2015 to 2019</Trans>
</span>
</div>
</section>
<section className={cn("min-w-0", sections[template].projects)}>
<h3 className={h3}>
<Trans>Projects</Trans>
</h3>
<div>
<h4 className={h4}>
<Trans>Open design library</Trans>
</h4>
<p className={sectionP}>
<Trans>A free collection of accessible patterns for the web.</Trans>
</p>
</div>
</section>
<footer
className={cn(
"absolute bottom-[3%] flex justify-between text-[#8d8791] text-[0.9cqw]",
ditgar ? "right-[4.5%] left-[38.5%]" : "right-[7%] left-[7%]",
)}
>
<span>
<Trans>Made with Reactive Resume</Trans>
</span>
<span>1</span>
</footer>
</div>
</article>
);
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { sectionProgress } from "./scroll";
describe("section progress", () => {
it("measures a pinned scene by how far its sticky stage has travelled", () => {
// A 3000px scene in a 1000px viewport pins for 2000px of scrolling.
expect(sectionProgress(200, 3000, 1000, true)).toBe(0);
expect(sectionProgress(-1000, 3000, 1000, true)).toBe(0.5);
expect(sectionProgress(-2600, 3000, 1000, true)).toBe(1);
});
it("measures any other section by how much of it has come into view", () => {
expect(sectionProgress(1000, 600, 1000, false)).toBe(0);
expect(sectionProgress(700, 600, 1000, false)).toBe(0.5);
expect(sectionProgress(-50, 2400, 1000, false)).toBe(1);
});
});
+250
View File
@@ -0,0 +1,250 @@
import type { RefObject } from "react";
import { useEffect } from "react";
import { create } from "zustand";
/** Each section's `data-scene` index, in page order. Scenes 0–5 are pinned; the rest scroll normally. */
export const SCENE = {
hero: 0,
write: 1,
design: 2,
check: 3,
tailor: 4,
share: 5,
numbers: 6,
languages: 7,
support: 8,
footer: 9,
} as const;
/**
* The coarse state of the story. Continuous motion never goes through React: the engine below writes each section's
* progress as the CSS variable --p and the scenes derive everything else from it with calc(). React only hears about
* steps: which scene is on screen, which template is showing, and which controls are visible enough to use.
*/
type LandingState = {
activeScene: number;
scrolled: boolean;
/** Write: 0 while the line is typed, 1 while the assistant's comment is up, 2 once the suggestion is accepted. */
writeStep: 0 | 1 | 2;
/** Design: the template on top of the stack (0–4). */
designStep: number;
/** Design: zoomed out to the contact sheet, so the template tabs are gone. */
designZoomed: boolean;
/** Check: the scan's progress, in 40 steps, which drives the score and the checklist. */
checkProgress: number;
/** Tailor: how many of the posting's four keywords are matched. */
keywordCount: number;
/** Share: the visibility and copy controls are showing. */
shareReady: boolean;
/** Share: the night sky is up, so the header draws in light ink. */
night: boolean;
/** Numbers: scrolled far enough in for the digits to roll. It stays true. */
numbersInView: boolean;
};
export const useLanding = create<LandingState>()(() => ({
activeScene: SCENE.hero,
scrolled: false,
writeStep: 0,
designStep: 0,
designZoomed: false,
checkProgress: 0,
keywordCount: 0,
shareReady: false,
night: false,
numbersInView: false,
}));
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
const stepsPassed = (progress: number, thresholds: number[]) => thresholds.filter((at) => progress >= at).length;
/**
* A section's progress through the viewport, from 0 to 1. A pinned scene counts how far its sticky stage has
* travelled; any other section counts how much of it has come into view.
*/
export function sectionProgress(top: number, height: number, viewportHeight: number, pinned: boolean) {
if (pinned) return clamp01(-top / Math.max(1, height - viewportHeight));
return clamp01((viewportHeight - top) / Math.max(1, Math.min(height, viewportHeight)));
}
const reducedMotionQuery = "(prefers-reduced-motion: reduce)";
export const prefersReducedMotion = () => window.matchMedia(reducedMotionQuery).matches;
/** Scrolls to a point within a scene, e.g. `at = 0.93` for the end of Write's accepted suggestion. */
export function goToScene(index: number, at: number) {
const section = document.querySelector<HTMLElement>(`[data-scene="${index}"]`);
if (!section) return;
const rect = section.getBoundingClientRect();
const top = rect.top + window.scrollY + at * Math.max(0, rect.height - window.innerHeight);
window.scrollTo({ top, behavior: prefersReducedMotion() ? "auto" : "smooth" });
}
/** Draws Tailor's threads from each keyword in the posting to its match on the resume, in the threads' SVG space. */
function drawThreads(section: HTMLElement) {
const svg = section.querySelector<SVGSVGElement>("[data-threads]");
if (!svg) return;
const origin = svg.getBoundingClientRect();
for (const path of svg.querySelectorAll<SVGPathElement>("[data-thread]")) {
const key = path.dataset.thread;
const from = section.querySelector(`[data-posting-keyword="${key}"]`)?.getBoundingClientRect();
const to = section.querySelector(`[data-keyword="${key}"]`)?.getBoundingClientRect();
if (!from || !to) continue;
const sideBySide = to.left > from.right || to.right < from.left;
if (sideBySide) {
const leftToRight = to.left > from.right;
const x1 = (leftToRight ? from.right + 3 : from.left - 3) - origin.left;
const x2 = (leftToRight ? to.left - 3 : to.right + 3) - origin.left;
const y1 = from.top + from.height / 2 - origin.top;
const y2 = to.top + to.height / 2 - origin.top;
const mid = (x1 + x2) / 2;
path.setAttribute("d", `M${x1} ${y1} C${mid} ${y1} ${mid} ${y2} ${x2} ${y2}`);
} else {
const x1 = from.left + from.width / 2 - origin.left;
const x2 = to.left + to.width / 2 - origin.left;
const y1 = from.bottom + 2 - origin.top;
const y2 = to.top - 2 - origin.top;
const mid = (y1 + y2) / 2;
path.setAttribute("d", `M${x1} ${y1} C${x1} ${mid} ${x2} ${mid} ${x2} ${y2}`);
}
}
}
/**
* The scroll engine: one rAF-throttled listener that writes --p on every `[data-scene]` section inside `root`,
* plus the few values that need layout (the contact sheet's scale, the Check lens path, Tailor's threads), and
* publishes the coarse steps to `useLanding`. It also moves the dark-mode lamp and the hero's parallax with the mouse.
*/
export function useScrollScenes(root: RefObject<HTMLElement | null>) {
useEffect(() => {
const container = root.current;
if (!container) return;
const reducedMotion = window.matchMedia(reducedMotionQuery);
const sections = [...container.querySelectorAll<HTMLElement>("[data-scene]")];
const sectionAt = (index: number) => sections.find((section) => section.dataset.scene === String(index));
// Rewriting an unchanged --p would still restyle the whole section, so only changes are written.
const writtenProgress = new WeakMap<HTMLElement, string>();
// Values that only change with the viewport, not with scrolling.
const layout = () => {
const design = sectionAt(SCENE.design);
const stack = design?.querySelector<HTMLElement>("[data-stack]");
if (!design || !stack) return;
const pageWidth = stack.offsetWidth || 1;
const scale = Math.min(
(window.innerWidth * 0.94) / (5.48 * pageWidth),
(window.innerHeight * 0.54) / (4.122 * pageWidth),
);
design.style.setProperty("--smin", scale.toFixed(4));
};
let frame = 0;
let threadsDrawn = false;
const measure = () => {
frame = 0;
const viewportHeight = window.innerHeight;
const reduced = reducedMotion.matches;
const progress: number[] = [];
let activeScene: number = SCENE.hero;
for (const section of sections) {
const index = Number(section.dataset.scene);
const rect = section.getBoundingClientRect();
const pinned = section.dataset.pin !== undefined;
progress[index] = reduced ? 1 : sectionProgress(rect.top, rect.height, viewportHeight, pinned);
if (rect.top <= viewportHeight / 2 && rect.bottom > viewportHeight / 2) activeScene = index;
}
for (const section of sections) {
const value = (progress[Number(section.dataset.scene)] ?? 0).toFixed(4);
if (writtenProgress.get(section) === value) continue;
section.style.setProperty("--p", value);
writtenProgress.set(section, value);
}
const [, write1 = 0, design = 0, check = 0, tailor = 0, share = 0, numbers = 0] = progress;
const scan = clamp01((check - 0.06) / 0.8);
// Check's lens follows a Lissajous path down the page, unless the pointer is steering it.
const checkSection = sectionAt(SCENE.check);
if (checkSection && checkSection.dataset.lensHover === undefined) {
checkSection.style.setProperty("--lx", `${(50 + 30 * Math.sin(scan * Math.PI * 2.5)).toFixed(2)}%`);
checkSection.style.setProperty("--ly", `${(14 + 72 * scan).toFixed(2)}%`);
}
// Threads follow the keywords' real positions, so they're redrawn while Tailor is near the screen.
const tailorSection = sectionAt(SCENE.tailor);
const nearTailor = activeScene >= SCENE.check && activeScene <= SCENE.share;
if (tailorSection && (nearTailor || !threadsDrawn)) {
drawThreads(tailorSection);
threadsDrawn = true;
}
const state = useLanding.getState();
const next: Partial<LandingState> = {
activeScene,
scrolled: window.scrollY > 8,
writeStep: write1 >= 0.84 ? 2 : write1 >= 0.4 ? 1 : 0,
designStep: stepsPassed(design, [0.15, 0.31, 0.47, 0.63]),
designZoomed: design >= 0.84,
checkProgress: Math.round(scan * 40) / 40,
keywordCount: stepsPassed(tailor, [0.19, 0.31, 0.43, 0.55]),
shareReady: share >= 0.66,
night: activeScene === SCENE.share && (reduced || (share > 0.06 && share < 0.93)),
numbersInView: state.numbersInView || numbers > 0.2,
};
const changed = (Object.keys(next) as (keyof LandingState)[]).some((key) => next[key] !== state[key]);
if (changed) useLanding.setState(next);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
const onResize = () => {
layout();
schedule();
};
// The pointer moves the dark-mode lamp everywhere and the hero's fragments and doodles while it's on screen.
let pointerFrame = 0;
let pointer = { x: 0, y: 0 };
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
pointer = { x: event.clientX, y: event.clientY };
if (pointerFrame) return;
pointerFrame = requestAnimationFrame(() => {
pointerFrame = 0;
if (reducedMotion.matches) return;
const lamp = container.querySelector<HTMLElement>("[data-lamp]");
lamp?.style.setProperty("--lx", `${pointer.x}px`);
lamp?.style.setProperty("--ly", `${pointer.y}px`);
const hero = sectionAt(SCENE.hero);
if (hero && useLanding.getState().activeScene === SCENE.hero) {
hero.style.setProperty("--mx", ((pointer.x / window.innerWidth) * 2 - 1).toFixed(3));
hero.style.setProperty("--my", ((pointer.y / window.innerHeight) * 2 - 1).toFixed(3));
}
});
};
layout();
measure();
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", onResize);
window.addEventListener("pointermove", onPointerMove, { passive: true });
reducedMotion.addEventListener("change", schedule);
// Web fonts change the text's size, which moves the keywords the threads point at.
void document.fonts?.ready.then(onResize);
return () => {
cancelAnimationFrame(frame);
cancelAnimationFrame(pointerFrame);
window.removeEventListener("scroll", schedule);
window.removeEventListener("resize", onResize);
window.removeEventListener("pointermove", onPointerMove);
reducedMotion.removeEventListener("change", schedule);
};
}, [root]);
}
+356
View File
@@ -0,0 +1,356 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { CSSProperties } from "react";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { prefersReducedMotion, SCENE, useLanding } from "./scroll";
import { CtaLink, Doodle, labelClass } from "./ui";
const crowdinUrl = "https://crowdin.com/project/reactive-resume";
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
// The statistics service caches the totals for six hours.
const statisticsStaleTime = 6 * 60 * 60 * 1000;
type RollingNumberProps = { value: number; roll: boolean; delay: number };
/** A total whose digits roll up into place, one column per digit, in the locale's own digits. */
function RollingNumber({ value, roll, delay }: RollingNumberProps) {
const { i18n } = useLingui();
const text = i18n.number(value);
const digits = Array.from({ length: 10 }, (_, digit) => i18n.number(digit));
return (
<>
<span className="sr-only">{text}</span>
<span aria-hidden="true" dir="ltr" className="inline-flex">
{Array.from(text).map((character, index) => {
const digit = digits.indexOf(character);
return (
<span key={index} className="inline-block h-[1em] overflow-hidden">
<span
className="flex starting:translate-y-0 translate-y-[calc(var(--offset)*1em)] flex-col transition-transform duration-[2s] ease-[cubic-bezier(.2,.8,.2,1)]"
style={
{
"--offset": roll ? -Math.max(0, digit) : 0,
transitionDelay: `${delay + index * 80}ms`,
} as CSSProperties
}
>
{digit === -1 ? (
<span className="h-[1em] leading-none">{character}</span>
) : (
digits.map((glyph) => (
<span key={glyph} className="h-[1em] leading-none">
{glyph}
</span>
))
)}
</span>
</span>
);
})}
</span>
</>
);
}
/** 06 Numbers: the live community totals. */
export function Numbers() {
const inView = useLanding((state) => state.numbersInView);
const { data } = useQuery(orpc.statistics.getTotals.queryOptions({ staleTime: statisticsStaleTime }));
const rows = [
{ label: t`People using it`, detail: t`writing, tailoring and sending`, value: data?.users },
{ label: t`Resumes created`, detail: t`and counting`, value: data?.resumes },
];
return (
<section
data-scene={SCENE.numbers}
aria-labelledby="numbers-title"
className="relative mx-auto max-w-[1440px] px-(--gutter) pt-[18vh] pb-[10vh]"
>
<h2 id="numbers-title" className={cn(labelClass, "text-ink-3")}>
{t`In good company`}
</h2>
<dl className="mt-[22px]">
{rows.map((row, index) => (
<div
key={row.label}
className="flex flex-wrap items-end justify-between gap-x-10 gap-y-3 border-line border-t py-[3.2vh]"
>
<dt className="flex flex-col gap-1.5 pb-[1.2vh]">
<span className={cn(labelClass, "text-ink-3")}>{row.label}</span>
<span className="font-display text-[clamp(18px,1.6vw,24px)] text-ink-2 italic leading-[1.3]">
{row.detail}
</span>
</dt>
<dd className="font-anybody font-light text-[clamp(56px,7.5vw,136px)] text-ink tabular-nums leading-none tracking-[-.03em]">
{row.value == null ? (
<span className="text-ink-3">
<span aria-hidden="true">—</span>
<span className="sr-only">{t`Loading the total`}</span>
</span>
) : (
<RollingNumber value={row.value} roll={inView} delay={index * 150} />
)}
</dd>
</div>
))}
</dl>
<p className="border-line border-t pt-3.5 font-ui text-[13px] text-ink-3">{t`Live totals, refreshed every few hours.`}</p>
</section>
);
}
/** The word for "résumé" in sixteen languages, each in its own script and direction, whatever the page's locale. */
const words = [
{ word: "Résumé", language: "Français", lang: "fr" },
{ word: "Lebenslauf", language: "Deutsch", lang: "de" },
{ word: "Currículum", language: "Español", lang: "es" },
{ word: "履歴書", language: "日本語", lang: "ja" },
{ word: "Życiorys", language: "Polski", lang: "pl" },
{ word: "السيرة الذاتية", language: "العربية", lang: "ar", dir: "rtl" },
{ word: "Currículo", language: "Português", lang: "pt" },
{ word: "简历", language: "中文", lang: "zh" },
{ word: "Özgeçmiş", language: "Türkçe", lang: "tr" },
{ word: "Резюме", language: "Русский", lang: "ru" },
{ word: "이력서", language: "한국어", lang: "ko" },
{ word: "קורות חיים", language: "עברית", lang: "he", dir: "rtl" },
{ word: "Životopis", language: "Čeština", lang: "cs" },
{ word: "Βιογραφικό", language: "Ελληνικά", lang: "el" },
{ word: "बायोडाटा", language: "हिन्दी", lang: "hi" },
{ word: "Curriculum", language: "Italiano", lang: "it" },
];
/** 07 Languages. The word turns over every 1.9s while the section is near; it can be paused. */
export function Languages() {
const near = useLanding((state) => state.activeScene >= SCENE.numbers && state.activeScene <= SCENE.support);
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const current = words[index] ?? words[0];
useEffect(() => {
if (!near || paused || prefersReducedMotion()) return;
const timer = window.setInterval(() => setIndex((value) => (value + 1) % words.length), 1900);
return () => window.clearInterval(timer);
}, [near, paused]);
return (
<section
data-scene={SCENE.languages}
aria-labelledby="languages-title"
className="relative flex flex-col items-center gap-[22px] overflow-hidden px-(--gutter) py-[14vh] text-center"
>
<Doodle
name="globe"
wipe="clamp(0, (var(--p) - .25) / .2, 1)"
className="start-[6vw] top-[10vh] w-[11vw] translate-y-[calc(var(--p)*-40px)] rotate-[-6deg] max-[900px]:hidden"
/>
<h2 id="languages-title" className={cn(labelClass, "text-ink-3")}>
{t`Speaks your language`}
</h2>
<div
aria-hidden="true"
className="h-[1.25em] w-full overflow-hidden font-anybody font-light text-[clamp(48px,7.5vw,140px)] text-ink leading-[1.25] tracking-[-.03em]"
>
<div
className="transition-transform duration-[.9s] ease-[cubic-bezier(.7,0,.2,1)]"
style={{ transform: `translateY(${index * -1.25}em)` }}
>
{words.map((item) => (
<div key={item.lang} lang={item.lang} dir={item.dir} className="h-[1.25em] whitespace-nowrap">
{item.word}
</div>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span
lang={current?.lang}
className="font-martian font-medium text-[12px] text-accent-text leading-[1.4] tracking-[.1em]"
>
{current?.language}
</span>
<button
type="button"
onClick={() => setPaused((value) => !value)}
aria-label={paused ? t`Play the list of languages` : t`Pause the list of languages`}
className="grid size-6 place-items-center rounded-full text-ink-3 transition-colors hover:bg-hover hover:text-ink motion-reduce:hidden"
>
<Icon name={paused ? "play_arrow" : "pause"} size={16} filled />
</button>
</div>
<p className="mt-3 max-w-[30em] text-pretty font-display text-[clamp(17px,1.4vw,21px)] text-ink-2 leading-normal">
<a
href={crowdinUrl}
className="text-accent-text italic underline underline-offset-[3px] transition-colors hover:text-accent-hover"
>
{t`Help translate it into yours.`}
</a>
</p>
</section>
);
}
const pillLinkClass =
"flex h-10 items-center gap-2 rounded-full border border-line-2 px-3.5 font-medium font-ui text-ink text-sm transition-colors hover:bg-hover";
const receiptButtonClass =
"flex h-[42px] items-center justify-between rounded-md px-3.5 font-semibold font-ui text-sm transition-colors";
/** 08 Support: the case for donations, printed as a receipt that totals nothing. */
export function Support() {
const { i18n } = useLingui();
const nothing = i18n.number(0, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const templates = i18n.number(15);
const none = t({
message: "none",
comment: "Receipt line amount for something Reactive Resume doesn't have, e.g. ads.",
});
const lineItems = [
[t`Resume builder`, nothing],
[t`Templates × ${templates}`, nothing],
[t`Assistant (your key)`, nothing],
[t`ATS checker`, nothing],
[t`Job tracker`, nothing],
[t`Cover letters`, nothing],
[t`Ads`, none],
[t`Tracking`, none],
] as const;
const pills: { href: string; icon: IconName; label: string }[] = [
{ href: crowdinUrl, icon: "translate", label: t`Translate the app` },
{ href: `${githubUrl}/issues`, icon: "bug_report", label: t`Report a bug` },
{ href: "https://docs.rxresu.me/contributing/development", icon: "code", label: t`Contribute code` },
];
return (
<section
id="support"
data-scene={SCENE.support}
aria-labelledby="support-title"
className="relative mx-auto grid max-w-[1440px] grid-cols-[repeat(auto-fit,minmax(min(100%,440px),1fr))] items-start gap-16 border-line border-t px-(--gutter) pt-[12vh] pb-[16vh]"
>
<Doodle
name="jar"
wipe="clamp(0, (var(--p) - .3) / .2, 1)"
className="start-[3vw] bottom-[4vh] w-[9vw] translate-y-[calc(var(--p)*-30px)] rotate-[4deg] max-[900px]:hidden"
/>
<div className="flex flex-col gap-[22px] pt-[4vh]">
<span className={cn(labelClass, "text-ink-3")}>{t`Support`}</span>
<h2
id="support-title"
className="font-anybody font-light text-[clamp(48px,5.6vw,100px)] text-ink leading-[.98] tracking-[-.03em]"
>
<Trans>
Keep it <em className="font-display font-normal text-accent-text italic">free.</em>
</Trans>
</h2>
<p className="max-w-[28em] text-pretty font-display text-[clamp(17px,1.4vw,21px)] text-ink-2 leading-normal">
{t`Reactive Resume is open source under the MIT License. Amruth Pillai and a community of contributors keep it running, and donations pay for hosting and development.`}
</p>
<ul className="flex flex-wrap gap-2">
{pills.map((pill) => (
<li key={pill.href}>
<a href={pill.href} className={pillLinkClass}>
<Icon name={pill.icon} size={18} className="text-ink-2" />
{pill.label}
</a>
</li>
))}
</ul>
</div>
<div className="flex w-[min(100%,400px)] flex-col items-center justify-self-center">
<div
aria-hidden="true"
className="relative z-2 h-[18px] w-[calc(100%+36px)] rounded-[9px] bg-ink shadow-[inset_0_-4px_0_oklch(0_0_0/.35),0_6px_14px_-6px_oklch(0_0_0/.4)]"
>
<span className="absolute inset-x-[18px] top-2 h-[3px] rounded-[2px] bg-[oklch(0_0_0/.6)]" />
</div>
<div className="-mx-3 -mt-[9px] box-content w-full overflow-hidden px-3 pb-10">
<div className="drop-shadow-[0_18px_24px_oklch(0.2_0.01_95/.22)] [transform:translateY(calc((1-clamp(0,var(--p)*1.7-.15,1))*-100%))] [transition:transform_.4s_linear]">
<div className="receipt-paper bg-[#fdfcf8] px-[26px] pt-[30px] pb-[46px] font-martian font-stretch-[87.5%] text-[12.5px] text-[oklch(0.25_0.01_95)] leading-[1.75]">
<div className="mb-4 flex flex-col gap-0.5 text-center">
<b className="font-anybody font-semibold font-stretch-[104%] text-[18px] leading-[1.1] tracking-[-.01em]">
REACTIVE RESUME
</b>
<span className="text-[oklch(0.5_0.01_95)]">{t`A free and open-source resume builder`}</span>
<span className="text-[oklch(0.5_0.01_95)] uppercase">
{i18n.date(new Date(), { day: "2-digit", month: "short", year: "numeric" })}
</span>
</div>
<dl className="grid grid-cols-[1fr_auto] gap-x-3.5 border-[oklch(0.7_0.01_95)] border-t-[1.5px] border-dashed pt-2.5">
{lineItems.map(([item, amount]) => (
<div key={item} className="contents">
<dt>{item}</dt>
<dd>{amount}</dd>
</div>
))}
</dl>
<p className="mt-2.5 flex justify-between border-[oklch(0.7_0.01_95)] border-t-[1.5px] border-dashed pt-2.5 font-bold text-base">
<span className="uppercase">{t`Total`}</span>
<span>{i18n.number(0, { style: "currency", currency: "USD" })}</span>
</p>
<p className="mt-2.5 border-[oklch(0.7_0.01_95)] border-t-[1.5px] border-dashed pt-3 text-[oklch(0.4_0.01_95)]">
{t`Hosting and development are paid for by people like you. Optional tip:`}
</p>
<div className="mt-2.5 flex flex-col gap-1.5">
<a
href="https://github.com/sponsors/AmruthPillai"
className={cn(
receiptButtonClass,
"bg-[oklch(0.22_0.01_95)] text-white hover:bg-[oklch(0.32_0.01_95)]",
)}
>
GitHub Sponsors
<Icon name="arrow_outward" size={19} />
</a>
<a
href="https://opencollective.com/reactive-resume/donate"
className={cn(
receiptButtonClass,
"border border-[oklch(0.82_0.008_95)] text-[oklch(0.22_0.01_95)] hover:bg-[oklch(0.96_0.005_95)]",
)}
>
Open Collective
<Icon name="arrow_outward" size={19} />
</a>
</div>
<div aria-hidden="true" className="receipt-barcode mx-auto mt-[18px] mb-1.5 h-11 w-[78%]" />
<p className="text-center text-[oklch(0.5_0.01_95)]">{t`Thank you for keeping it free.`}</p>
</div>
</div>
</div>
</div>
</section>
);
}
/** 09 Closing call to action. */
export function Closing() {
return (
<section
aria-labelledby="closing-title"
className="relative mx-auto flex max-w-[1440px] flex-col items-center gap-7 border-line border-t px-(--gutter) pt-[16vh] pb-[18vh] text-center"
>
<span className={cn(labelClass, "text-ink-3")}>{t`Your turn`}</span>
<h2
id="closing-title"
className="text-balance font-anybody font-light text-[clamp(48px,6vw,108px)] text-ink leading-none tracking-[-.03em]"
>
<Trans>
Put it all on <em className="font-display font-normal text-accent-text italic">one page.</em>
</Trans>
</h2>
<p className="max-w-[28em] text-pretty font-display text-[clamp(17px,1.4vw,21px)] text-ink-2 leading-normal">
{t`Start from scratch or import what you have. It takes a few minutes, and you can come back to it anytime.`}
</p>
<CtaLink size="closing" />
</section>
);
}
+217
View File
@@ -0,0 +1,217 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { CSSProperties } from "react";
import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
import { cn } from "@reactive-resume/utils/style";
import { SCENE, useLanding } from "./scroll";
import { Sheet } from "./sheet";
import { Doodle, labelClass, TypedText } from "./ui";
const ADDRESS = "rxresu.me/alex-morgan";
const SLUG_START = ADDRESS.indexOf("/") + 1;
/**
* Colours that go from the day palette to the night sky's as --nt rises, so the text keeps its contrast however far
* the night has faded in (and, under reduced motion, the scene rests at night).
*/
const byNight = (day: string, night: string) => `color-mix(in oklch, ${day}, ${night} calc(var(--nt) * 100%))`;
const nightPalette = {
"--night-ink": byNight("var(--ink)", "oklch(0.95 0.01 95)"),
"--night-ink-2": byNight("var(--ink-2)", "oklch(0.9 0.01 95)"),
"--night-on-ink": byNight("var(--bg)", "oklch(0.18 0.02 265)"),
"--night-accent": byNight("var(--accent-text)", "oklch(0.8 0.13 150)"),
"--night-label": byNight("var(--accent-text)", "oklch(0.75 0.09 150)"),
"--night-chip": byNight("var(--ink-2)", "oklch(0.9 0.05 150)"),
"--night-chip-line": byNight("var(--line-2)", "oklch(0.8 0.12 150)"),
"--night-line": byNight("var(--line)", "oklch(1 0 0 / .14)"),
"--night-fill": byNight("var(--hover)", "oklch(1 0 0 / .08)"),
} as CSSProperties;
/** 46 stars, seeded so the server and the browser draw the same sky. */
const stars = (() => {
let seed = 7;
const random = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
return Array.from({ length: 46 }, () => ({
left: `${(random() * 100).toFixed(2)}%`,
top: `${(random() * 100).toFixed(2)}%`,
size: `${(1 + random() * 1.8).toFixed(1)}px`,
opacity: Number((0.25 + random() * 0.6).toFixed(2)),
}));
})();
const visibilityIcons: IconName[] = ["public", "lock", "visibility_off"];
/**
* 05 Share, the night scene. The page flies off to the top corner on a green trail, the resume's address types out,
* and the visibility and copy controls and the export formats appear.
*/
export function Share() {
const active = useLanding((state) => state.activeScene === SCENE.share);
const ready = useLanding((state) => state.shareReady);
const [visibility, setVisibility] = useState(0);
const [copied, setCopied] = useState(false);
const copiedTimer = useRef(0);
const share = t`Share`;
const isPrivate = visibility === 2;
useEffect(() => () => window.clearTimeout(copiedTimer.current), []);
const copy = () => {
setCopied(true);
window.clearTimeout(copiedTimer.current);
copiedTimer.current = window.setTimeout(() => setCopied(false), 1600);
};
const formats = [
{ icon: "picture_as_pdf", label: "PDF" },
{ icon: "description", label: t`Word` },
{ icon: "data_object", label: "JSON" },
] as const;
return (
<section
id="share"
data-scene={SCENE.share}
data-pin
aria-labelledby="share-title"
className="relative h-[240vh] motion-reduce:h-svh min-[900px]:h-[300vh]"
>
<div
className="sticky top-0 h-svh overflow-hidden [--f1:clamp(0,(var(--p)-.62)*14,1)] [--f2:clamp(0,(var(--p)-.68)*14,1)] [--f3:clamp(0,(var(--p)-.74)*14,1)] [--fl:clamp(0,(var(--p)-.1)/.34,1)] [--nt:calc(clamp(0,var(--p)*7,1)*(1-clamp(0,(var(--p)-.9)*12,1)))] [--pw:min(40vw,26vh)] [--tu:clamp(0,(var(--p)-.34)/.22,1)] [--ui:clamp(0,(var(--p)-.58)*8,1)] motion-reduce:[--nt:1] min-[900px]:[--pw:min(22vw,42vh)]"
style={nightPalette}
>
<div
aria-hidden="true"
className="absolute inset-0 bg-[radial-gradient(120%_90%_at_70%_0%,oklch(0.24_0.03_265),oklch(0.15_0.02_265)_60%)] opacity-(--nt)"
/>
<div aria-hidden="true" className="absolute inset-0 opacity-(--nt) [transform:translateY(calc(var(--p)*-8vh))]">
{stars.map((star) => (
<span
key={`${star.left}${star.top}`}
className="absolute size-(--size) rounded-full bg-[oklch(0.95_0.02_95)]"
style={{ left: star.left, top: star.top, opacity: star.opacity, "--size": star.size } as CSSProperties}
/>
))}
</div>
<Doodle
name="plane"
wipe="clamp(0, (var(--p) - .12) / .2, 1)"
className="start-[7vw] top-[max(13vh,120px)] w-[14vw] translate-y-[calc(var(--p)*-30px)] rotate-[-8deg] opacity-[calc(var(--nt)*.6)]! [filter:invert(1)]!"
/>
<svg
aria-hidden="true"
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 size-full opacity-[calc(var(--nt)*.7)] rtl:-scale-x-100"
>
<path
d="M50 50 Q73 50 96 -20"
pathLength={1}
fill="none"
stroke="oklch(0.8 0.12 150)"
// Not non-scaling: that measures the dash in screen pixels, which breaks the pathLength-based draw.
strokeWidth={0.1}
strokeDasharray={1}
style={{ strokeDashoffset: "calc(1 - var(--fl))" }}
/>
</svg>
<div className="absolute top-1/2 left-1/2 aspect-[612/792] w-(--pw) rounded-[2px] opacity-[calc(1-clamp(0,(var(--fl)-.82)*6,1))] shadow-paper [transform:translate(-50%,-50%)_translate(calc(var(--dir)*var(--fl)*46vw),calc(var(--fl)*var(--fl)*-70vh))_rotate(calc(var(--dir)*var(--fl)*26deg))_scale(calc(1-var(--fl)*.78))] [transition:transform_.35s_var(--ease)]">
<Sheet />
</div>
<div className="absolute inset-x-(--gutter) top-1/2 flex -translate-y-1/2 flex-col items-center gap-[26px] text-center">
<span className={cn(labelClass, "text-(--night-label) tracking-[.1em] opacity-[clamp(0,var(--tu)*20,1)]")}>
{t`Your resume, live at`}
</span>
<h2
id="share-title"
dir="ltr"
className={cn(
"flex flex-wrap items-center justify-center font-anybody font-light text-(--night-ink) text-[8vw] leading-[1.1] tracking-[-.025em] transition-opacity duration-300 min-[900px]:text-[clamp(34px,4.6vw,88px)]",
isPrivate && "opacity-40",
)}
>
<Icon
name={visibilityIcons[visibility] ?? "public"}
className="me-[.2em] text-(--night-accent) opacity-[clamp(0,var(--tu)*20,1)]"
style={{ fontSize: ".6em" }}
/>
<TypedText
text={ADDRESS}
progress="--tu"
rate={60}
className={cn("[overflow-wrap:anywhere]", isPrivate && "line-through")}
charStyle={(index) => (index >= SLUG_START ? { color: "var(--night-accent)" } : undefined)}
/>
<span
aria-hidden="true"
className="ms-[.05em] h-[.82em] w-[.07em] bg-(--night-accent) opacity-[clamp(0,var(--tu)*20,1)]"
/>
</h2>
<div
inert={!ready}
className={cn(
"flex flex-wrap justify-center gap-3 opacity-(--ui) [transform:translateY(calc((1-var(--ui))*16px))]",
!active && "pointer-events-none",
)}
>
<SegmentedControl
aria-label={t`Visibility`}
value={visibility}
onValueChange={(value) => setVisibility(value as number)}
className="h-auto rounded-full border border-(--night-line) bg-(--night-fill) p-1"
>
{[t`Public`, t`Password`, t`Private`].map((label, index) => (
<SegmentedControlItem
key={label}
value={index}
className="h-[34px] flex-none rounded-full px-3.5 font-ui text-(--night-ink-2) duration-300 hover:text-(--night-ink) data-checked:bg-(--night-ink) data-checked:text-(--night-on-ink) data-checked:shadow-none"
>
<Icon name={visibilityIcons[index] ?? "public"} size={17} />
{label}
</SegmentedControlItem>
))}
</SegmentedControl>
<button
type="button"
onClick={copy}
className="flex h-11 items-center gap-1.5 rounded-full bg-(--night-ink) px-[18px] font-semibold font-ui text-(--night-on-ink) text-sm"
>
<Icon name={copied ? "check" : "link"} size={18} />
{copied ? t`Copied` : t`Copy link`}
</button>
</div>
<ul className="flex flex-wrap justify-center gap-2.5">
{formats.map((format, index) => (
<li
key={format.icon}
className="flex h-9 items-center gap-2 rounded-md border border-[color-mix(in_oklch,var(--night-chip-line)_calc(var(--f)*100%),transparent)] px-3.5 font-martian font-medium text-(--night-chip) text-[11.5px] uppercase leading-none tracking-[.08em] opacity-(--f) [transform:translateY(calc((1-var(--f))*10px))]"
style={{ "--f": `var(--f${index + 1})` } as CSSProperties}
>
<Icon name={format.icon} size={18} />
{format.label}
</li>
))}
</ul>
</div>
<div className="absolute start-(--gutter) bottom-[clamp(20px,5vh,44px)] flex max-w-[90vw] flex-col gap-2.5 opacity-[clamp(0,(var(--p)-.2)*6,1)] min-[900px]:max-w-[min(28em,34vw)]">
<span className={cn(labelClass, "text-(--night-label)")}>05 / {share}</span>
<p className="text-pretty font-display text-(--night-ink) text-base leading-[1.45] min-[900px]:short:hidden min-[900px]:text-[clamp(17px,1.35vw,20px)]">
{t`Share a public link, add a password or keep it private. Export to PDF or Word, or take all your data with you as JSON.`}
</p>
</div>
</div>
</section>
);
}
+339
View File
@@ -0,0 +1,339 @@
import type { MessageDescriptor } from "@lingui/core";
import type { CSSProperties, ReactNode } from "react";
import { msg, t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Icon } from "@reactive-resume/ui/components/icon";
/*
* The demo resume page that every scene shows: Alex Morgan's resume, in one of five looks. Everything is sized in
* cqw so a page reads the same at any width. The scenes steer it through inherited CSS variables:
* --chk Check's scan progress; ticks each section heading as the scan passes it.
* --dw Check's date warning; flags the second job's mixed date format.
* --kwa..--kwd Tailor's keyword matches; highlights each matched phrase and adds the new skill.
*/
const fonts = {
sans: "'Hanken Grotesk Variable', system-ui, sans-serif",
serif: "'Newsreader Variable', Georgia, serif",
mono: "'Martian Mono Variable', ui-monospace, monospace",
display: "'Anybody Variable', system-ui, sans-serif",
};
export type SheetTemplate = {
name: MessageDescriptor;
root: CSSProperties;
header: CSSProperties;
/** How the header lines up: flex-start or center. */
align: "flex-start" | "center";
title: CSSProperties;
subtitleColor: string;
heading: CSSProperties;
columns: string;
main: CSSProperties;
side: CSSProperties;
};
const bodyText = (family: string, size = "1.75cqw") => ({ fontFamily: family, fontSize: size });
export const sheetTemplates: SheetTemplate[] = [
{
name: msg`Classic`,
root: { ...bodyText(fonts.sans), "--accent-ink": "oklch(0.48 0.1 150)" } as CSSProperties,
header: { margin: "0 8cqw", padding: "8cqw 0 3.6cqw", borderBottom: "0.25cqw solid oklch(0.48 0.1 150)" },
align: "flex-start",
title: { fontFamily: fonts.serif, fontSize: "6.6cqw", fontWeight: 500, letterSpacing: "-.015em" },
subtitleColor: "oklch(0.48 0.1 150)",
heading: {
fontFamily: fonts.sans,
fontSize: "1.6cqw",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: ".14em",
},
columns: "minmax(0,1fr)",
main: { padding: "4.4cqw 8cqw 3.6cqw" },
side: { order: 2, padding: "0 8cqw 6cqw", gridTemplateColumns: "minmax(0,1fr) minmax(0,1fr)" },
},
{
name: msg`Sidebar`,
root: { ...bodyText(fonts.sans), "--accent-ink": "oklch(0.45 0.1 250)" } as CSSProperties,
header: { padding: "7.5cqw 7cqw 5cqw", background: "oklch(0.965 0.012 250)" },
align: "flex-start",
title: { fontFamily: fonts.sans, fontSize: "6cqw", fontWeight: 700, letterSpacing: "-.03em" },
subtitleColor: "oklch(0.45 0.1 250)",
heading: {
fontFamily: fonts.sans,
fontSize: "2.1cqw",
fontWeight: 700,
borderBottom: "0.2cqw solid oklch(0.45 0.1 250 / .25)",
paddingBottom: "0.8cqw",
},
columns: "minmax(0,34fr) minmax(0,66fr)",
main: { padding: "4.4cqw 7cqw 4cqw 5cqw" },
side: {
order: 0,
padding: "1cqw 4.5cqw 6cqw 7cqw",
background: "oklch(0.965 0.012 250)",
gridTemplateColumns: "minmax(0,1fr)",
},
},
{
name: msg`Banner`,
root: { ...bodyText(fonts.sans), "--accent-ink": "oklch(0.5 0.12 40)" } as CSSProperties,
header: { padding: "8cqw 8cqw 6cqw", background: "oklch(0.5 0.12 40)", color: "#fff", textAlign: "center" },
align: "center",
title: {
fontFamily: fonts.serif,
fontSize: "7cqw",
fontWeight: 400,
fontStyle: "italic",
letterSpacing: "-.015em",
},
subtitleColor: "oklch(0.95 0.03 60)",
heading: { fontFamily: fonts.serif, fontSize: "2.7cqw", fontWeight: 500, fontStyle: "italic" },
columns: "minmax(0,68fr) minmax(0,32fr)",
main: { padding: "5cqw 4cqw 4cqw 7cqw" },
side: {
order: 2,
padding: "5cqw 6cqw 6cqw 4cqw",
borderInlineStart: "0.15cqw solid oklch(0.9 0.006 95)",
gridTemplateColumns: "minmax(0,1fr)",
},
},
{
name: msg`Mono`,
root: { ...bodyText(fonts.mono, "1.42cqw"), "--accent-ink": "oklch(0.25 0.01 95)" } as CSSProperties,
header: { margin: "0 8cqw", padding: "8cqw 0 4cqw", borderBottom: "0.2cqw dashed oklch(0.3 0.01 95)" },
align: "flex-start",
title: {
fontFamily: fonts.mono,
fontSize: "4.6cqw",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: ".02em",
},
subtitleColor: "oklch(0.45 0.01 95)",
heading: {
fontFamily: fonts.mono,
fontSize: "1.6cqw",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: ".12em",
},
columns: "minmax(0,1fr)",
main: { padding: "4.4cqw 8cqw 3.6cqw" },
side: { order: 2, padding: "0 8cqw 6cqw", gridTemplateColumns: "minmax(0,1fr) minmax(0,1fr)" },
},
{
name: msg`Editorial`,
root: { ...bodyText(fonts.serif, "1.95cqw"), "--accent-ink": "oklch(0.22 0.01 95)" } as CSSProperties,
header: { margin: "0 7cqw", padding: "7cqw 0 3cqw", borderBottom: "0.6cqw solid oklch(0.2 0.01 95)" },
align: "flex-start",
title: {
fontFamily: fonts.display,
fontSize: "10.5cqw",
fontWeight: 800,
fontStretch: "62%",
textTransform: "uppercase",
letterSpacing: "-.02em",
},
subtitleColor: "oklch(0.48 0.1 150)",
heading: {
fontFamily: fonts.display,
fontSize: "2.4cqw",
fontWeight: 800,
fontStretch: "80%",
textTransform: "uppercase",
},
columns: "minmax(0,64fr) minmax(0,36fr)",
main: { padding: "4.4cqw 4cqw 4cqw 7cqw" },
side: { order: 2, padding: "4.4cqw 7cqw 6cqw 2cqw", gridTemplateColumns: "minmax(0,1fr)" },
},
];
type KeywordProps = { id: "a" | "b" | "c" | "d"; children: ReactNode };
/** A phrase Tailor matches against the job posting. The scene draws a thread to it by `data-keyword`. */
function Keyword({ id, children }: KeywordProps) {
return (
<span data-keyword={id} className="sheet-keyword" style={{ "--k": `var(--kw${id}, 0)` } as CSSProperties}>
{children}
</span>
);
}
export function ProfileSummary() {
return (
<Trans>
Product designer with nine years of experience building <Keyword id="a">design systems</Keyword> and research-led
products for B2B teams.
</Trans>
);
}
export function ResearchBullet() {
return (
<Trans>
Led <Keyword id="b">user research</Keyword> for the onboarding redesign; activation rose from 31% to 44%.
</Trans>
);
}
export function AccessibilityBullet() {
return (
<Trans>
Shipped <Keyword id="c">accessible</Keyword> checkout flows for web and iOS, meeting WCAG 2.1 AA.
</Trans>
);
}
/** The rewritten line the assistant suggests in Write, and the line the page carries everywhere else. */
export const designSystemBullet = () =>
t`Built a shared design system used by six product teams, cutting UI review time by 40%.`;
type HeadingProps = {
template: SheetTemplate;
children: ReactNode /** Where in Check's scan it gets ticked. */;
tick: number;
};
function Heading({ template, children, tick }: HeadingProps) {
return (
<h3
className="mb-[1.2cqw] flex items-center justify-between text-(--accent-ink) leading-[1.3]"
style={template.heading}
>
{children}
<Icon
name="check_circle"
filled
className="text-[oklch(0.5_0.12_150)] opacity-[clamp(0,calc((var(--chk,0)-var(--tick))*12),1)]"
style={{ fontSize: "2.5cqw", "--tick": tick } as CSSProperties}
/>
</h3>
);
}
type SheetProps = { template?: SheetTemplate };
/** Alex Morgan's resume, as a page. Decorative: the scenes' own copy says what it shows. */
export function Sheet({ template = sheetTemplates[0] as SheetTemplate }: SheetProps) {
return (
<div
aria-hidden="true"
className="@container absolute inset-0 select-none overflow-hidden rounded-[2px] bg-paper text-start"
>
<div className="absolute inset-0 flex flex-col text-[oklch(0.3_0.01_95)] leading-normal" style={template.root}>
<header
className="flex flex-col gap-[.7cqw] text-[oklch(0.22_0.01_95)]"
style={{ alignItems: template.align, ...template.header }}
>
<span className="leading-none" style={template.title}>
{t`Alex Morgan`}
</span>
<span className="font-medium text-[2.3cqw]" style={{ color: template.subtitleColor }}>
{t`Senior Product Designer`}
</span>
<span
className="flex flex-wrap gap-x-[3cqw] gap-y-[.4cqw] text-[1.6cqw] opacity-78"
style={{ justifyContent: template.align }}
>
<span>alex@morgan.design</span>
<span>{t`Lisbon, Portugal`}</span>
<span>morgan.design</span>
</span>
</header>
<div className="grid min-h-0 flex-1" style={{ gridTemplateColumns: template.columns }}>
<div className="order-1 flex min-w-0 flex-col gap-[3.2cqw]" style={template.main}>
<section>
<Heading template={template} tick={0.1}>
{t`Profile`}
</Heading>
<p>
<ProfileSummary />
</p>
</section>
<section>
<Heading template={template} tick={0.28}>
{t`Experience`}
</Heading>
<div className="flex flex-col gap-[1.8cqw]">
<div>
<div className="flex justify-between gap-[2cqw]">
<b className="font-semibold">{t`Lead Product Designer`}</b>
<span className="whitespace-nowrap opacity-70">{t`2021 – Present`}</span>
</div>
<div className="opacity-78">{t`Northwind Labs · Lisbon`}</div>
<ul className="mt-[.6cqw] flex list-disc flex-col gap-[.4cqw] ps-[2.2cqw]">
<li>{designSystemBullet()}</li>
<li>
<ResearchBullet />
</li>
</ul>
</div>
<div>
<div className="flex justify-between gap-[2cqw]">
<b className="font-semibold">{t`Product Designer`}</b>
<span className="grid justify-items-end whitespace-nowrap">
<span className="col-start-1 row-start-1 opacity-[calc(.7*(1-var(--dw,0)))]">{t`2017 – 2021`}</span>
<span className="col-start-1 row-start-1 -mx-[.5cqw] rounded-[.4cqw] bg-[oklch(0.92_0.09_85)] px-[.5cqw] text-[oklch(0.4_0.09_70)] opacity-[var(--dw,0)] shadow-[0_0_0_.3cqw_oklch(0.75_0.13_80)]">
{t`06/2017 – 2021`}
</span>
</span>
</div>
<div className="opacity-78">{t`Parcel & Co. · Porto`}</div>
<ul className="mt-[.6cqw] list-disc ps-[2.2cqw]">
<li>
<AccessibilityBullet />
</li>
</ul>
</div>
</div>
</section>
<section>
<Heading template={template} tick={0.5}>
{t`Education`}
</Heading>
<div className="flex justify-between gap-[2cqw]">
<b className="font-semibold">{t`BA, Communication Design`}</b>
<span className="whitespace-nowrap opacity-70">{t`2013 – 2017`}</span>
</div>
<div className="opacity-78">{t`University of Porto`}</div>
</section>
</div>
<div className="grid min-w-0 content-start gap-[3.2cqw]" style={template.side}>
<section>
<Heading template={template} tick={0.66}>
{t`Skills`}
</Heading>
<div className="flex flex-col gap-[.3cqw]">
<span>{t`Design systems`}</span>
<span>{t`User research`}</span>
<span>{t`Accessibility`}</span>
<span>{t`Prototyping`}</span>
<span>{t`Figma, HTML and CSS`}</span>
<span className="block max-h-[calc(var(--kwd,0)*3em)] overflow-hidden opacity-[var(--kwd,0)]">
<Keyword id="d">{t`Cross-functional leadership`}</Keyword>
</span>
</div>
</section>
<section>
<Heading template={template} tick={0.8}>
{t`Languages`}
</Heading>
<div className="flex flex-col gap-[.3cqw]">
<span>{t`English (native)`}</span>
<span>{t`Portuguese (native)`}</span>
<span>{t`Spanish (B2)`}</span>
</div>
</section>
</div>
</div>
</div>
</div>
);
}
@@ -1,212 +0,0 @@
import type { Icon } from "@phosphor-icons/react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArrowUpRightIcon,
DiscordLogoIcon,
GithubLogoIcon,
LinkedinLogoIcon,
RedditLogoIcon,
XLogoIcon,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { m } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { wrap } from "./classes";
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
const licenseUrl = `${githubUrl}/blob/main/LICENSE`;
type FooterLink = { label: string } & (
| { external: string }
| { anchor: string }
| { to: "/dashboard" | "/ats-checker" }
);
type FooterColumn = { title: string; links: FooterLink[] };
type SocialLink = { url: string; label: string; icon: Icon };
const getColumns = (): FooterColumn[] => [
{
title: t`Product`,
links: [
{ anchor: "#templates", label: t`Templates` },
{ anchor: "#features", label: t`Features` },
{ to: "/ats-checker", label: t`ATS Checker` },
{ to: "/dashboard", label: t`Get Started` },
],
},
{
title: t`Resources`,
links: [
{ external: "https://docs.rxresu.me", label: t`Documentation` },
{ external: "https://docs.rxresu.me/changelog", label: t`Changelog` },
{ external: githubUrl, label: t`Source Code` },
{ external: "https://opencollective.com/reactive-resume/donate", label: t`Sponsorships` },
],
},
{
title: t`Community`,
links: [
{ external: "https://discord.gg/aSyA5ZSxpb", label: t`Discord` },
{ external: "https://reddit.com/r/reactiveresume", label: t`Subreddit` },
{ external: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
{ external: `${githubUrl}/issues`, label: t`Report an issue` },
],
},
{
title: t`Legal`,
links: [
{ external: "https://docs.rxresu.me/legal/privacy-policy", label: t`Privacy Policy` },
{ external: licenseUrl, label: t`MIT License` },
],
},
];
const getSocialLinks = (): SocialLink[] => [
{ url: githubUrl, label: t`GitHub`, icon: GithubLogoIcon },
{ url: "https://x.com/KingOKings", label: t`X (Twitter)`, icon: XLogoIcon },
{ url: "https://linkedin.com/in/amruthpillai", label: t`LinkedIn`, icon: LinkedinLogoIcon },
{ url: "https://discord.gg/aSyA5ZSxpb", label: t`Discord`, icon: DiscordLogoIcon },
{ url: "https://reddit.com/r/reactiveresume", label: t`Subreddit`, icon: RedditLogoIcon },
];
const linkClass =
"group/link inline-flex min-h-9 items-center gap-1 text-(--home-muted) text-[14px] [transition:color_150ms_ease] hover:text-(--home-ink)";
const arrowClass =
"opacity-0 [transition:opacity_180ms_ease,transform_180ms_var(--ease-out-strong)] group-hover/link:transform-[translate(1px,-1px)] group-hover/link:opacity-60";
const socialClass =
"inline-grid size-10 place-items-center text-(--home-muted) [transition:color_160ms_ease] hover:text-(--home-ink)";
const metaLinkClass = "text-(--home-ink) underline-offset-[3px] hover:underline";
function FooterColumnLink({ link }: { link: FooterLink }) {
if ("external" in link) {
return (
<a href={link.external} target="_blank" rel="noopener noreferrer" className={linkClass}>
{link.label}
<span className="sr-only">
<Trans>(opens in new tab)</Trans>
</span>
<ArrowUpRightIcon size={13} aria-hidden="true" className={arrowClass} />
</a>
);
}
if ("anchor" in link) {
return (
<a href={link.anchor} className={linkClass}>
{link.label}
</a>
);
}
return (
<Link to={link.to} className={linkClass}>
{link.label}
</Link>
);
}
export function SiteFooter() {
return (
<footer id="footer" className="relative isolate z-1 overflow-clip border-(--home-line) border-t">
{/* Warm light pooling under the wordmark, echoing the parallax planes behind the page. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 -z-1 h-[78%] bg-[radial-gradient(125%_100%_at_50%_100%,rgb(196_166_140/13%)_0%,rgb(166_143_184/7%)_36%,transparent_70%)]"
/>
<div
className={cn(
wrap,
"grid grid-cols-[minmax(0,1.4fr)_repeat(4,minmax(0,1fr))] gap-x-8 gap-y-12 pt-[76px] max-[1100px]:grid-cols-[repeat(3,minmax(0,1fr))] max-[700px]:grid-cols-2 max-[700px]:gap-y-10 max-[540px]:pt-[52px]",
)}
>
<div className="max-[1100px]:col-span-full">
<Link to="/" className="inline-flex items-center gap-[11px] font-[550] text-[17px] tracking-[-0.04em]">
<img src="/icon/dark.svg" alt="" width="30" height="30" className="block" />
<span>Reactive Resume</span>
</Link>
<p className="mt-[18px] max-w-[300px] text-(--home-muted) text-[14px] leading-[1.7]">
<Trans>A free, open-source resume builder. Yours to keep, yours to export, yours to host.</Trans>
</p>
<ul className="mt-6 flex flex-wrap gap-1">
{getSocialLinks().map((social) => (
<li key={social.label}>
<a
href={social.url}
target="_blank"
rel="noopener noreferrer"
className={socialClass}
aria-label={`${social.label} (${t`opens in new tab`})`}
>
<social.icon aria-hidden="true" size={19} weight="fill" />
</a>
</li>
))}
</ul>
</div>
{getColumns().map((column) => (
<nav key={column.title} aria-label={column.title}>
<h2 className="font-[Manrope_Variable,sans-serif] font-semibold text-[15px] tracking-[-0.03em]">
{column.title}
</h2>
<ul className="mt-[18px] space-y-0.5">
{column.links.map((link) => (
<li key={link.label}>
<FooterColumnLink link={link} />
</li>
))}
</ul>
</nav>
))}
</div>
<div
className={cn(
wrap,
"mt-[68px] flex items-center justify-between gap-x-8 gap-y-3 border-(--home-line) border-t pt-6 text-[#87878d] text-[12px] max-[540px]:mt-12 max-[700px]:flex-col max-[700px]:items-start",
)}
>
<p>
<Trans>By the community, for the community.</Trans>
<br />
<Trans>
Released under the{" "}
<a href={licenseUrl} target="_blank" rel="noopener noreferrer" className={metaLinkClass}>
MIT License
</a>
.
</Trans>
</p>
<p>
<Trans>
A passion project by{" "}
<a href="https://amruthpillai.com" target="_blank" rel="noopener noreferrer" className={metaLinkClass}>
Amruth Pillai
</a>
</Trans>
<span aria-hidden="true" className="px-2 text-(--home-line)">
/
</span>
<bdi className="tabular-nums">v{__APP_VERSION__}</bdi>
</p>
</div>
{/* Oversized wordmark, cropped by the footer edge so it reads as a watermark rather than a heading. */}
<div className={cn(wrap, "@container mt-10 max-[540px]:mt-7")}>
<m.p
aria-hidden="true"
className="select-none bg-gradient-to-b from-(--home-ink) from-45% to-[rgb(241_240_235/7%)] bg-clip-text font-[Manrope_Variable,sans-serif] font-bold text-[14.4cqw] text-transparent leading-[0.78] tracking-[-0.07em]"
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.7, ease: EASE_OUT_STRONG }}
>
Reactive Resume
</m.p>
</div>
</footer>
);
}
-51
View File
@@ -1,51 +0,0 @@
@import "@fontsource-variable/manrope";
@media (prefers-reduced-motion: no-preference) {
html:has(.homepage) {
scroll-behavior: smooth;
}
}
/* Element defaults for every component on the page. In @layer base so element-level utilities still win. */
@layer base {
.homepage :where(button, a, input):focus-visible {
outline: 2px solid var(--home-ink);
outline-offset: 5px;
}
.homepage :where(button) {
cursor: pointer;
}
.homepage :where(button:disabled) {
cursor: default;
}
.homepage :where(a, button) {
-webkit-tap-highlight-color: transparent;
}
}
@keyframes home-feature-enter {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes home-word-arrive {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes home-data-flow {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
+177
View File
@@ -0,0 +1,177 @@
import type { CSSProperties, ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { cn } from "@reactive-resume/utils/style";
import { SCENE, useLanding } from "./scroll";
import { Sheet } from "./sheet";
import { Doodle, labelClass, SceneCaption } from "./ui";
/** Job match as each of the posting's four keywords is matched. */
const MATCH = [62, 70, 78, 85, 91];
const KEYWORDS = ["a", "b", "c", "d"] as const;
type PostingKeywordProps = { id: (typeof KEYWORDS)[number]; children: ReactNode };
/** A phrase in the posting, highlighted as its thread reaches the resume. */
function PostingKeyword({ id, children }: PostingKeywordProps) {
const step = KEYWORDS.indexOf(id) + 1;
return (
<mark data-posting-keyword={id} className="posting-keyword" style={{ "--k": `var(--t${step})` } as CSSProperties}>
{children}
</mark>
);
}
/**
* 04 Tailor. The verb is stitched together, then threads run from each keyword in a job posting to its match on the
* resume, the match score climbs, a cover letter slides out and the role moves from Saved to Applied.
*/
export function Tailor() {
const { i18n } = useLingui();
const matched = useLanding((state) => state.keywordCount);
const tailor = t`Tailor`;
const stages = [
{ label: t`Saved`, dot: "bg-stage-saved" },
{ label: t`Applied`, dot: "bg-stage-applied" },
{ label: t`Interview`, dot: "bg-stage-interview" },
{ label: t`Offer`, dot: "bg-stage-offer" },
];
return (
<section
id="tailor"
data-scene={SCENE.tailor}
data-pin
aria-labelledby="tailor-title"
className="relative h-[280vh] motion-reduce:h-svh min-[900px]:h-[330vh]"
>
<div className="sticky top-0 h-svh overflow-hidden [--ap:clamp(0,(var(--p)-.8)/.1,1)] [--cv:clamp(0,(var(--p)-.64)/.12,1)] [--cvx:-60%] [--kwa:var(--t1)] [--kwb:var(--t2)] [--kwc:var(--t3)] [--kwd:var(--t4)] [--pw:38vw] [--sw:clamp(0,var(--p)/.14,1)] [--t1:clamp(0,(var(--p)-.14)/.1,1)] [--t2:clamp(0,(var(--p)-.26)/.1,1)] [--t3:clamp(0,(var(--p)-.38)/.1,1)] [--t4:clamp(0,(var(--p)-.5)/.1,1)] min-[900px]:[--cvx:-40%] min-[900px]:[--pw:min(23vw,46vh)]">
<Doodle
name="scissors"
wipe="clamp(0, (var(--p) - .08) / .2, 1)"
className="start-[41vw] top-[74vh] w-[12vw] translate-y-[calc(var(--p)*-40px)] rotate-[12deg] max-[900px]:hidden"
/>
<h2
id="tailor-title"
className="absolute start-(--gutter) top-[10vh] whitespace-nowrap font-normal text-[14vw] text-ink leading-[.86] min-[900px]:top-[11vh] min-[900px]:text-[clamp(64px,8vw,150px)]"
>
<span className="block font-anybody font-light tracking-[-.03em] [clip-path:inset(0_-5%_50%_-5%)] [transform:translateX(calc(var(--dir)*(1-var(--sw))*-3vw))]">
{tailor}
<span className="text-accent">.</span>
</span>
<span
aria-hidden="true"
className="absolute start-0 top-0 block font-anybody font-light tracking-[-.03em] [clip-path:inset(50%_-5%_-10%_-5%)] [transform:translateX(calc(var(--dir)*(1-var(--sw))*3vw))]"
>
{tailor}
<span className="text-accent">.</span>
</span>
<span
aria-hidden="true"
className="absolute -inset-x-[1%] top-[calc(50%-1px)] h-0.5 bg-[repeating-linear-gradient(90deg,var(--accent)_0_10px,transparent_10px_17px)] [clip-path:inset(0_calc((1-var(--sw))*100%)_0_0)] rtl:[clip-path:inset(0_0_0_calc((1-var(--sw))*100%))]"
/>
</h2>
<SceneCaption
number="04"
title={tailor}
className="absolute end-(--gutter) top-[13vh] hidden w-[min(24em,30vw)] min-[900px]:flex min-[900px]:short:hidden"
>
{t`Paste a job posting once. It feeds a tailored copy of your resume, a match score, a cover letter draft and a place to track the role.`}
</SceneCaption>
<svg
data-threads
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-3 size-full overflow-visible"
>
{KEYWORDS.map((key, index) => (
<path
key={key}
data-thread={key}
d="M0 0"
pathLength={1}
fill="none"
stroke="oklch(0.55 0.12 150)"
strokeWidth={1.6}
strokeLinecap="round"
strokeDasharray={1}
style={{ strokeDashoffset: `calc(1 - var(--t${index + 1}))` }}
/>
))}
</svg>
<div className="absolute start-(--gutter) top-[calc(10vh+17vw)] z-2 flex w-[calc(100vw-2*var(--gutter))] flex-col gap-4 min-[900px]:top-[40vh] min-[900px]:w-[min(390px,30vw)]">
<div className="flex rotate-[-1deg] flex-col gap-3 rounded-[14px] border border-line bg-raised p-[18px] font-ui text-ink text-sm leading-[1.55] shadow-e3">
<div className="flex items-center gap-[11px]">
<span
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-[9px] bg-ink font-anybody font-medium font-stretch-[95%] text-[19px] text-bg leading-none"
>
F
</span>
<div className="flex flex-col">
<b className="font-semibold text-[15px]">{t`Senior Product Designer`}</b>
<span className="text-[12.5px] text-ink-3">{t`Fieldnote · Remote, EU`}</span>
</div>
<span className={cn(labelClass, "ms-auto text-[10px] text-ink-3 tracking-[.06em]")}>{t`Posting`}</span>
</div>
<p className="text-ink-2">
<Trans>
You’ll own our <PostingKeyword id="a">design systems</PostingKeyword>, run{" "}
<PostingKeyword id="b">user research</PostingKeyword> with customers and hold a high bar for{" "}
<PostingKeyword id="c">accessibility</PostingKeyword>, working in a{" "}
<PostingKeyword id="d">cross-functional</PostingKeyword> team.
</Trans>
</p>
<div className="flex items-baseline justify-between border-line border-t pt-3">
<span className={cn(labelClass, "text-[10.5px] text-ink-3")}>{t`Job match`}</span>
<span className="font-anybody font-light text-[40px] text-accent-text tabular-nums leading-none tracking-[-.03em]">
{i18n.number((MATCH[matched] ?? 0) / 100, { style: "percent" })}
</span>
</div>
</div>
<div className="hidden flex-col gap-2 rounded-[14px] border border-line-2 border-dashed px-3.5 py-3 min-[900px]:flex min-[900px]:short:hidden">
<div className="grid grid-cols-4 font-martian font-medium text-[10px] text-ink-3 uppercase leading-none tracking-[.06em]">
{stages.map((stage) => (
<span key={stage.label} className="flex min-w-0 items-center gap-[5px]">
<span aria-hidden="true" className={cn("size-[7px] shrink-0 rounded-full", stage.dot)} />
{stage.label}
</span>
))}
</div>
<div className="relative h-10">
<div className="absolute start-[calc(var(--ap)*25%)] top-0 flex h-10 w-[calc(25%-6px)] min-w-max flex-col justify-center overflow-hidden whitespace-nowrap rounded-[9px] border border-line bg-raised px-2 font-semibold font-ui text-[11.5px] text-ink leading-[1.2] shadow-e2 [transition:inset-inline-start_.5s_var(--ease)]">
{t`Fieldnote`}
<span className="font-normal text-[10.5px] text-ink-3">{t`Resume attached`}</span>
</div>
</div>
</div>
</div>
<div className="absolute end-[8vw] top-[78%] aspect-[612/792] w-(--pw) -translate-y-1/2 min-[900px]:top-[58%]">
<div
aria-hidden="true"
className="@container absolute inset-0 overflow-hidden rounded-[2px] bg-[#fbfaf6] shadow-paper [transform:translate(calc(var(--dir)*var(--cv)*var(--cvx)),calc(var(--cv)*8%))_rotate(calc(var(--dir)*var(--cv)*-8deg))] [transition:transform_.5s_var(--ease)]"
>
<div className="flex flex-col gap-[2.6cqw] p-[9cqw] font-display text-[2.2cqw] text-[oklch(0.3_0.01_95)] leading-normal">
<span className="font-martian font-medium text-[1.6cqw] text-[oklch(0.48_0.1_150)] uppercase tracking-[.08em]">
{t`Cover letter · Fieldnote`}
</span>
<span className="text-[3cqw]">{t`Dear Fieldnote team,`}</span>
{["92%", "86%", "90%", "62%"].map((width) => (
<span key={width} className="h-[1.2cqw] rounded-[1cqw] bg-[oklch(0.9_0.006_95)]" style={{ width }} />
))}
</div>
</div>
<div className="absolute inset-0 rounded-[2px] shadow-paper">
<Sheet />
</div>
</div>
</div>
</section>
);
}
@@ -1,111 +0,0 @@
import type { Template } from "@reactive-resume/schema/templates";
import type { CSSProperties } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpRightIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { templateSchema } from "@reactive-resume/schema/templates";
import { textLink } from "./classes";
type TemplateShelfProps = { template: Template; onChange: (template: Template) => void };
const templates = templateSchema.options;
const displayName = (name: string) => name[0].toUpperCase() + name.slice(1);
const roundLinkClass =
"inline-flex size-[46px] shrink-0 items-center justify-center rounded-full border border-[#444447] bg-transparent text-(--home-ink) [transition:background-color_150ms_ease,transform_150ms_var(--ease-out-strong)] hover:bg-[#2c2c2f] active:transform-[scale(0.97)] max-[540px]:size-11";
export function TemplateShelf({ template, onChange }: TemplateShelfProps) {
const index = templates.indexOf(template);
const [instant, setInstant] = useState(false);
const select = (next: Template, keyboard: boolean) => {
setInstant(keyboard);
onChange(next);
};
return (
<div
className="group/shelf [--home-paper-step:230px] [--home-paper-width:284px] max-[1100px]:[--home-paper-step:190px] max-[540px]:[--home-paper-step:140px] max-[540px]:[--home-paper-width:218px] max-[900px]:[--home-paper-step:178px] max-[900px]:[--home-paper-width:250px]"
data-instant={instant}
>
<fieldset
className="perspective-[1200px] relative isolate h-[465px] max-[540px]:h-[360px] max-[900px]:h-[420px]"
aria-label={t`Resume templates`}
>
{templates.map((item, itemIndex) => {
const offset = ((itemIndex - index + templates.length + 7) % templates.length) - 7;
return (
<button
key={item}
type="button"
className="transform-[translateX(calc(var(--position)_*_var(--home-paper-step)))_translateY(calc(var(--distance)_*_15px))_translateZ(calc(var(--distance)_*_-80px))_rotateY(calc(var(--position)_*_-12deg))_rotateZ(calc(var(--position)_*_2deg))] absolute top-5 left-[calc(50%_-_var(--home-paper-width)_/_2)] z-[calc(10_-_var(--distance))] aspect-[510/720] w-(--home-paper-width) rounded-[2px] border border-[#dedbd3] bg-[#eee] shadow-[0_25px_28px_#0004,0_2px_1px_#0006] brightness-[calc(1_-_var(--distance)_*_0.18)] [transition:transform_550ms_var(--ease-out-strong),filter_350ms_ease,opacity_220ms_ease,visibility_220ms] not-aria-pressed:hover:brightness-95 aria-hidden:invisible aria-hidden:opacity-0 group-data-[instant=true]/shelf:transition-none"
aria-label={t`Choose ${displayName(item)}`}
aria-pressed={item === template}
aria-hidden={Math.abs(offset) > 2}
tabIndex={Math.abs(offset) > 2 ? -1 : 0}
disabled={Math.abs(offset) > 2}
style={{ "--position": offset, "--distance": Math.abs(offset) } as CSSProperties}
onClick={(event) => select(item, event.detail === 0)}
>
<img
src={`/templates/jpg/${item}.jpg`}
alt=""
width="510"
height="720"
loading="lazy"
draggable={false}
className="pointer-events-none block size-full"
/>
</button>
);
})}
</fieldset>
<div className="flex items-center justify-center gap-[29px]">
<button
type="button"
className={roundLinkClass}
aria-label={t`Previous template`}
onClick={(event) => select(templates[(index - 1 + templates.length) % templates.length], event.detail === 0)}
>
<ArrowLeftIcon size={21} aria-hidden="true" />
</button>
<div className="grid w-[110px] justify-items-center gap-[2px]" aria-live="polite">
<strong className="font-medium text-[20px]">{displayName(template)}</strong>
<span className="text-(--home-muted) text-[12px] tabular-nums">
{index + 1} / {templates.length}
</span>
</div>
<button
type="button"
className={roundLinkClass}
aria-label={t`Next template`}
onClick={(event) => select(templates[(index + 1) % templates.length], event.detail === 0)}
>
<ArrowRightIcon size={21} aria-hidden="true" />
</button>
</div>
<fieldset
className="mx-auto mt-[35px] flex max-w-[860px] flex-wrap justify-center gap-x-[7px] gap-y-[2px] max-[540px]:mt-[26px] max-[540px]:gap-0"
aria-label={t`Choose a template`}
>
{templates.map((item) => (
<button
type="button"
key={item}
className="min-h-11 rounded-[4px] bg-transparent px-3 py-[7px] text-(--home-muted) text-[13px] [transition:color_150ms_ease,background-color_150ms_ease] hover:text-(--home-ink) aria-pressed:bg-[#313133] aria-pressed:text-(--home-ink) max-[540px]:px-2.5 max-[540px]:text-[12px]"
aria-pressed={item === template}
onClick={(event) => select(item, event.detail === 0)}
>
{displayName(item)}
</button>
))}
</fieldset>
<div className="mt-[30px] flex items-center justify-end gap-5 border-(--home-line) border-t pt-[35px] max-[540px]:pt-[25px]">
<a href={`/templates/pdf/${template}.pdf`} target="_blank" rel="noopener noreferrer" className={textLink}>
<Trans>Open template PDF</Trans>
<ArrowUpRightIcon size={16} aria-hidden="true" />
<span className="sr-only">
<Trans>Opens in a new tab</Trans>
</span>
</a>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import type { CSSProperties, ReactNode } from "react";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
/** A Martian Mono label: section kickers, scene numbers, small caps metadata. */
export const labelClass =
"font-martian font-medium text-[11px] uppercase leading-[1.4] tracking-[.08em] font-stretch-[87.5%]";
type CtaLinkProps = { size: "header" | "hero" | "closing"; className?: string };
/** "Build your resume": the page's one call to action, in the header, the hero and the closing section. */
export function CtaLink({ size, className }: CtaLinkProps) {
return (
<Link
to="/dashboard"
className={cn(
"inline-flex shrink-0 items-center rounded-full bg-accent font-semibold font-ui text-on-accent transition-colors hover:bg-accent-hover",
size === "header" && "h-[38px] px-4 text-sm",
size === "hero" && "h-[54px] gap-2.5 px-6 text-base shadow-e2",
size === "closing" && "h-14 gap-2.5 px-7 text-[17px] shadow-e2",
className,
)}
>
<Trans>Build your resume</Trans>
{size !== "header" && <Icon name="arrow_forward" size={20} />}
</Link>
);
}
type SceneCaptionProps = {
number: string;
title: string;
children: ReactNode;
/** A link under the sentence. */
action?: ReactNode;
className?: string;
};
/** A pinned scene's caption: "01 / Write" over a sentence or two in Newsreader. */
export function SceneCaption({ number, title, children, action, className }: SceneCaptionProps) {
return (
<div className={cn("flex flex-col gap-3", className)}>
<span className={cn(labelClass, "text-accent-text")}>
{number} / {title}
</span>
<p className="text-pretty font-display text-base text-ink leading-[1.45] min-[900px]:text-[clamp(17px,1.35vw,20px)]">
{children}
</p>
{action}
</div>
);
}
const doodleSizes = {
curve: [394, 312],
eraser: [346, 283],
globe: [301, 374],
jar: [264, 347],
magnifier: [403, 390],
paperclip: [335, 299],
pencil: [363, 378],
plane: [369, 344],
scissors: [403, 400],
} as const;
type DoodleProps = {
name: keyof typeof doodleSizes;
/** Mask wipe progress, usually derived from the section's --p. */
wipe: string;
className?: string;
style?: CSSProperties;
eager?: boolean;
};
/** A graphite doodle. Decorative, so it's hidden from assistive tech and never takes the pointer. */
export function Doodle({ name, wipe, className, style, eager = false }: DoodleProps) {
const [width, height] = doodleSizes[name];
return (
<img
src={`/doodles/${name}.webp`}
alt=""
aria-hidden="true"
width={width}
height={height}
loading={eager ? "eager" : "lazy"}
decoding="async"
draggable={false}
className={cn("doodle", className)}
style={{ "--wipe": wipe, ...style } as CSSProperties}
/>
);
}
/** Splits text into user-perceived characters, so accents and conjuncts type in as one. */
function useGraphemes(text: string) {
const { i18n } = useLingui();
const segmenter = new Intl.Segmenter(i18n.locale, { granularity: "grapheme" });
return Array.from(segmenter.segment(text), (part) => part.segment);
}
type TypedTextProps = {
text: string;
/** The CSS variable, from 0 to 1, that types the text in. */
progress: string;
className?: string;
charClassName?: string;
charStyle?: (index: number) => CSSProperties | undefined;
/** How sharply each character fades in; higher is more like a keystroke. */
rate?: number;
};
/**
* Text that types in as `progress` rises. The characters are decorative: the whole string is exposed once as
* visually hidden text, so screen readers and search engines read it as a sentence.
*/
export function TypedText({ text, progress, className, charClassName, charStyle, rate }: TypedTextProps) {
const characters = useGraphemes(text);
const style = { "--typed": `var(${progress})`, "--typed-rate": rate } as CSSProperties;
return (
<span className={className} style={style}>
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{characters.map((character, index) => (
<span
key={index}
className={cn("typed", charClassName)}
style={{ "--f": (index / characters.length).toFixed(4), ...charStyle?.(index) } as CSSProperties}
>
{character}
</span>
))}
</span>
</span>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { useEffect, useRef } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { goToScene, SCENE, useLanding } from "./scroll";
import { AccessibilityBullet, designSystemBullet, ResearchBullet } from "./sheet";
import { Doodle, SceneCaption, TypedText } from "./ui";
/** Scene progress for the accepted suggestion (Accept) and for the open comment (Keep mine, Undo). */
const ACCEPTED_AT = 0.93;
const COMMENT_AT = 0.42;
/**
* 01 Write. A line types itself on a sheet of paper, the assistant suggests a sharper one in the margin, and the
* suggestion is struck through, typed in and accepted. Accept and Keep mine scrub the scene to either outcome.
*/
export function Write() {
const { i18n } = useLingui();
const step = useLanding((state) => state.writeStep);
const active = useLanding((state) => state.activeScene === SCENE.write);
const version = i18n.number(14);
const acceptRef = useRef<HTMLButtonElement>(null);
const undoRef = useRef<HTMLButtonElement>(null);
// The control a keyboard user pressed goes inert mid-scroll, so focus follows to its counterpart on arrival.
const pendingFocus = useRef<"accept" | "undo" | null>(null);
useEffect(() => {
if (pendingFocus.current === "undo" && step === 2) undoRef.current?.focus({ preventScroll: true });
else if (pendingFocus.current === "accept" && step === 1) acceptRef.current?.focus({ preventScroll: true });
else return;
pendingFocus.current = null;
}, [step]);
const accept = () => {
pendingFocus.current = "undo";
goToScene(SCENE.write, ACCEPTED_AT);
};
const reopen = (focusAccept: boolean) => {
pendingFocus.current = focusAccept ? "accept" : null;
goToScene(SCENE.write, COMMENT_AT);
};
return (
<section
id="write"
data-scene={SCENE.write}
data-pin
aria-labelledby="write-title"
className="relative h-[280vh] motion-reduce:h-svh min-[900px]:h-[330vh]"
>
<div className="sticky top-0 h-svh overflow-hidden [--cl:clamp(0,(var(--p)-.8)/.08,1)] [--cm:clamp(0,(var(--p)-.36)/.08,1)] [--in:clamp(0,var(--p)/.12,1)] [--st:clamp(0,(var(--p)-.48)/.08,1)] [--ta:clamp(0,(var(--p)-.12)/.22,1)] [--ti:clamp(0,(var(--p)-.56)/.2,1)]">
<Doodle
name="eraser"
wipe="clamp(0, (var(--p) - .02) / .2, 1)"
className="start-[7vw] top-[36vh] w-[10vw] translate-y-[calc(var(--p)*-50px)] rotate-[-10deg] max-[900px]:hidden"
/>
<h2
id="write-title"
className="absolute start-(--gutter) top-[10vh] whitespace-nowrap font-anybody font-light text-[16vw] text-ink leading-[.9] tracking-[-.03em] [font-stretch:calc(86%+var(--in)*14%)] [transition:font-stretch_.3s] min-[900px]:top-[11vh] min-[900px]:text-[clamp(72px,9vw,160px)]"
>
{t`Write`}
<span className="text-accent">.</span>
</h2>
<SceneCaption
number="01"
title={t`Write`}
className="absolute inset-x-(--gutter) top-[calc(10vh+17vw)] opacity-(--in) min-[900px]:end-auto min-[900px]:top-auto min-[900px]:bottom-[clamp(20px,5vh,44px)] min-[900px]:max-w-[min(30em,34vw)]"
>
{t`Write in one editor beside a live page. It saves as you type and keeps every version. Ask the assistant for a sharper line: it suggests, you decide.`}
</SceneCaption>
<div className="absolute start-[5vw] top-[calc(10vh+17vw+150px)] h-[120vh] w-[90vw] origin-top-left rounded-[3px] bg-paper p-[6vw] font-ui text-[14px] text-[oklch(0.28_0.01_95)] leading-[1.55] shadow-paper [transform:translateY(calc((1-var(--in))*14vh))_rotate(-1.2deg)_scale(calc(.94+var(--in)*.06))] [transition:transform_.5s_var(--ease)] min-[900px]:start-[42vw] min-[900px]:top-[14vh] min-[900px]:w-[60vw] min-[900px]:px-[5vw] min-[900px]:py-[4.5vw] min-[900px]:text-[clamp(14px,1.3vw,20px)] rtl:origin-top-right">
<div className="absolute end-[2vw] top-[1.6vw] flex h-7 items-center gap-1.5 rounded-full bg-[oklch(0.95_0.006_95)] ps-2 pe-[11px] font-medium text-[12px] text-[oklch(0.42_0.01_95)] opacity-(--cl)">
<Icon name="cloud_done" size={16} className="text-[oklch(0.42_0.1_150)]" />
{t`Saved · version ${version}`}
</div>
<div className="grid grid-cols-[minmax(0,1fr)] items-start gap-x-[3vw] gap-y-[.5em] min-[900px]:grid-cols-[minmax(0,1fr)_minmax(0,max(15vw,210px))]">
<div className="col-start-1 flex flex-col gap-[.25em]">
<span className="mb-[.8em] border-[oklch(0.45_0.1_150/.35)] border-b pb-[.6em] font-martian text-[.7em] text-[oklch(0.45_0.1_150)] uppercase leading-[1.4] tracking-[.14em]">
{t`Experience`}
</span>
<div className="flex items-baseline justify-between gap-[1em]">
<b className="font-display font-medium text-[1.55em] leading-[1.1]">{t`Lead Product Designer`}</b>
<span className="whitespace-nowrap font-martian text-[.72em] text-[oklch(0.5_0.01_95)] uppercase">
{t`2021 – Present`}
</span>
</div>
<span className="text-[oklch(0.45_0.01_95)]">{t`Northwind Labs · Lisbon`}</span>
</div>
{/* Each line draws its own bullet, so the rewrite keeps one when the original collapses away. */}
<ul className="col-start-1 mt-[.3em]">
<li>
<span className="relative block max-h-[calc((1-var(--cl))*8em)] overflow-hidden ps-[1.1em] opacity-[calc(1-var(--cl))] before:absolute before:start-[.25em] before:content-['•']">
<TypedText
text={t`Responsible for the design system and helping other teams with their designs.`}
progress="--ta"
className="bg-[linear-gradient(oklch(0.55_0.17_27),oklch(0.55_0.17_27))] bg-no-repeat text-[color-mix(in_oklch,oklch(0.28_0.01_95),oklch(0.55_0.15_27)_calc(var(--st)*70%))] [background-position:0_58%] [background-size:calc(var(--st)*100%)_2px] [box-decoration-break:clone]"
/>
</span>
<span className="relative block max-h-[calc(clamp(0,var(--ti)*40,1)*8em)] overflow-hidden ps-[1.1em] before:absolute before:start-[.25em] before:opacity-(--cl) before:content-['•']">
<TypedText text={designSystemBullet()} progress="--ti" charClassName="typed-suggestion" />
</span>
</li>
</ul>
<div
className={cn(
"relative col-start-1 grid min-w-0 font-ui text-[13px] text-[oklch(0.25_0.01_95)] leading-[1.45] opacity-(--cm) [transform:translateX(calc(var(--dir)*(1-var(--cm))*30px))] [transition:transform_.4s_var(--ease)] min-[900px]:col-start-2 min-[900px]:row-start-2",
!active && "pointer-events-none",
)}
>
<span
aria-hidden="true"
className="absolute -start-[3vw] top-[22px] hidden w-[3vw] border-[oklch(0.5_0.1_150/.6)] border-t-[1.5px] border-dashed min-[900px]:block"
/>
<div
inert={step !== 1}
className="col-start-1 row-start-1 flex flex-col gap-[9px] rounded-xl border border-[oklch(0.9_0.006_95)] bg-white px-[13px] py-3 opacity-[calc(1-var(--cl))] shadow-[0_12px_30px_-12px_oklch(0.2_0.01_95/.3)]"
>
<div className="flex items-center gap-2">
<span className="flex size-[22px] items-center justify-center rounded-full bg-[oklch(0.94_0.035_150)] text-[oklch(0.42_0.1_150)]">
<Icon name="auto_awesome" size={15} />
</span>
<b className="font-semibold">{t`Assistant`}</b>
</div>
<span>{t`Lead with the result and give it a scale.`}</span>
<div className="flex flex-wrap gap-1.5">
<button
ref={acceptRef}
type="button"
onClick={accept}
className="h-[30px] rounded-md bg-[oklch(0.5_0.1_150)] px-3 font-semibold text-[12.5px] text-white transition-colors hover:bg-[oklch(0.44_0.1_150)]"
>
{t`Accept`}
</button>
<button
type="button"
onClick={() => reopen(false)}
className="h-[30px] rounded-md border border-[oklch(0.82_0.008_95)] bg-white px-[11px] font-medium text-[12.5px] text-[oklch(0.25_0.01_95)] transition-colors hover:bg-[oklch(0.96_0.005_95)]"
>
{t`Keep mine`}
</button>
</div>
</div>
<div
inert={step !== 2}
className="col-start-1 row-start-1 flex items-center gap-2 self-start rounded-xl bg-[oklch(0.95_0.03_150)] px-[13px] py-2.5 font-medium text-[oklch(0.38_0.1_150)] opacity-(--cl)"
>
<Icon name="check" size={17} />
{t`Accepted`}
{/* Undo scrubs the scene back, which reduced motion doesn't do, so it's left out there. */}
<button
ref={undoRef}
type="button"
onClick={() => reopen(true)}
className="ms-auto flex h-7 items-center gap-[3px] rounded-sm px-2 font-medium text-[12.5px] text-[oklch(0.3_0.01_95)] transition-colors hover:bg-[oklch(0.9_0.04_150)] motion-reduce:hidden"
>
<Icon name="undo" size={16} />
{t`Undo`}
</button>
</div>
</div>
<ul className="col-start-1 list-disc ps-[1.1em]">
<li>
<ResearchBullet />
</li>
</ul>
<div className="col-start-1 mt-[1.2em] flex flex-col gap-[.25em]">
<div className="flex items-baseline justify-between gap-[1em]">
<b className="font-display font-medium text-[1.55em] leading-[1.1]">{t`Product Designer`}</b>
<span className="whitespace-nowrap font-martian text-[.72em] text-[oklch(0.5_0.01_95)]">{t`2017 – 2021`}</span>
</div>
<span className="text-[oklch(0.45_0.01_95)]">{t`Parcel & Co. · Porto`}</span>
<ul className="mt-[.3em] list-disc ps-[1.1em]">
<li>
<AccessibilityBullet />
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
);
}

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