feat: initial commit for Kerf Studio with IRANSans typography, Persian localization, and RTL support
This commit is contained in:
+28
@@ -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
@@ -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>
|
||||
Generated
+2812
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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: "فارسی",
|
||||
},
|
||||
};
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user