feat: initial commit for Kerf Studio with IRANSans typography, Persian localization, and RTL support

This commit is contained in:
s.esmaeili
2026-10-04 07:23:11 +03:30
commit 8511fd0e89
59 changed files with 4754 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Vite / Build outputs
.artifact
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kerf Studio — استودیو طراحی و مهندسی محصول کِرف</title>
<meta name="description" content="طراحی و مهندسی محصولات نرم‌افزاری اختصاصی و با کارایی بالا در حوزه‌های مالی، لجستیک بنادر، حقوقی و پزشکی بالینی." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2812
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "kerf-studio",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
import "./styles.css";
import { LanguageProvider, useLang } from "./LanguageContext";
import Hero from "./components/Hero";
import Product from "./components/Product";
import Method from "./components/Method";
import Proof from "./components/Proof";
import Stack from "./components/Stack";
import Studio from "./components/Studio";
import Contact from "./components/Contact";
import Vernier from "./components/Vernier";
function AppMain() {
const { strings, isRtl } = useLang();
return (
<div dir={isRtl ? "rtl" : "ltr"} className={isRtl ? "font-sans" : ""}>
<a
href="#p-ledger"
className={`sr-only focus:not-sr-only focus:fixed ${
isRtl ? "focus:right-4" : "focus:left-4"
} focus:top-4 focus:z-[60] focus:rounded-full focus:bg-[var(--ink)] focus:px-4 focus:py-2 focus:text-[var(--paper)]`}
>
{strings.skipToProducts}
</a>
<main>
<Hero />
<div id="objects">
{strings.products.map((p, i) => (
<Product key={p.key} p={p} index={i} total={strings.products.length} />
))}
</div>
<Method />
<Proof />
<Stack />
<Studio />
<Contact />
</main>
<Vernier />
</div>
);
}
export default function App() {
return (
<LanguageProvider>
<AppMain />
</LanguageProvider>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React, { createContext, useContext, useState, useEffect } from "react";
import { t } from "./translations";
const LanguageContext = createContext();
export function LanguageProvider({ children }) {
const [lang, setLang] = useState(() => {
try {
return localStorage.getItem("kerf_lang") || "fa";
} catch {
return "fa";
}
});
useEffect(() => {
try {
localStorage.setItem("kerf_lang", lang);
} catch {}
document.documentElement.lang = lang;
document.documentElement.dir = lang === "fa" ? "rtl" : "ltr";
}, [lang]);
const toggleLang = () => setLang((prev) => (prev === "fa" ? "en" : "fa"));
const strings = t[lang] || t.fa;
return (
<LanguageContext.Provider value={{ lang, setLang, toggleLang, strings, isRtl: lang === "fa" }}>
{children}
</LanguageContext.Provider>
);
}
export const useLang = () => useContext(LanguageContext);
+35
View File
@@ -0,0 +1,35 @@
import Mark from "./Mark";
import { useLang } from "../LanguageContext";
export default function Contact() {
const { strings } = useLang();
return (
<section id="contact" aria-label="Contact" className="relative flex min-h-[100svh] flex-col justify-between bg-[var(--blaze)] px-5 pb-32 pt-24 text-[var(--ink)] sm:px-10 lg:px-16 lg:pb-28">
<p className="mono text-[14px] font-medium">{strings.contactTag}</p>
<div>
<h2 className="display text-[clamp(44px,9.5vw,180px)] whitespace-pre-line leading-[1.1]">
{strings.contactTitle}
</h2>
<div className="mt-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<a
href={`mailto:${strings.contactEmail}?subject=${strings.contactSubjectHard}`}
className="inline-flex h-14 items-center justify-center rounded-full bg-[var(--ink)] px-8 text-[16px] font-semibold text-[var(--paper)] transition-transform duration-300 hover:-translate-y-0.5 dir-ltr"
>
{strings.contactEmail}
</a>
<a
href={`mailto:${strings.contactEmail}?subject=${strings.contactSubjectCall}`}
className="inline-flex h-14 items-center justify-center rounded-full border border-[var(--ink)] px-8 text-[16px] font-medium transition-colors duration-300 hover:bg-[var(--ink)] hover:text-[var(--blaze)]"
>
{strings.contactCallBtn}
</a>
</div>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 border-t border-[rgba(18,20,19,.3)] pt-5">
<Mark size={20} notch="var(--paper)" />
<p className="mono text-[13px]">{strings.contactFooter}</p>
</div>
</section>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useRef } from "react";
import { useFrame, pinProgress, ease, clamp, reducedMotion } from "../scroll";
import Scaled from "./Scaled";
import { LedgerUI, TideUI, QuireUI, HaldenUI } from "./ProductUIs";
import Mark from "./Mark";
import { useLang } from "../LanguageContext";
const UI_MAP = {
ledger: LedgerUI,
tide: TideUI,
quire: QuireUI,
halden: HaldenUI,
};
export default function Hero() {
const { strings, lang, toggleLang } = useLang();
const sec = useRef(null), top = useRef(null), bot = useRef(null), inner = useRef(null), read = useRef(null), hint = useRef(null);
useFrame(() => {
if (!sec.current) return;
const p = reducedMotion() ? 0 : pinProgress(sec.current);
const e = ease(clamp(p / 0.72));
top.current.style.transform = `translate3d(0, ${-e * 100}%, 0)`;
bot.current.style.transform = `translate3d(0, ${e * 100}%, 0)`;
const r = clamp((p - 0.12) / 0.7);
inner.current.style.transform = `scale(${0.86 + 0.14 * ease(r)})`;
inner.current.style.opacity = String(clamp(r * 1.6));
read.current.textContent = (e * window.innerHeight * 0.2645).toFixed(1).padStart(5, "0");
hint.current.style.opacity = String(1 - clamp(p * 8));
});
return (
<section id="cut" ref={sec} aria-label="Kerf, product studio" className="relative h-[280vh] motion-reduce:h-auto">
<div className="sticky top-0 h-[100svh] overflow-hidden bg-[var(--chamber)] motion-reduce:relative motion-reduce:h-auto">
{/* the chamber: what is inside the cut */}
<div ref={inner} className="absolute inset-0 flex flex-col justify-center px-5 py-24 opacity-0 will-change-transform sm:px-10 lg:px-16 motion-reduce:relative motion-reduce:opacity-100">
<div className="mb-8 flex flex-wrap items-end justify-between gap-4 text-[var(--paper)] lg:mb-12">
<h2 className="display max-w-[15ch] text-[clamp(32px,5.2vw,80px)]">
{strings.insideChamberTitle}
</h2>
<p className="label max-w-[34ch] text-[var(--fog)] normal-case tracking-normal">
{strings.insideChamberDesc}
</p>
</div>
<ul className="grid grid-cols-2 gap-x-4 gap-y-6 lg:grid-cols-4 lg:gap-6">
{strings.minis.map((mini) => {
const UIComponent = UI_MAP[mini.key] || LedgerUI;
return (
<li key={mini.key}>
<a href={`#p-${mini.key}`} className="group block" aria-label={`${mini.name}, ${mini.field}`}>
<Scaled className="w-full transition-transform duration-700 group-hover:-translate-y-1">
<UIComponent step={1} />
</Scaled>
<div className="mt-3 flex items-baseline justify-between text-[var(--paper)]">
<span className="text-[15px] font-semibold tracking-tight sm:text-[17px]">{mini.name}</span>
<span className="mono hidden text-[13px] text-[var(--fog)] sm:inline">{mini.field}</span>
</div>
</a>
</li>
);
})}
</ul>
</div>
{/* top half */}
<div ref={top} className="absolute inset-x-0 top-0 flex h-1/2 flex-col justify-between bg-[var(--alu)] px-5 pt-5 will-change-transform sm:px-10 lg:px-16 motion-reduce:relative motion-reduce:h-[50svh]">
<div className="flex items-start justify-between pt-1">
<div className="flex items-center gap-3">
<Mark />
<button
onClick={toggleLang}
title="تغییر زبان / Switch Language"
className="mono text-[11px] font-semibold px-2.5 py-1 rounded-full border border-[var(--steel)]/30 bg-white/40 hover:bg-white text-[var(--ink)] hover:text-[var(--blaze)] transition-all shadow-sm"
>
{lang === "fa" ? "EN" : "فا"}
</button>
</div>
<p className="mono fade-in text-end text-[13px] leading-snug text-[var(--steel)] whitespace-pre-line">
{strings.studioSubtitle}
</p>
</div>
<div className="overflow-hidden pb-[0.06em]">
<h1 className="display rise-up text-[13vw] sm:text-[8vw] min-[1800px]:text-[144px] whitespace-pre-line">
{strings.heroTitle1}
</h1>
</div>
<div className="absolute bottom-0 left-0 right-0 h-[2px] overflow-visible">
<div className="cut-line absolute inset-0 bg-[var(--blaze)]" />
</div>
</div>
{/* bottom half */}
<div ref={bot} className="absolute inset-x-0 bottom-0 flex h-1/2 flex-col justify-between bg-[var(--alu)] px-5 pb-24 will-change-transform sm:px-10 sm:pb-28 lg:px-16 motion-reduce:relative motion-reduce:h-[50svh]">
<div className="absolute left-0 right-0 top-0 h-[2px]">
<div className="cut-line absolute inset-0 bg-[var(--blaze)]" />
<span className="saw-head absolute -top-[5px] h-3 w-3 -translate-x-1/2 rounded-full bg-[var(--blaze)] shadow-[0_0_24px_6px_rgba(255,91,20,.55)]" aria-hidden />
</div>
<div className="overflow-hidden pb-[2vw] min-[1800px]:pb-9">
<p className="display rise-down text-[13vw] text-[var(--steel)] sm:text-[8vw] min-[1800px]:text-[144px] whitespace-pre-line">
{strings.heroTitle2}
</p>
</div>
<div className="fade-in flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[40ch] text-[16px] leading-relaxed sm:text-[18px]">
{strings.heroLead}
</p>
<div ref={hint} className="mono flex items-center gap-3 text-[13px] text-[var(--steel)]">
<span>{strings.kerfWidth}</span>
<span className="tabular-nums text-[var(--ink)] font-semibold"><span ref={read}>000.0</span> mm</span>
<span aria-hidden className="inline-block h-6 w-px animate-pulse bg-[var(--ink)]" />
<span>{strings.scrollToCut}</span>
</div>
</div>
</div>
</div>
</section>
);
}
+9
View File
@@ -0,0 +1,9 @@
// Wordmark: KERF with the saw's notch taken out of the F.
export default function Mark({ dark = false, size = 22, notch = "var(--blaze)" }) {
return (
<span className="inline-flex items-center font-semibold tracking-[-0.04em]" style={{ fontSize: size, color: dark ? "var(--paper)" : "var(--ink)" }} aria-label="Kerf">
<span aria-hidden>KER</span>
<span aria-hidden className="relative">F<span className="absolute -right-[0.32em] top-[0.42em] h-[0.12em] w-[0.42em]" style={{ background: notch }} /></span>
</span>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useRef } from "react";
import { useFrame, pinProgress, reducedMotion } from "../scroll";
import { useLang } from "../LanguageContext";
const Glyph = ({ i }) => (
<svg width="88" height="88" viewBox="0 0 88 88" aria-hidden className="text-[var(--ink)]">
<rect x="4" y="4" width="80" height="80" fill="none" stroke="currentColor" strokeOpacity=".18" />
{i >= 0 && <line x1="4" y1="44" x2="84" y2="44" stroke="var(--blaze)" strokeWidth="2" />}
{i >= 1 && <rect x="16" y="16" width="56" height="56" fill="none" stroke="currentColor" strokeDasharray="4 4" />}
{i >= 2 && <rect x="16" y="16" width="56" height="56" fill="currentColor" />}
{i >= 3 && <circle cx="72" cy="16" r="6" fill="var(--blaze)" />}
</svg>
);
export default function Method() {
const { strings, isRtl } = useLang();
const sec = useRef(null), track = useRef(null), meter = useRef(null);
useFrame(() => {
if (!sec.current || reducedMotion()) return;
const p = pinProgress(sec.current);
const max = track.current.scrollWidth - window.innerWidth;
const offset = isRtl ? p * Math.max(0, max) : -p * Math.max(0, max);
track.current.style.transform = `translate3d(${offset}px,0,0)`;
meter.current.style.transform = `scaleX(${p})`;
});
return (
<section id="method" ref={sec} aria-label="How we work" className="relative h-[360vh] bg-[var(--alu)] motion-reduce:h-auto">
<div className="sticky top-0 flex h-[100svh] flex-col justify-center overflow-hidden pb-16 motion-reduce:relative motion-reduce:h-auto motion-reduce:py-24">
<div className="mb-10 flex items-end justify-between gap-6 px-5 sm:px-10 lg:mb-14 lg:px-16">
<h2 className="display max-w-[14ch] text-[clamp(38px,5.6vw,96px)]">{strings.methodTitle}</h2>
<p className="mono hidden max-w-[32ch] text-[14px] leading-relaxed text-[var(--steel)] md:block">
{strings.methodSubtitle}
</p>
</div>
<div ref={track} className="flex w-max gap-4 px-5 will-change-transform sm:gap-6 sm:px-10 lg:px-16 motion-reduce:w-auto motion-reduce:flex-col">
{strings.methodItems.map((m, i) => (
<article key={m.v} className="flex h-[min(50svh,440px)] w-[min(82vw,520px)] shrink-0 flex-col justify-between bg-[var(--paper)] p-6 sm:p-9 motion-reduce:w-auto">
<div className="flex items-start justify-between">
<span className="mono text-[14px] text-[var(--steel)] tabular-nums">
{strings.methodStage.replace("{i}", i + 1).replace("{d}", m.d)}
</span>
<Glyph i={i} />
</div>
<div>
<h3 className="display text-[clamp(44px,5.5vw,84px)]">{m.v}</h3>
<p className="mt-4 max-w-[32ch] text-[17px] leading-relaxed">{m.t}</p>
<p className="mono mt-5 border-t border-[var(--line)] pt-4 text-[14px] text-[var(--steel)] font-medium">
{isRtl ? "← " : "→ "}
{m.out}
</p>
</div>
</article>
))}
<div className="flex w-[min(70vw,420px)] shrink-0 items-end pb-2 motion-reduce:hidden">
<p className="display text-[clamp(28px,3.2vw,48px)] text-[var(--steel)]">{strings.methodFooter}</p>
</div>
</div>
<div className="mx-5 mt-10 h-[2px] bg-[var(--line)] sm:mx-10 lg:mx-16">
<div ref={meter} className={`h-full ${isRtl ? "origin-right" : "origin-left"} bg-[var(--ink)]`} style={{ transform: "scaleX(0)" }} />
</div>
</div>
</section>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useRef } from "react";
import { usePinStep } from "../scroll";
import Scaled from "./Scaled";
import { LedgerUI, TideUI, QuireUI, HaldenUI } from "./ProductUIs";
import { useLang } from "../LanguageContext";
const UIS = { ledger: LedgerUI, tide: TideUI, quire: QuireUI, halden: HaldenUI };
export default function Product({ p, index, total }) {
const { strings, isRtl } = useLang();
const sec = useRef(null), bar = useRef(null);
const step = usePinStep(sec, 3, (v) => { if (bar.current) bar.current.style.transform = `scaleX(${v})`; });
const UI = UIS[p.key] || LedgerUI;
const states = strings.productStates;
return (
<section id={`p-${p.key}`} ref={sec} data-product={p.name} data-step={step} aria-label={`${p.name}, ${p.field}`} className="relative h-[330vh] motion-reduce:h-auto">
<div className="sticky top-0 flex h-[100svh] flex-col overflow-hidden bg-[var(--chamber)] text-[var(--paper)] motion-reduce:relative motion-reduce:h-auto">
{/* blueprint grid that fades as the object becomes real */}
<div aria-hidden className="pointer-events-none absolute inset-0 transition-opacity duration-1000" style={{ opacity: step === 0 ? 1 : 0.25, backgroundImage: "linear-gradient(rgba(255,91,20,.07) 1px, transparent 1px), linear-gradient(90deg, rgba(255,91,20,.07) 1px, transparent 1px)", backgroundSize: "40px 40px" }} />
<div className="relative grid flex-1 grid-rows-[auto_1fr] gap-6 px-5 pb-28 pt-20 sm:px-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,8fr)] lg:grid-rows-1 lg:items-center lg:gap-14 lg:px-16 lg:pb-24 lg:pt-24">
{/* text column */}
<div className="flex flex-col">
<div className="mono flex items-center gap-3 text-[14px] text-[var(--fog)]">
<span className="tabular-nums text-[var(--paper)]">{String(index + 1).padStart(2, "0")}<span className="text-[var(--fog)]">/{String(total).padStart(2, "0")}</span></span>
<span className="h-px w-6 bg-[rgba(169,174,169,.5)]" />
<span>{p.field}</span>
</div>
<h2 className="display mt-3 text-[clamp(40px,5vw,96px)] lg:mt-5">{p.name}</h2>
<p className="mono mt-3 text-[14px] text-[var(--fog)]">{p.owner}</p>
<ol className="mt-6 flex gap-5 lg:mt-12" aria-label="Product state">
{states.map((s, i) => (
<li key={s} className={`label relative pb-2 transition-colors duration-500 ${i === step ? "text-[var(--paper)] font-bold" : "text-[rgba(169,174,169,.6)]"}`} aria-current={i === step ? "step" : undefined}>
{s}
<span className={`absolute bottom-0 inset-x-0 h-[2px] w-full ${isRtl ? "origin-right" : "origin-left"} bg-[var(--blaze)] transition-transform duration-700 ${i <= step ? "scale-x-100" : "scale-x-0"}`} />
</li>
))}
</ol>
<div className="relative mt-5 min-h-[176px] lg:mt-8 lg:min-h-[220px]">
{p.steps.map((s, i) => (
<div key={i} className="swap absolute inset-0" data-on={String(i === step)} aria-hidden={i !== step}>
<p className="mono text-[14px] text-[var(--blaze)] font-semibold">{s.k}</p>
<p className="mt-2 max-w-[28ch] text-[clamp(18px,1.9vw,26px)] font-medium leading-[1.35] tracking-[-0.015em]">{s.t}</p>
{s.m && (
<p className="mt-4 flex items-baseline gap-3">
<span className="display text-[38px] text-[var(--blaze)] lg:text-[54px] tabular-nums">{s.m}</span>
<span className="mono max-w-[20ch] text-[13px] leading-snug text-[var(--fog)]">{s.ml}</span>
</p>
)}
</div>
))}
</div>
</div>
{/* the object */}
<div className="relative flex min-h-0 items-center justify-center">
<Scaled className="max-h-full w-full"><UI step={step} /></Scaled>
</div>
</div>
<div className="absolute inset-x-0 bottom-0 h-px bg-white/10">
<div ref={bar} className={`h-full ${isRtl ? "origin-right" : "origin-left"} bg-[var(--blaze)]`} style={{ transform: "scaleX(0)" }} />
</div>
</div>
</section>
);
}
+523
View File
@@ -0,0 +1,523 @@
import { useLang } from "../LanguageContext";
// Four product interfaces drawn at 720x460. Each moves through three states:
// 0 drawing (dashed blueprint + dimensions), 1 object (filled), 2 running (live + measured callout).
const Bar = ({ w, h = 6, c = "rgba(18,20,19,.14)", className = "", style }) => (
<div className={`rounded-full ${className}`} style={{ width: w, height: h, background: c, ...style }} />
);
const Win = ({ children, title, crumb, right }) => (
<div className="bp ui-window absolute inset-0 overflow-hidden" style={{ "--i": 0, borderRadius: 10 }}>
<div className="fill flex h-[38px] items-center justify-between border-b border-black/[.07] px-4 text-[12px]">
<div className="flex items-center gap-2">
<span className="font-semibold tracking-tight">{title}</span>
<span className="text-black/35">/</span>
<span className="text-black/55">{crumb}</span>
</div>
<div className="flex items-center gap-2 text-black/55">{right}</div>
</div>
{children}
</div>
);
const Dim = ({ children, style }) => <span className="dim" style={style}>{children}</span>;
const Callout = ({ children, style, side = "left" }) => (
<div className="callout absolute z-10 flex items-center gap-2" style={style}>
{side === "right" && <span className="h-px w-8 bg-[var(--blaze)]" />}
<div className="rounded-md bg-[var(--ink)] px-3 py-2 text-[12px] leading-tight text-white shadow-xl">{children}</div>
{side === "left" && <span className="h-px w-8 bg-[var(--blaze)]" />}
</div>
);
const Live = ({ label = "Live" }) => (
<span className="inline-flex items-center gap-1.5 font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--blaze)] animate-pulse" />
{label}
</span>
);
/* ---------------- Ledgerline : treasury ---------------- */
export function LedgerUI({ step }) {
const { isRtl } = useLang();
const pts = [62, 58, 64, 60, 70, 66, 74, 71, 80, 77, 86, 83, 92, 96];
const path = pts.map((v, i) => `${i ? "L" : "M"}${(i / (pts.length - 1)) * 420} ${140 - v * 1.25}`).join(" ");
const rows = isRtl
? [
["شرکت نورث‌ویند", "€ ۱,۲۰۴,۰۰۰.۰۰"],
["آکیرو کی‌کی", "¥ ۸۸,۴۰۰,۰۰۰"],
["هالورسن آ‌اس", "kr ۳,۹۰۰,۵۰۰"],
["اورتیز و واله", "$ ۶۴۰,۲۱۰.۰۰"],
["بانکا لومه", "€ ۹۲,۰۰۰.۰۰"],
]
: [
["Northwind GmbH", "€ 1,204,000.00"],
["Akiro KK", "¥ 88,400,000"],
["Halvorsen AS", "kr 3,900,500"],
["Ortiz & Vale", "$ 640,210.00"],
["Banca Lume", "€ 92,000.00"],
];
return (
<div className="ui absolute inset-0" data-step={step}>
<Win
title={isRtl ? "لجرلاین" : "Ledgerline"}
crumb={isRtl ? "وضعیت نقدینگی" : "Cash position"}
right={
<>
<span className="mono">EUR</span>
<span className="text-black/25">·</span>
<Live label={isRtl ? "زنده" : "Live"} />
</>
}
>
<div className={`absolute ${isRtl ? "right-4" : "left-4"} top-[54px] w-[440px]`}>
<div className="bp bp-sunk relative h-[100px] px-5 py-4" style={{ "--i": 1 }}>
<div className="fill text-[11px] uppercase tracking-[.08em] text-black/55 mono">
{isRtl ? "تراز تجمیعی · ۲۳ حساب · ۹ ارز بین‌الملل" : "Consolidated · 23 accounts · 9 currencies"}
</div>
<div className="fill mt-2 text-[38px] font-semibold tracking-[-0.04em] tabular-nums dir-ltr text-start">
€48,213,940<span className="text-black/35">.12</span>
</div>
<Dim style={{ [isRtl ? "left" : "right"]: 8, top: 6 }}>h 100</Dim>
</div>
<div className="bp relative mt-3 h-[280px] p-4" style={{ "--i": 2 }}>
<div className="fill flex justify-between text-[11px] text-black/55 mono">
<span>{isRtl ? "وضعیت ۹۰ روزه" : "90 DAY POSITION"}</span>
<span className="text-emerald-700 font-semibold dir-ltr">+12.8%</span>
</div>
<svg className="fill mt-3" width="408" height="196" viewBox="0 0 420 150" preserveAspectRatio="none">
{[0, 1, 2, 3].map((g) => (
<line key={g} x1="0" x2="420" y1={g * 40 + 10} y2={g * 40 + 10} stroke="rgba(18,20,19,.07)" />
))}
<path d={`${path} L420 150 L0 150Z`} fill="rgba(255,91,20,.08)" />
<path d={path} fill="none" stroke="var(--ink)" strokeWidth="1.6" />
<circle cx="420" cy={140 - 96 * 1.25} r="4" fill="var(--blaze)" />
</svg>
<div className="fill mt-1 flex justify-between text-[11px] text-black/50 mono">
<span>{isRtl ? "تیر" : "JUL"}</span>
<span>{isRtl ? "مرداد" : "AUG"}</span>
<span>{isRtl ? "شهریور" : "SEP"}</span>
</div>
<Dim style={{ [isRtl ? "right" : "left"]: 8, bottom: 6 }}>w 440 · chart 90d</Dim>
</div>
</div>
<div className={`bp absolute ${isRtl ? "left-4" : "right-4"} top-[54px] h-[392px] w-[232px] p-3`} style={{ "--i": 3 }}>
<div className="fill mb-2 flex justify-between text-[11px] text-black/55 mono">
<span>{isRtl ? "تسویه‌ها" : "SETTLEMENTS"}</span>
<span>{isRtl ? "امروز ۲۱۴" : "today 214"}</span>
</div>
{rows.map(([n, a], i) => (
<div
key={n}
className={`live fill mb-2.5 rounded-md border px-2.5 py-2 ${
step === 2 && i === 2 ? "border-[var(--blaze)] bg-[rgba(255,91,20,.08)]" : "border-black/[.06] bg-white"
}`}
style={{ "--i": 4 + i }}
>
<div className="flex justify-between text-[12px] font-medium">
<span>{n}</span>
</div>
<div className="mt-0.5 flex justify-between text-[11px] tabular-nums text-black/60">
<span className="dir-ltr text-start">{a}</span>
<span className={step === 2 && i === 2 ? "text-[var(--blaze)] font-medium" : ""}>
{step === 2 && i === 2
? isRtl ? "تسویه ۱۴۰ms" : "settled 138ms"
: i < 2
? isRtl ? "در انتظار" : "pending"
: isRtl ? "تسویه شد" : "settled"}
</span>
</div>
</div>
))}
<Dim style={{ [isRtl ? "left" : "right"]: 8, bottom: 6 }}>w 232 · list ×n</Dim>
</div>
</Win>
<Callout style={{ [isRtl ? "left" : "right"]: 254, top: 200 }}>
{isRtl ? "تاخیر p99 تسویه" : "p99 settlement"}
<br />
<b className="text-[17px] text-[var(--blaze)] dir-ltr">140 ms</b>
</Callout>
</div>
);
}
/* ---------------- Tidewater : berth planning ---------------- */
export function TideUI({ step }) {
const { isRtl } = useLang();
const ships = [
{ b: 0, x: 30, w: 170, n: "MSC Aurelia", t: "ETA 02:10" },
{ b: 0, x: 240, w: 210, n: "Kobe Express", t: "ETA 09:40" },
{ b: 1, x: 90, w: 150, n: "Nordlys", t: "ETA 04:00" },
{ b: 1, x: 260, w: 140, n: "Cap Ferro", t: "ETA 11:20", conflict: true },
{ b: 2, x: 20, w: 260, n: "Ever Lumen", t: "ETA 00:30" },
{ b: 3, x: 300, w: 180, n: "Saga Viking", t: "ETA 13:00" },
{ b: 4, x: 60, w: 200, n: "Atlantic Wren", t: "ETA 03:15" },
{ b: 4, x: 300, w: 120, n: "Hanse 7", t: "ETA 12:30" },
];
const tabs = isRtl ? ["اسکله‌ها", "جرثقیل‌ها", "محوطه کانتینر", "گیت‌ها"] : ["Berths", "Cranes", "Yard", "Gates"];
return (
<div className="ui absolute inset-0" data-step={step}>
<Win
title={isRtl ? "تایدواتر" : "Tidewater"}
crumb={isRtl ? "ترمینال غربی · پنج‌شنبه ۱۷ مهر" : "Terminal West · Thu 8 Oct"}
right={
<>
<span className="mono">{isRtl ? "۳۲ شناور" : "32 calls"}</span>
<span className="text-black/25">·</span>
<Live label={isRtl ? "زنده" : "Live"} />
</>
}
>
<div className="absolute left-4 right-4 top-[54px] flex h-[36px] items-center gap-2">
{tabs.map((t, i) => (
<div
key={t}
className={`bp fill flex h-full items-center px-3 text-[12px] ${
i === 0 ? "font-semibold" : "text-black/50"
}`}
style={{ "--i": 1 + i }}
>
{t}
</div>
))}
<div className={`bp ${isRtl ? "mr-auto" : "ml-auto"} flex h-full items-center px-3 text-[12px]`} style={{ "--i": 5 }}>
<span className="fill mono text-black/60">
{isRtl ? `بهره‌وری ${step === 2 ? "۹۱٪" : "۷۴٪"}` : `utilisation ${step === 2 ? "91%" : "74%"}`}
</span>
</div>
</div>
<div className="bp absolute bottom-4 left-4 right-4 top-[102px] overflow-hidden" style={{ "--i": 6 }}>
<div className="fill absolute left-[86px] right-0 top-0 flex h-[28px] items-center justify-between border-b border-black/[.06] pr-4 text-[10px] text-black/40 mono dir-ltr">
{["00", "04", "08", "12", "16", "20", "24"].map((h) => (
<span key={h}>{h}:00</span>
))}
</div>
{[0, 1, 2, 3, 4].map((b) => (
<div
key={b}
className="absolute left-0 right-0 flex items-center border-b border-black/[.05]"
style={{ top: 28 + b * 62, height: 62 }}
>
<span className="fill w-[86px] pl-4 text-[12px] font-medium" style={{ "--i": 7 + b }}>
{isRtl ? `اسکله ${b + 1}` : `Berth ${b + 1}`}
</span>
</div>
))}
<div className="absolute bottom-0 top-[28px] w-px bg-[var(--blaze)]" style={{ left: 86 + 245 }}>
<span className="fill absolute -left-[18px] -top-[1px] rounded bg-[var(--blaze)] px-1 text-[9px] text-white mono">
{isRtl ? "اکنون" : "NOW"}
</span>
</div>
{ships.map((s, i) => {
const moved = step === 2 && s.conflict;
return (
<div
key={s.n}
className={`live bp absolute flex flex-col justify-center px-2.5 ${
s.conflict && step === 1 ? "!border-[var(--blaze)] !bg-[rgba(255,91,20,.12)]" : ""
} ${moved ? "!border-[var(--ink)] !bg-[var(--ink)] text-white" : ""}`}
style={{
"--i": 10 + i,
left: 86 + s.x,
width: s.w,
top: 28 + s.b * 62 + 10,
height: 42,
transform: moved ? `translate(${150 - s.x}px, ${2 * 62}px)` : "none",
}}
>
<span className="fill text-[12px] font-medium leading-tight">{s.n}</span>
<span className={`fill text-[10px] mono ${moved ? "text-white/70" : "text-black/50"}`}>
{moved
? isRtl ? "تغییر اسکله · بدون توقف" : "re-berthed · 0 wait"
: s.conflict && step === 1
? isRtl ? "تداخل ۲س ۱۰د" : "overlap 2h 10m"
: s.t}
</span>
</div>
);
})}
<Dim style={{ left: 92, bottom: 8 }}>
{isRtl ? "ارتفاع ۶۲ · ۵ اسکله · محور ۲۴ ساعته" : "row h 62 · 5 berths · 24h axis"}
</Dim>
</div>
</Win>
<Callout side="right" style={{ left: 450, top: 236 }}>
{isRtl ? "معطلی اسکله" : "berth idle time"}
<br />
<b className="text-[18px] text-[var(--blaze)] dir-ltr">−31%</b>
</Callout>
</div>
);
}
/* ---------------- Quire : contract review ---------------- */
export function QuireUI({ step }) {
const { isRtl } = useLang();
const para = (ws, key, hl) => (
<div
key={key}
className={`fill mb-3 rounded-sm ${hl ? "-mx-2 px-2 py-1.5 live" : ""}`}
style={{ background: hl ? (step === 2 ? "transparent" : "rgba(255,91,20,.1)") : "transparent" }}
>
{ws.map((w, i) => (
<Bar key={i} w={w} h={5} className="mb-[7px]" />
))}
</div>
);
return (
<div className="ui absolute inset-0" data-step={step}>
<Win
title={isRtl ? "کوایر" : "Quire"}
crumb={isRtl ? "قرارداد تامین کالا · نسخه ۴" : "Northwind Supply · MSA v4"}
right={
<>
<span className="mono">{isRtl ? "۴۸ ص" : "48 pp"}</span>
<span className="text-black/25">·</span>
<span>{isRtl ? "شیوه‌نامه: استاندارد" : "Playbook: Standard"}</span>
</>
}
>
<div className={`bp absolute bottom-4 ${isRtl ? "right-4" : "left-4"} top-[54px] w-[420px] overflow-hidden px-7 py-6`} style={{ "--i": 1 }}>
<div className="fill mono text-[10px] text-black/50">
{isRtl ? "بند ۱۴ · مسئولیت طرفین" : "§ 14 · LIABILITY"}
</div>
<div className="fill mb-4 mt-1 text-[16px] font-semibold tracking-tight">
{isRtl ? "۱۴.۲ سقف مسئولیت مدنی" : "14.2 Limitation of liability"}
</div>
{para([360, 340, 352, 210], "a")}
<div className="fill relative mb-3 text-[12.5px] leading-[1.6] text-black/80">
{isRtl ? (
<>
مجموع مسئولیت تامین‌کننده نباید از{" "}
<span className={`live ${step === 2 ? "line-through decoration-[var(--blaze)] text-black/35" : "bg-[rgba(255,91,20,.16)]"}`}>
کارمزدهای پرداخت‌شده در سه (۳) ماه گذشته
</span>
{step === 2 && (
<span className="font-semibold text-[var(--blaze)]">
{" "}دو برابر (۲×) مجموع کارمزدهای پرداخت‌شده در دوازده (۱۲) ماه گذشته فراتر رود.
</span>
)}
</>
) : (
<>
Supplier’s aggregate liability shall not exceed{" "}
<span className={`live ${step === 2 ? "line-through decoration-[var(--blaze)] text-black/35" : "bg-[rgba(255,91,20,.16)]"}`}>
fees paid in the preceding three (3) months
</span>
{step === 2 && (
<span className="font-medium text-[var(--blaze)]">
{" "}two times (2×) the fees paid in the preceding twelve (12) months
</span>
)}
.
</>
)}
</div>
{para([350, 362, 300], "b")}
{para([340, 356, 120], "c")}
<Dim style={{ [isRtl ? "left" : "right"]: 10, top: 8 }}>page w 420 · measure 62ch</Dim>
</div>
<div className={`absolute bottom-4 ${isRtl ? "left-4" : "right-4"} top-[54px] w-[244px]`}>
<div className="bp relative h-[96px] p-3" style={{ "--i": 2 }}>
<div className="fill mono text-[10px] text-black/50">
{isRtl ? "بازبینی خودکار" : "REVIEW"}
</div>
<div className="fill mt-1 flex items-baseline gap-2">
<span className="text-[28px] font-semibold tracking-[-0.04em] tabular-nums">
{step === 2 ? "0" : "3"}
</span>
<span className="text-[12px] text-black/60">
{step === 2
? isRtl ? "انحراف باز" : "open deviations"
: isRtl ? "انحراف از شیوه‌نامه" : "deviations from playbook"}
</span>
</div>
<div className="fill mt-2 h-1 overflow-hidden rounded-full bg-black/[.07]">
<div
className="live h-full bg-[var(--ink)]"
style={{ width: step === 2 ? "100%" : "86%", transition: "width 900ms" }}
/>
</div>
</div>
{[
[
"14.2",
isRtl ? "سقف مسئولیت کمتر از ۱۲ ماه" : "Liability cap below 12-month fees",
true,
],
[
"9.1",
isRtl ? "تمدید خودکار بدون اعلام قبلی" : "Auto-renewal without notice",
false,
],
[
"21.4",
isRtl ? "قانون حاکم: نامشخص" : "Governing law: unlisted",
false,
],
].map(([c, t, main], i) => (
<div
key={c}
className={`bp live relative mt-2 p-3 ${main && step >= 1 ? "!border-[var(--blaze)]" : ""}`}
style={{ "--i": 3 + i }}
>
<div className="fill flex items-center justify-between">
<span className="mono text-[11px] text-black/50">§ {c}</span>
<span
className={`text-[10px] mono ${
step === 2 ? "text-black/50" : main ? "text-[var(--blaze)] font-semibold" : "text-black/50"
}`}
>
{step === 2
? isRtl ? "حل شد" : "resolved"
: main
? isRtl ? "ریسک بالا" : "high"
: isRtl ? "عادی" : "low"}
</span>
</div>
<div className="fill mt-1 text-[12px] font-medium leading-snug">{t}</div>
</div>
))}
<Dim style={{ [isRtl ? "left" : "right"]: 6, bottom: -14 }}>panel w 244</Dim>
</div>
</Win>
<Callout side="right" style={{ left: 214, top: 64 }}>
{isRtl ? "میانگین زمان بازبینی" : "median review"}
<br />
<b className="text-[17px] text-[var(--blaze)] dir-ltr">
{isRtl ? "۶ ساعت ← ۴۰ دقیقه" : "6 h → 40 min"}
</b>
</Callout>
</div>
);
}
/* ---------------- Halden : clinical trial ops ---------------- */
export function HaldenUI({ step }) {
const { isRtl } = useLang();
const sites = [
[60, 70, 9],
[120, 50, 6],
[170, 110, 12],
[90, 160, 7],
[230, 80, 5],
[280, 140, 10],
[210, 200, 8],
[330, 60, 4],
[350, 190, 9],
[140, 230, 6],
[300, 250, 11],
[390, 120, 5],
[60, 250, 4],
[250, 30, 6],
];
const flagged = 5;
return (
<div className="ui absolute inset-0" data-step={step}>
<Win
title={isRtl ? "هالدن" : "Halden"}
crumb={isRtl ? "مطالعه HLD-301 · فاز ۳" : "HLD-301 · Phase III"}
right={
<>
<span className="mono">{isRtl ? "۱۴ مرکز" : "14 sites"}</span>
<span className="text-black/25">·</span>
<Live label={isRtl ? "زنده" : "Live"} />
</>
}
>
<div className={`bp bp-sunk absolute bottom-4 ${isRtl ? "right-4" : "left-4"} top-[54px] w-[440px] overflow-hidden`} style={{ "--i": 1 }}>
<svg className="fill absolute inset-0" width="440" height="390">
{Array.from({ length: 12 }).map((_, i) => (
<line key={"v" + i} x1={i * 40} x2={i * 40} y1="0" y2="390" stroke="rgba(18,20,19,.05)" />
))}
{Array.from({ length: 10 }).map((_, i) => (
<line key={"h" + i} y1={i * 40} y2={i * 40} x1="0" x2="440" stroke="rgba(18,20,19,.05)" />
))}
<path
d="M40 300 C 120 260, 160 300, 230 270 S 360 300, 420 250"
fill="none"
stroke="rgba(18,20,19,.12)"
strokeDasharray="3 4"
/>
</svg>
{sites.map(([x, y, r], i) => (
<span
key={i}
className={`fill absolute rounded-full ${
step === 2 && i === flagged ? "pulse bg-[var(--blaze)]" : "bg-[var(--ink)]"
}`}
style={{
"--i": 2 + i * 0.4,
left: x + 10,
top: y + 30,
width: r + 4,
height: r + 4,
opacity: step === 0 ? 0 : 0.85,
}}
/>
))}
<div className={`fill absolute ${isRtl ? "right-4" : "left-4"} top-3 mono text-[10px] text-black/50`}>
{isRtl ? "پذیرش به تفکیک مرکز بالینی · نقطه = بیماران" : "ENROLMENT BY SITE · DOT = PATIENTS"}
</div>
<div className={`fill absolute bottom-3 ${isRtl ? "right-4" : "left-4"} flex gap-4 text-[11px] text-black/60`}>
<span>{isRtl ? "۴۱۲ پذیرش‌شده" : "412 enrolled"}</span>
<span>{isRtl ? "هدف ۶۰۰" : "target 600"}</span>
<span>{isRtl ? "روز ۲۱۴" : "day 214"}</span>
</div>
<Dim style={{ [isRtl ? "left" : "right"]: 8, top: 8 }}>map 440 × 390</Dim>
</div>
<div className={`bp absolute bottom-4 ${isRtl ? "left-4" : "right-4"} top-[54px] w-[232px] p-3`} style={{ "--i": 2 }}>
<div className="fill mb-2 mono text-[10px] text-black/50">
{isRtl ? "سیگنال‌ها و هشدارها" : "SIGNALS"}
</div>
{[
[
isRtl ? "مرکز ۰۶ · لیدن" : "Site 06 · Leiden",
isRtl ? "ریسک انحراف از پروتکل" : "Visit window drift",
0.72,
true,
],
[isRtl ? "مرکز ۰۳ · پورتو" : "Site 03 · Porto", isRtl ? "مطابق برنامه" : "On track", 0.9],
[isRtl ? "مرکز ۱۱ · لیون" : "Site 11 · Lyon", isRtl ? "مطابق برنامه" : "On track", 0.64],
[isRtl ? "مرکز ۰۹ · گراتس" : "Site 09 · Graz", isRtl ? "مطابق برنامه" : "On track", 0.58],
[isRtl ? "مرکز ۰۱ · تورکو" : "Site 01 · Turku", isRtl ? "مطابق برنامه" : "On track", 0.81],
].map(([n, s, v, f], i) => (
<div
key={n}
className={`live fill mb-1.5 rounded-md border px-2.5 py-2 ${
step === 2 && f ? "border-[var(--blaze)] bg-[rgba(255,91,20,.08)]" : "border-black/[.06] bg-white"
}`}
style={{ "--i": 3 + i }}
>
<div className="flex justify-between text-[12px] font-medium">
<span>{n}</span>
</div>
<div
className={`mt-0.5 text-[11px] ${
step === 2 && f ? "text-[var(--blaze)] font-semibold" : "text-black/55"
}`}
>
{step === 2 && f ? (isRtl ? "ریسک انحراف · ۳ بیمار" : "Deviation risk · 3 pts") : s}
</div>
<div className="mt-1.5 h-1 rounded-full bg-black/[.06]">
<div className="h-full rounded-full bg-[var(--ink)]" style={{ width: `${v * 100}%` }} />
</div>
</div>
))}
<Dim style={{ [isRtl ? "right" : "left"]: 8, bottom: 6 }}>w 232 · signal ×n</Dim>
</div>
</Win>
<Callout side="right" style={{ left: 312, top: 158 }}>
{isRtl ? "کشف انحرافات" : "deviations caught"}
<br />
<b className="text-[17px] text-[var(--blaze)] dir-ltr">
{isRtl ? "۹ روز زودتر" : "9 days earlier"}
</b>
</Callout>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useInView } from "../scroll";
import { useLang } from "../LanguageContext";
export default function Proof() {
const { strings, isRtl } = useLang();
const [ref, seen] = useInView({ threshold: 0.15 });
return (
<section id="proof" ref={ref} aria-label="Proof" className={`bg-[var(--paper)] px-5 py-28 sm:px-10 lg:px-16 lg:py-40 ${seen ? "seen" : ""}`}>
<div className="grid gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,8fr)] lg:gap-14">
<h2 className="display reveal text-[clamp(40px,5.8vw,100px)]">
{strings.proofTitle} <span className="text-[var(--steel)]">{strings.proofTitleSub}</span>
</h2>
<ul className="flex flex-col">
{strings.proofItems.map((r, i) => (
<li key={r.l} className="reveal border-t border-[var(--line)] py-6 lg:py-8" style={{ transitionDelay: `${i * 90}ms` }}>
<div className="flex items-baseline justify-between gap-4">
<p className="max-w-[28ch] text-[16px] leading-relaxed text-[var(--steel)] sm:text-[18px]">{r.l}</p>
<p className="display shrink-0 text-[clamp(44px,5.5vw,90px)] tabular-nums">
{r.n}
{r.u && <span className="mx-1 text-[0.45em] tracking-[-0.01em] text-[var(--steel)] font-normal">{r.u}</span>}
</p>
</div>
<div className="relative mt-4 h-3" aria-hidden>
<div
className={`measure absolute ${isRtl ? "right-0" : "left-0"} top-1/2 h-[2px] -translate-y-1/2 bg-[var(--blaze)]`}
style={{ width: `${r.w * 100}%`, transitionDelay: `${300 + i * 140}ms` }}
/>
<span className={`absolute ${isRtl ? "right-0" : "left-0"} top-0 h-3 w-[2px] bg-[var(--blaze)]`} />
<span
className="absolute top-0 h-3 w-[2px] bg-[var(--blaze)] transition-opacity duration-500"
style={{
[isRtl ? "right" : "left"]: `calc(${r.w * 100}% - 2px)`,
opacity: seen ? 1 : 0,
transitionDelay: `${1500 + i * 140}ms`,
}}
/>
</div>
</li>
))}
<li className="reveal mt-10 lg:mt-16">
<blockquote>
<p className="max-w-[34ch] text-[clamp(21px,2.2vw,32px)] font-medium leading-[1.35] tracking-[-0.015em]">
{strings.proofQuote}
</p>
<footer className="mono mt-5 text-[14px] text-[var(--steel)]">{strings.proofAuthor}</footer>
</blockquote>
</li>
</ul>
</div>
</section>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useRef, useState } from "react";
// Renders a fixed 720x460 interface and scales it to fit its box, so the
// product looks identical (just smaller) on a phone and a 5K display.
export const W = 720, H = 460;
export default function Scaled({ children, className = "" }) {
const box = useRef(null);
const [s, setS] = useState(0);
useEffect(() => {
const el = box.current;
const ro = new ResizeObserver(([e]) => {
const { width, height } = e.contentRect;
setS(Math.min(width / W, height ? height / H : Infinity));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div ref={box} className={`relative ${className}`} style={{ aspectRatio: `${W} / ${H}` }}>
<div className="absolute left-1/2 top-1/2" style={{ width: W, height: H, transform: `translate(-50%,-50%) scale(${s})`, opacity: s ? 1 : 0 }}>
{children}
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useInView } from "../scroll";
import { useLang } from "../LanguageContext";
export default function Stack() {
const { strings, isRtl } = useLang();
const [ref, seen] = useInView({ threshold: 0.1 });
const headers = strings.tableHeaders;
return (
<section id="stack" ref={ref} aria-label="Technology" className={`bg-[var(--chamber)] px-5 py-28 text-[var(--paper)] sm:px-10 lg:px-16 lg:py-40 ${seen ? "seen" : ""}`}>
<div className="flex flex-wrap items-end justify-between gap-6">
<h2 className="display reveal max-w-[14ch] text-[clamp(40px,5.8vw,100px)]">{strings.stackTitle}</h2>
<p className="mono reveal max-w-[34ch] text-[14px] leading-relaxed text-[var(--fog)]">{strings.stackSubtitle}</p>
</div>
<table className={`reveal mt-14 w-full border-collapse ${isRtl ? "text-right" : "text-left"} lg:mt-20`}>
<thead className="hidden md:table-header-group">
<tr className="label text-[var(--fog)]">
<th className="w-[6%] pb-4 font-normal">{headers.item}</th>
<th className="w-[26%] pb-4 font-normal">{headers.part}</th>
<th className="pb-4 font-normal">{headers.job}</th>
<th className="pb-4 font-normal">{headers.tolerance}</th>
<th className={`pb-4 font-normal ${isRtl ? "text-left" : "text-right"}`}>{headers.usedIn}</th>
</tr>
</thead>
<tbody>
{strings.bom.map(([part, job, tol, used], i) => (
<tr key={part} className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 border-t border-[var(--line-d)] py-5 md:table-row md:py-0">
<td className="mono row-span-3 text-[14px] text-[var(--fog)] tabular-nums md:py-6">{String(i + 1).padStart(2, "0")}</td>
<td className="text-[19px] font-semibold tracking-tight md:py-6 md:text-[22px]">{part}</td>
<td className="text-[15px] text-[var(--fog)] md:py-6 md:text-[16px] md:text-[var(--paper)]">{job}</td>
<td className="mono text-[14px] text-[var(--blaze)] font-medium md:py-6">{tol}</td>
<td className={`mono hidden text-[14px] text-[var(--fog)] md:table-cell md:py-6 ${isRtl ? "text-left" : "text-right"}`}>{used}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { useInView } from "../scroll";
import { useLang } from "../LanguageContext";
export default function Studio() {
const { strings, isRtl } = useLang();
const [ref, seen] = useInView({ threshold: 0.2 });
return (
<section id="studio" ref={ref} aria-label="Company" className={`bg-[var(--alu)] px-5 py-28 sm:px-10 lg:px-16 lg:py-40 ${seen ? "seen" : ""}`}>
<h2 className="display reveal max-w-[18ch] text-[clamp(38px,6.8vw,118px)]">
{strings.studioTitle} <span className="text-[var(--steel)]">{strings.studioTitleSub}</span>
</h2>
<p className="reveal mt-8 max-w-[46ch] text-[17px] leading-relaxed sm:text-[19px]">
{strings.studioLead}
</p>
<dl className={`reveal mt-16 grid grid-cols-2 ${isRtl ? "border-r border-t" : "border-l border-t"} border-[var(--ink)] lg:mt-24 lg:grid-cols-6`}>
{strings.studioCells.map(([k, v]) => (
<div key={k} className={`border-b ${isRtl ? "border-l" : "border-r"} border-[var(--ink)] p-4 sm:p-5`}>
<dt className="mono text-[13px] text-[var(--steel)] font-medium">{k}</dt>
<dd className="mt-3 text-[16px] font-semibold leading-snug tracking-tight">{v}</dd>
</div>
))}
</dl>
</section>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from "react";
import { useFrame, clamp } from "../scroll";
import { useLang } from "../LanguageContext";
export default function Vernier() {
const { strings, isRtl, lang, toggleLang } = useLang();
const [cur, setCur] = useState({ i: 0, sub: "" });
const [open, setOpen] = useState(false);
const idx = useRef(null), last = useRef("");
const sections = strings.vernierSections;
const states = strings.productStates;
const tick = () => {
const doc = document.documentElement;
const p = clamp(window.scrollY / Math.max(1, doc.scrollHeight - window.innerHeight));
if (idx.current) {
idx.current.style.transform = `translateX(${p * 100}%)`;
}
let i = 0;
sections.forEach(([id], k) => {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= window.innerHeight * 0.5) i = k;
});
const el = document.getElementById(sections[i]?.[0]);
const sub = el?.dataset.step != null ? states[+el.dataset.step] : "";
const key = i + sub;
if (key !== last.current) {
last.current = key;
setCur({ i, sub });
}
};
useFrame(tick);
useEffect(() => {
const t = setInterval(tick, 250);
return () => clearInterval(t);
}, [sections, states]);
useEffect(() => {
const k = (e) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", k);
return () => window.removeEventListener("keydown", k);
}, []);
const go = (id) => {
setOpen(false);
document.getElementById(id)?.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
});
};
const onContact = cur.i === sections.length - 1;
return (
<nav aria-label="Site" className="fixed bottom-4 left-1/2 z-50 w-[min(calc(100vw-24px),580px)] -translate-x-1/2 sm:bottom-6">
<div
id="vernier-index"
className={`mb-2 origin-bottom overflow-hidden rounded-[22px] bg-[rgba(18,20,19,.95)] text-[var(--paper)] backdrop-blur-md transition-[opacity,transform] duration-500 ${
open ? "opacity-100" : "pointer-events-none translate-y-2 scale-[.98] opacity-0"
}`}
aria-hidden={!open}
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-[12px] mono text-[var(--fog)]">
<span>{strings.nowViewing}: {sections[cur.i]?.[1]}</span>
<button
onClick={toggleLang}
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors text-[11px]"
>
<span>{lang === "fa" ? "English" : "فارسی"}</span>
</button>
</div>
<ul className="grid grid-cols-2 gap-px p-2">
{sections.map(([id, n], k) => (
<li key={id}>
<button
tabIndex={open ? 0 : -1}
onClick={() => go(id)}
className={`flex h-11 w-full items-center justify-between rounded-[12px] px-3.5 text-start text-[14px] transition-colors hover:bg-white/[.07] ${
k === cur.i ? "text-[var(--blaze)] font-semibold" : ""
}`}
>
<span>{n}</span>
{k === cur.i && <span className="h-1.5 w-1.5 rounded-full bg-[var(--blaze)]" />}
</button>
</li>
))}
</ul>
</div>
<div className="flex h-14 items-center gap-1 rounded-full border border-white/10 bg-[rgba(18,20,19,.92)] p-1.5 px-2 text-[var(--paper)] shadow-[0_20px_50px_-20px_rgba(0,0,0,.6)] backdrop-blur-md">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls="vernier-index"
aria-label={`${strings.nowViewing} ${sections[cur.i]?.[1]}`}
className="group flex h-11 min-w-0 flex-1 items-center gap-3 rounded-full px-3 transition-colors hover:bg-white/[.05]"
>
<span className="relative h-5 min-w-[64px] flex-1 overflow-hidden" aria-hidden>
<span
className="absolute inset-x-0 bottom-0 h-full"
style={{
backgroundImage: "repeating-linear-gradient(90deg, rgba(242,242,239,.35) 0 1px, transparent 1px 6px)",
maskImage: "linear-gradient(to top, #000 40%, transparent 41%)",
WebkitMaskImage: "linear-gradient(to top, #000 40%, transparent 41%)",
}}
/>
<span
className="absolute inset-x-0 bottom-0 h-full"
style={{
backgroundImage: "repeating-linear-gradient(90deg, rgba(242,242,239,.6) 0 1px, transparent 1px 30px)",
}}
/>
<span ref={idx} className="absolute inset-y-0 left-0 w-full will-change-transform">
<span className="absolute -left-px bottom-0 top-0 w-[2px] bg-[var(--blaze)]" />
</span>
</span>
<span className="mono flex shrink-0 items-baseline gap-2 text-[13px]">
<span className="text-[var(--paper)] font-medium">{sections[cur.i]?.[1]}</span>
{cur.sub && <span className="hidden text-[var(--fog)] text-[12px] min-[420px]:inline">{cur.sub}</span>}
</span>
</button>
<a
href="#contact"
onClick={(e) => {
e.preventDefault();
go("contact");
}}
className={`flex h-11 shrink-0 items-center rounded-full px-4 text-[14px] font-semibold transition-colors duration-500 ${
onContact ? "bg-[var(--paper)] text-[var(--ink)]" : "bg-[var(--blaze)] text-[var(--ink)]"
}`}
>
{strings.startProject}
</a>
</div>
</nav>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { t } from "./translations";
export const products = t.fa.products;
export const method = t.fa.methodItems;
export const proof = t.fa.proofItems;
export const bom = t.fa.bom;
export const studioCells = t.fa.studioCells;
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
// One shared rAF loop for every scroll-linked element (no per-component listeners).
const subs = new Set();
let ticking = false;
const run = () => { ticking = false; subs.forEach((f) => f()); };
const request = () => { if (!ticking) { ticking = true; requestAnimationFrame(run); } };
if (typeof window !== "undefined") {
window.addEventListener("scroll", request, { passive: true });
window.addEventListener("resize", request);
}
export const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
export const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export const reducedMotion = () =>
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// progress 0..1 of a tall section while its sticky child is pinned
export function pinProgress(el) {
const r = el.getBoundingClientRect();
const total = r.height - window.innerHeight;
return total <= 0 ? 0 : clamp(-r.top / total);
}
export function useFrame(fn) {
const ref = useRef(fn);
ref.current = fn;
useEffect(() => {
const f = () => ref.current();
subs.add(f);
f();
return () => subs.delete(f);
}, []);
}
// Discrete step from a pinned section, re-rendering only when the step changes.
export function usePinStep(sectionRef, steps, onProgress) {
const [step, setStep] = useState(reducedMotion() ? steps - 1 : 0);
const last = useRef(step);
useFrame(() => {
const el = sectionRef.current;
if (!el) return;
const p = pinProgress(el);
onProgress && onProgress(p);
if (reducedMotion()) return;
const s = Math.min(steps - 1, Math.floor(p * steps * 0.999 + 0.0001));
if (s !== last.current) { last.current = s; setStep(s); }
});
return step;
}
export function useInView(opts = { threshold: 0.3 }) {
const ref = useRef(null);
const [seen, setSeen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setSeen(true); io.disconnect(); } }, opts);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, seen];
}
+223
View File
@@ -0,0 +1,223 @@
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ==========================================================================
IRANSans Typography Declarations
========================================================================== */
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-Light.ttf') format('truetype'),
url('/fonts/IRANSans-Light.ttf') format('truetype');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-Regular.ttf') format('truetype'),
url('/fonts/IRANSans-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-Medium.ttf') format('truetype'),
url('/fonts/IRANSans-Medium.ttf') format('truetype');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-DemiBold.ttf') format('truetype'),
url('/fonts/IRANSans-DemiBold.ttf') format('truetype');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-Bold.ttf') format('truetype'),
url('/fonts/IRANSans-Bold.ttf') format('truetype');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-ExtraBold.ttf') format('truetype');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-Black.ttf') format('truetype');
}
@font-face {
font-family: 'IRANSansX';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'iransans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/IRANSansXFaNum-Regular.ttf') format('truetype');
}
:root {
--alu: #e3e4e0;
--paper: #f2f2ef;
--ink: #121413;
--steel: #565b57;
--blaze: #ff5b14;
--chamber: #0f1110;
--chamber-2: #181b19;
--fog: #a9aea9;
--line: rgba(18, 20, 19, 0.14);
--line-d: rgba(242, 242, 239, 0.12);
--sans: "IRANSans", "IRANSansX", "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
--mono: "IRANSans", "IBM Plex Mono", ui-monospace, monospace;
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body {
margin: 0;
background: var(--alu);
color: var(--ink);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection {
background: var(--blaze);
color: var(--ink);
}
.mono {
font-family: var(--mono);
letter-spacing: 0.01em;
}
.display {
font-family: var(--sans);
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.05;
}
[dir="rtl"] .display {
letter-spacing: -0.02em;
line-height: 1.15;
}
.label {
font-family: var(--mono);
font-size: 14px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
a, button {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 2px solid var(--blaze);
outline-offset: 3px;
border-radius: 4px;
}
/* ---------- HERO : the cut ---------- */
.cut-line {
transform-origin: left center;
animation: saw 1.4s cubic-bezier(.7,0,.2,1) .25s both;
}
[dir="rtl"] .cut-line {
transform-origin: right center;
}
@keyframes saw { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.rise-up { animation: riseUp 1.1s cubic-bezier(.2,.7,.1,1) 1.05s both; }
.rise-down { animation: riseDown 1.1s cubic-bezier(.2,.7,.1,1) 1.15s both; }
@keyframes riseUp { from { transform: translateY(105%); } to { transform: none; } }
@keyframes riseDown { from { transform: translateY(-105%); } to { transform: none; } }
.fade-in { animation: fadeIn 1s ease 1.7s both; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.saw-head {
animation: sawHead 1.4s cubic-bezier(.7,0,.2,1) .25s both;
}
@keyframes sawHead {
from { left: 0%; opacity: 1; }
92% { opacity: 1; }
to { left: 100%; opacity: 0; }
}
[dir="rtl"] .saw-head {
animation: sawHeadRtl 1.4s cubic-bezier(.7,0,.2,1) .25s both;
}
@keyframes sawHeadRtl {
from { right: 0%; left: auto; opacity: 1; }
92% { opacity: 1; }
to { right: 100%; left: auto; opacity: 0; }
}
/* ---------- PRODUCT UI : drawing -> object ---------- */
.ui { --t: 700ms; font-family: var(--sans); color: var(--ink); }
.ui .bp { border: 1px solid rgba(18,20,19,.12); background: #fbfbf9; border-radius: 6px; transition: border-color var(--t), background-color var(--t), box-shadow var(--t); transition-delay: calc(var(--i, 0) * 45ms); }
.ui .bp.bp-sunk { background: #f1f2ee; }
.ui .fill { transition: opacity var(--t) cubic-bezier(.2,.7,.1,1), transform var(--t) cubic-bezier(.2,.7,.1,1); transition-delay: calc(var(--i, 0) * 45ms + 120ms); }
.ui .dim { opacity: 0; transition: opacity 400ms; font-family: var(--mono); font-size: 10px; color: var(--blaze); position: absolute; white-space: nowrap; letter-spacing: .04em; }
.ui .callout { opacity: 0; transform: translateY(8px); transition: opacity 600ms 250ms, transform 600ms 250ms cubic-bezier(.2,.7,.1,1); }
.ui .live { transition: background-color 600ms, color 600ms, transform 900ms cubic-bezier(.65,0,.2,1), border-color 600ms; }
.ui[data-step="0"] .bp { border: 1px dashed rgba(255,91,20,.75); background: transparent; box-shadow: none; }
.ui[data-step="0"] .fill { opacity: 0; transform: translateY(6px); }
.ui[data-step="0"] .dim { opacity: 1; }
.ui[data-step="2"] .callout { opacity: 1; transform: none; }
.ui-window { box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 40px 80px -30px rgba(0,0,0,.6); }
.ui[data-step="0"] .ui-window { box-shadow: none; }
.pulse { animation: pulse 2.2s ease-out infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,91,20,.55); } 70%,100% { box-shadow: 0 0 0 14px rgba(255,91,20,0); } }
/* chapter text swap */
.swap { transition: opacity 500ms ease, transform 600ms cubic-bezier(.2,.7,.1,1); }
.swap[data-on="false"] { opacity: 0; transform: translateY(14px); pointer-events: none; }
.swap[data-on="true"] { opacity: 1; transform: none; }
/* measured lines */
.measure { transform-origin: left; transform: scaleX(0); transition: transform 1.4s cubic-bezier(.65,0,.2,1); }
[dir="rtl"] .measure { transform-origin: right; }
.seen .measure { transform: scaleX(1); }
.reveal { opacity: 0; transform: translateY(20px); transition: opacity 900ms ease, transform 900ms cubic-bezier(.2,.7,.1,1); }
.seen .reveal, .reveal.seen { opacity: 1; transform: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { scrollbar-width: none; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; animation-delay: 0ms !important; transition-duration: 1ms !important; transition-delay: 0ms !important; }
.measure { transform: scaleX(1); }
.reveal { opacity: 1; transform: none; }
}
html, body { height: auto !important; overflow-x: clip; }
#root { height: auto !important; min-height: 100%; overflow: visible !important; }
+313
View File
@@ -0,0 +1,313 @@
export const t = {
fa: {
// Header & Meta
skipToProducts: "پرش به محصولات",
// Hero
heroTitle1: "ما نرم‌افزار می‌سازیم",
heroTitle2: "بدون کوچک‌ترین افزونگی.",
studioSubtitle: "استودیو مستقل طراحی و مهندسی محصول\nرتردام · مونترال",
heroLead: "استودیو کِرف (Kerf) محصولات نرم‌افزاری اختصاصی را برای امور مالی، لجستیک بنادر، حقوقی و پزشکی بالینی طراحی، مهندسی و مدیریت می‌کند. ۱۱ محصول عملیاتی در محیط تولید.",
kerfWidth: "عرض برش (کِرف)",
scrollToCut: "برای برش اسکرول کنید",
insideChamberTitle: "درون برش: محصول، نه پروژه.",
insideChamberDesc: "دو محصول متعلق به خودمان؛ دو محصول اختصاصی برای مشتریان. هر چهار محصول هر شب بدون وقفه در حال پردازش زنده هستند.",
minis: [
{ key: "ledger", name: "لجرلاین", field: "خزانه‌داری" },
{ key: "tide", name: "تایدواتر", field: "لجستیک بندری" },
{ key: "quire", name: "کوایر", field: "امور حقوقی" },
{ key: "halden", name: "هالدن", field: "کارآزمایی بالینی" },
],
// Products
productStates: ["طرح اولیه (نقشه)", "کالبد شیء", "اجرای زنده"],
products: [
{
key: "ledger",
name: "لجرلاین (Ledgerline)",
shortName: "لجرلاین",
field: "خزانه‌داری بین‌الملل",
owner: "محصول مستقل خودمان · از ۲۰۱۹",
steps: [
{ k: "صورت مسئله", t: "تیم‌های مالی حساب‌های ماهانه را با اکسل‌هایی می‌بندند که تا روز سه‌شنبه منقضی و غلط می‌شوند." },
{ k: "جامعه هدف", t: "خزانه‌دارانی که بیش از ۲۰ حساب بانکی را در ۹ ارز مختلف بین‌المللی مدیریت می‌کنند." },
{ k: "ارزش افزوده", t: "مشاهده لحظه‌ای وضعیت نقدینگی؛ تسویه کلیه تراکنش‌ها پیش از سرد شدن قهوه صبحگاهی.", m: "۲.۱ میلیارد یورو", ml: "تسویه ماهانه تراکنش‌ها" },
],
},
{
key: "tide",
name: "تایدواتر (Tidewater)",
shortName: "تایدواتر",
field: "لجستیک ترمینال‌های بندری",
owner: "توسعه اختصاصی برای ترمینال‌های دریای شمال",
steps: [
{ k: "صورت مسئله", t: "یک کشتی کانتینری معطل در لنگرگاه روزانه ۴۰٬۰۰۰ یورو سوخت می‌سوزاند." },
{ k: "جامعه هدف", t: "برنامه‌ریزان ترمینال که ماهانه بیش از ۳۰۰ تردد شناور سنگین را زمان‌بندی می‌کنند." },
{ k: "ارزش افزوده", t: "حل و فصل تداخلات پهلوگیری پیش از آنکه کشتی به محدوده رادیویی برسد.", m: "۳۱٪−", ml: "کاهش زمان معطلی اسکله در ۶ ترمینال" },
],
},
{
key: "quire",
name: "کوایر (Quire)",
shortName: "کوایر",
field: "تحلیل قراردادهای حقوقی",
owner: "محصول مستقل خودمان · از ۲۰۲۲",
steps: [
{ k: "صورت مسئله", t: "وکلا تک‌تک بندها را می‌خوانند، از جمله ۹۰ درصدی که دقیقاً منطبق بر شیوه‌نامه استاندارد است." },
{ k: "جامعه هدف", t: "تیم‌های حقوقی درون‌سازمانی با بازبینی بیش از ۲۰۰ قرارداد تامین در هر فصل." },
{ k: "ارزش افزوده", t: "کوایر ۹۰٪ استاندارد را می‌خواند؛ وکلا روی ۱۰٪ چالش‌برانگیز تمرکز می‌کنند.", m: "۴۰ دقیقه", ml: "میانگین زمان بازبینی، در برابر ۶ ساعت قبلی" },
],
},
{
key: "halden",
name: "هالدن (Halden)",
shortName: "هالدن",
field: "کارآزمایی‌های بالینی پزشکی",
owner: "توسعه مشترک با مرکز تحقیقات آرکادیا (Arcadia CRO)",
steps: [
{ k: "صورت مسئله", t: "انحراف از پروتکل بالینی در ویزیت‌های ماهانه کشف می‌شود؛ یعنی هفته‌ها دیرتر." },
{ k: "جامعه هدف", t: "تیم‌های راهبری عملیات بالینی که ۳۰ تا ۴۰۰ مرکز درمانی را همزمان اداره می‌کنند." },
{ k: "ارزش افزوده", t: "انحراف در همان روز وقوع ثبت و هشدار داده می‌شود، نه ماهی که دیر شده است.", m: "۹ روز", ml: "شناسایی سریع‌تر هرگونه انحراف از پروتکل" },
],
},
],
// Method
methodTitle: "از پیش‌طرح تا کالبد واقعی.",
methodSubtitle: "چهار مرحله بنیادین. همان تیم کوچک و متمرکز از نخستین اسکچ تا آماده‌باش ساعت ۳ بامداد.",
methodStage: "مرحله {i} از ۴ · {d}",
methodItems: [
{ v: "قاب‌بندی", d: "۱ هفته", t: "برش اساسی و دقیق را پیدا می‌کنیم و به سایر موارد غیرضروری نه می‌گوییم.", out: "یک مستند تک‌صفحه‌ای مستدل و قابل دفاع" },
{ v: "رسم نقشه", d: "۳ هفته", t: "طراحی پیش‌نمونه‌ها با کد آماده تولید، نه اسلایدهای نمایشی.", out: "یک محصول تعاملی کلیک‌پذیر با داده‌های واقعی" },
{ v: "تراش و ساخت", d: "۸ تا ۱۶ هفته", t: "طراحان و مهندسان در یک تیم پیوسته، با تحویل هفتگی کد به محیط زنده.", out: "نرم‌افزار کامل و پایدار در محیط پروداکشن" },
{ v: "پایش مداوم", d: "سال‌ها", t: "همیشه پاسخگو و پای کار می‌مانیم. بیشتر مشتریان شش سال با ما ادامه می‌دهند.", out: "توافقنامه سطح خدمات (SLA) با تعهد شخصی ما" },
],
methodFooter: "سپس آن را بدون وقفه در مدار نگه می‌داریم.",
// Proof
proofTitle: "اندازه‌گیری‌شده؛",
proofTitleSub: "نه صرفاً ادعا.",
proofItems: [
{ n: "۱۹", u: "روز", l: "از نخستین تماس تا استقرار کد در محیط پروداکشن", w: 0.38 },
{ n: "۹/۱۱", u: "", l: "محصولاتی که پس از ۵ سال همچنان در محیط عملیاتی فعالند", w: 0.82 },
{ n: "۶.۴", u: "سال", l: "میانگین طول دوره همکاری با هر مشتری", w: 0.64 },
{ n: "۹۹.۹۸", u: "٪", l: "پایداری بدون وقفه در تمامی محصولات طی ۳۶ ماه", w: 1 },
],
proofQuote: "«آن‌ها با استدلال نیمی از نقشه راه ما را حذف کردند. همان نیمی که ساخته شد، دلیل اصلی تصاحب تجاری شرکت ما شد.»",
proofAuthor: "اینس وارت · مدیر ارشد سابق محصول در Fernhill Payments",
// Stack / Bill of Materials
stackTitle: "صورت متریال و فناوری.",
stackSubtitle: "ساده و اثبات‌شده در جاهایی که باید باشد؛ فوق‌العاده سریع در گلوگاه‌ها. فناوری‌هایی که خودمان مستقیماً اجرا می‌کنیم.",
tableHeaders: {
item: "ردیف",
part: "بخش فناوری",
job: "وظیفه مهندسی",
tolerance: "معیار و کارایی",
usedIn: "کاربرد در محصولات",
},
bom: [
["Rust", "موتورهای تسویه مالی و زمان‌بندی ترددها", "تاخیر p99 کمتر از ۵ میلی‌ثانیه", "لجرلاین · تایدواتر"],
["TypeScript · React", "رابط‌های کاربری تمامی محصولات", "اجرای نرم ۶۰ فریم بر ثانیه", "همه محصولات"],
["PostgreSQL", "پایگاه داده اصلی و سیستم ثبت وقایع", "بازیابی نقطه‌ای ۳۰ روزه", "همه محصولات"],
["Event streams", "تله‌متری بلادرنگ شناورها، بانک‌ها و مراکز", "۲.۱ میلیارد رویداد روزانه", "تایدواتر · هالدن"],
["On-device models", "دسته‌بندی خودکار بندهای حقوقی", "پردازش کاملاً آفلاین · ۴۰ مگابایت", "کوایر"],
["Bare Metal + Terraform", "زیرساخت سرور و خودکارسازی", "۹۹.۹۸٪ پایداری طی ۳۶ ماه", "همه محصولات"],
],
// Studio
studioTitle: "کوچک به انتخاب خود.",
studioTitleSub: "دقیق بر حسب عادت.",
studioLead: "کِرف (Kerf) به معنای عرض برش اره است: مقداری از متریال که فدا می‌کنید تا قطعه نهایی دقیق و میلی‌متری حاصل شود. ما مقیاس انبوه را فدا کرده‌ایم تا هر محصول مستقیماً توسط همان کسانی توسعه یابد که آن را ترسیم کرده‌اند.",
studioCells: [
["استودیو", "Kerf B.V."],
["سال تاسیس", "۲۰۱۴ (۱۳۹۳)"],
["دفاتر طراحی", "رتردام · مونترال"],
["نفرات", "۴۲ نفر · ۳۱ مهندس، ۱۱ طراح"],
["تعهدات سالانه", "حداکثر ۴ پروژه در سال"],
["مقیاس", "۱ : ۱ (واقعی)"],
],
// Contact
contactTag: "پذیرش دو پروژه کاری برای بهار ۲۰۲۷",
contactTitle: "پروژه سخت و پیچیده‌تان را\nبه ما بسپارید.",
contactEmail: "hello@kerf.studio",
contactSubjectHard: "یک_پروژه_سخت",
contactSubjectCall: "جلسه_۳۰_دقیقه‌ای",
contactCallBtn: "رزرو جلسه گفتگو (۳۰ دقیقه)",
contactFooter: "© ۲۰۲۶ استودیو کِرف (Kerf B.V.) · شیت ۷ از ۷",
// Vernier Navigation
vernierSections: [
["cut", "کِرف"],
["p-ledgerline", "لجرلاین"],
["p-tidewater", "تایدواتر"],
["p-quire", "کوایر"],
["p-halden", "هالدن"],
["method", "روش‌شناسی"],
["proof", "سنجش"],
["stack", "متریال"],
["studio", "استودیو"],
["contact", "تماس"],
],
startProject: "شروع پروژه",
nowViewing: "در حال مشاهده",
langButton: "EN",
langName: "انگلیسی",
},
en: {
// Header & Meta
skipToProducts: "Skip to products",
// Hero
heroTitle1: "We build \nsoftware",
heroTitle2: "with nothing \nto spare.",
studioSubtitle: "Independent product studio\nRotterdam · Montréal",
heroLead: "Kerf designs, engineers and runs software products for finance, logistics, law and medicine. Eleven in production.",
kerfWidth: "kerf width",
scrollToCut: "scroll to cut",
insideChamberTitle: "Inside the cut: products, not projects.",
insideChamberDesc: "Two we own. Two we built for clients. All four we still run, every night.",
minis: [
{ key: "ledger", name: "Ledgerline", field: "Treasury" },
{ key: "tide", name: "Tidewater", field: "Port logistics" },
{ key: "quire", name: "Quire", field: "Legal" },
{ key: "halden", name: "Halden", field: "Clinical trials" },
],
// Products
productStates: ["Drawing", "Object", "Running"],
products: [
{
key: "ledger",
name: "Ledgerline",
shortName: "Ledgerline",
field: "Treasury",
owner: "Ours · since 2019",
steps: [
{ k: "The problem", t: "Finance teams close the month in spreadsheets that are wrong by Tuesday." },
{ k: "Who it’s for", t: "Treasurers running 20+ bank accounts across nine currencies." },
{ k: "What it’s worth", t: "One live cash position. Every settlement confirmed before the coffee cools.", m: "€2.1B", ml: "settled every month" },
],
},
{
key: "tide",
name: "Tidewater",
shortName: "Tidewater",
field: "Port logistics",
owner: "Built for North Sea Terminals",
steps: [
{ k: "The problem", t: "A container ship waiting at anchor burns €40,000 a day." },
{ k: "Who it’s for", t: "Terminal planners scheduling 300+ vessel calls a month." },
{ k: "What it’s worth", t: "Conflicts resolved before the ship is in radio range.", m: "−31%", ml: "berth idle time, six terminals" },
],
},
{
key: "quire",
name: "Quire",
shortName: "Quire",
field: "Legal",
owner: "Ours · since 2022",
steps: [
{ k: "The problem", t: "Lawyers read every clause, including the 90% that already matches the playbook." },
{ k: "Who it’s for", t: "In-house teams reviewing 200+ supplier contracts a quarter." },
{ k: "What it’s worth", t: "Quire reads the 90%. People argue the 10%.", m: "40 min", ml: "median review, from 6 hours" },
],
},
{
key: "halden",
name: "Halden",
shortName: "Halden",
field: "Clinical trials",
owner: "Built with Arcadia CRO",
steps: [
{ k: "The problem", t: "Trials spot protocol deviations at the monthly visit. Weeks too late." },
{ k: "Who it’s for", t: "Trial operations teams running 30 to 400 sites." },
{ k: "What it’s worth", t: "Drift is flagged the day it starts, not the month it’s found.", m: "9 days", ml: "earlier deviation detection" },
],
},
],
// Method
methodTitle: "From drawing to object.",
methodSubtitle: "Four stages. The same small team from the first sketch to the 3 a.m. pager.",
methodStage: "Stage {i} of 4 · {d}",
methodItems: [
{ v: "Frame", d: "1 week", t: "We find the one cut that matters and say no to the rest.", out: "A one-page brief you can defend" },
{ v: "Draw", d: "3 weeks", t: "Prototypes in production code. Never slides.", out: "A clickable product on real data" },
{ v: "Machine", d: "8–16 weeks", t: "Designers and engineers in one team, shipping weekly.", out: "Software in production" },
{ v: "Run", d: "Years", t: "We stay on call. Most clients keep us for six years.", out: "An SLA with our names on it" },
],
methodFooter: "Then we keep it running.",
// Proof
proofTitle: "Measured,",
proofTitleSub: "not claimed.",
proofItems: [
{ n: "19", u: "days", l: "First call to code in production", w: 0.38 },
{ n: "9/11", u: "", l: "Products still in production after five years", w: 0.82 },
{ n: "6.4", u: "years", l: "Median client relationship", w: 0.64 },
{ n: "99.98", u: "%", l: "Uptime across every product we run, 36 months", w: 1 },
],
proofQuote: "“They argued us out of half the roadmap. The half that shipped is why we were acquired.”",
proofAuthor: "Ines Vaart · former CPO, Fernhill Payments",
// Stack / Bill of Materials
stackTitle: "Bill of materials.",
stackSubtitle: "Boring where it should be. Fast where it matters. Nothing we can’t run ourselves.",
tableHeaders: {
item: "Item",
part: "Part",
job: "Job",
tolerance: "Tolerance",
usedIn: "Used in",
},
bom: [
["Rust", "Settlement and scheduling engines", "p99 under 5 ms", "Ledgerline · Tidewater"],
["TypeScript · Vue · Nuxt", "Every product interface", "60 fps on a 2019 phone", "All"],
["PostgreSQL", "System of record", "30-day point-in-time recovery", "All"],
["Event streams", "Vessel, bank and site telemetry", "2.1 B events a day", "Tidewater · Halden"],
["On-device models", "Clause classification", "Runs offline · 40 MB", "Quire"],
["Own metal + Terraform", "Infrastructure", "99.98% over 36 months", "All"],
],
// Studio
studioTitle: "Small on purpose.",
studioTitleSub: "Precise by habit.",
studioLead: "A kerf is the width of a saw cut: the material you give up to make something exact. We give up scale, so every product gets the people who drew it.",
studioCells: [
["Studio", "Kerf B.V."],
["Established", "2014"],
["Studios", "Rotterdam · Montréal"],
["People", "42 · 31 engineers, 11 designers"],
["Engagements", "Four a year"],
["Scale", "1 : 1"],
],
// Contact
contactTag: "Taking two engagements for spring 2027",
contactTitle: "Bring us\nthe hard one.",
contactEmail: "hello@kerf.studio",
contactSubjectHard: "A%20hard%20one",
contactSubjectCall: "30-minute%20call",
contactCallBtn: "Book a 30-minute call",
contactFooter: "© 2026 Kerf B.V. · Sheet 7 of 7",
// Vernier Navigation
vernierSections: [
["cut", "Kerf"],
["p-ledgerline", "Ledgerline"],
["p-tidewater", "Tidewater"],
["p-quire", "Quire"],
["p-halden", "Halden"],
["method", "Method"],
["proof", "Proof"],
["stack", "Materials"],
["studio", "Studio"],
["contact", "Contact"],
],
startProject: "Start a project",
nowViewing: "Now viewing",
langButton: "فا",
langName: "فارسی",
},
};
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
alu: "#e3e4e0",
paper: "#f2f2ef",
ink: "#121413",
steel: "#565b57",
blaze: "#ff5b14",
chamber: "#0f1110",
"chamber-2": "#181b19",
fog: "#a9aea9",
},
fontFamily: {
sans: ['IRANSans', 'IRANSansX', '"IBM Plex Sans"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['IRANSans', 'IRANSansX', '"IBM Plex Mono"', 'ui-monospace', 'monospace'],
},
},
},
plugins: [],
}
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "@playwright/test";
test("loads without browser or network errors", async ({ page }) => {
const problems = [];
const report = (kind, detail) => problems.push(`${kind}: ${detail}`);
page.on("console", (message) => {
if (message.type() === "error") {
report("Console error", message.text());
}
});
page.on("pageerror", (error) => report("Uncaught exception", error.message));
page.on("requestfailed", (request) => report("Request failed", `${request.method()} ${request.url()}`));
page.on("response", (response) => {
if (response.status() >= 400) {
report("HTTP error", `${response.status()} ${response.url()}`);
}
});
await page.goto("/");
await expect(page.locator("#root > *").first()).toBeAttached();
expect(problems).toEqual([]);
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5180,
host: true,
open: true,
},
});