feat: initial commit for landing rotating clock

This commit is contained in:
s.esmaeili
2026-10-04 06:54:23 +03:30
commit 2f0b60c42b
22 changed files with 13386 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
<!-- Signature 3 · the instrument comes apart: four physical layers = the shared platform. -->
<script setup lang="ts">
const story = useStory();
const layers = [
{ term: "اُم", title: "زیرساخت مشترک", text: "هویت، پرداخت، پایش و استقرار؛ یک بار ساخته شده، پانزده بار به کار رفته." },
{ term: "صفیحه", title: "یک زبان داده", text: "همهٔ محصولات روی یک مدل داده و یک تقویم شمسی با هم حرف می‌زنند." },
{ term: "عنکبوت", title: "پانزده محصول", text: "هر محصول جای ثابت خودش را دارد. چرخ می‌چرخد، نظم نمی‌شکند." },
{ term: "عضاده", title: "یک تیم", text: "چهل‌ودو مهندس و طراح که هر بار فقط یک چیز را نشانه می‌گیرند." },
];
</script>
<template>
<section id="anatomy" aria-labelledby="anat-h" class="relative" style="height: 340svh">
<div class="sticky top-0 flex h-[100svh] items-end px-[6vw] pb-[9vh] md:items-center md:pb-0">
<div class="md:w-[38vw]">
<h2 id="anat-h" class="text-[clamp(2.4rem,4.6vw,4.8rem)] font-black leading-[1.08]">یک سامانه،<br><span class="font-extralight">چهار لایه.</span></h2>
<ol class="mt-8 space-y-4 md:mt-12 md:space-y-6">
<li v-for="(l, i) in layers" :key="l.term" class="transition-all duration-700"
:class="[story.focus === -1 ? 'opacity-70' : story.focus === i ? 'opacity-100' : 'opacity-25',
story.mobile && story.focus !== -1 && story.focus !== i ? 'hidden' : '']">
<p class="flex items-baseline gap-3 text-[20px] font-bold md:text-[22px]">
<span :class="story.focus === i ? 'text-lapis' : ''">{{ l.title }}</span>
<span class="text-[15px] font-normal text-mute">«{{ l.term }}»</span>
</p>
<!-- Text is always in the DOM for screen readers; only its visual height animates. -->
<p class="max-w-[34ch] overflow-hidden text-[16px] leading-7 text-ink/80 transition-all duration-700 md:text-[17px]"
:class="story.focus === i ? 'mt-2 max-h-24' : 'max-h-0'">{{ l.text }}</p>
</li>
</ol>
</div>
</div>
</section>
</template>
+101
View File
@@ -0,0 +1,101 @@
<!--
The one canvas behind the whole page. Client-only: the HTML ships complete without it,
WebGL is created after hydration and never blocks first paint.
GSAP's ticker drives the frame; ScrollTrigger measures the sections and keeps them in sync on refresh.
-->
<script setup lang="ts">
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { createAstrolabe } from "~/lib/astrolabe";
import { storyAt, ecoScrollFor } from "~/lib/story";
import { products, STEP } from "~/data/products";
const canvas = ref<HTMLCanvasElement | null>(null);
const labels = ref<HTMLElement[]>([]);
const story = useStory();
let scene: ReturnType<typeof createAstrolabe> | null = null;
let metrics: any = null;
const triggers: ScrollTrigger[] = [];
onMounted(async () => {
await nextTick();
if (!canvas.value) {
canvas.value = document.querySelector("canvas");
}
if (!canvas.value) return;
gsap.registerPlugin(ScrollTrigger);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
// Lower-powered devices keep the same instrument, with half the engraving density and a capped DPR.
const lowPower = (navigator.hardwareConcurrency || 8) <= 4 || innerWidth < 600;
scene = createAstrolabe(canvas.value, { products, step: STEP, reducedMotion: reduced, lowPower });
if (!scene) return;
scene.set({ draw: 1 });
const eco = ScrollTrigger.create({ trigger: "#products", start: "top top", end: "bottom bottom" });
const anat = ScrollTrigger.create({ trigger: "#anatomy", start: "top top", end: "bottom bottom" });
const zij = ScrollTrigger.create({ trigger: "#zij", start: "top bottom", end: "bottom top" });
triggers.push(eco, anat, zij);
const measure = () => {
metrics = { vh: innerHeight, ecoTop: eco.start, ecoEnd: eco.end, zijTop: zij.start + innerHeight, anatTop: anat.start, anatEnd: anat.end };
story.value.mobile = innerWidth < 768 || innerWidth / innerHeight < 0.8;
scene?.resize();
};
ScrollTrigger.addEventListener("refresh", measure);
ScrollTrigger.refresh();
document.fonts?.ready?.then(() => ScrollTrigger.refresh());
scene.onFrame(({ center, stars }) => {
const s = story.value;
stars?.forEach(([x, y]: number[], i: number) => {
const el = labels.value[i]; if (!el) return;
let dx = x - center[0], dy = y - center[1]; const l = Math.hypot(dx, dy) || 1; dx /= l; dy /= l;
const show = s.inEco && (!s.mobile || i === s.active);
const off = i === s.active ? 26 : 16;
el.style.opacity = String(show ? (i === s.active ? 1 : 0.62) : 0);
el.style.pointerEvents = show ? "auto" : "none";
el.style.transform = `translate(${x + dx * off}px, ${y + dy * off}px) translate(${dx < -0.2 ? "-100%" : dx > 0.2 ? "0" : "-50%"}, -50%)`;
});
});
gsap.ticker.add(tick);
addEventListener("pointermove", onMove, { passive: true });
story.value.go = (i: number) => {
if (!metrics) return;
const n = Math.max(0, Math.min(14, i));
gsap.killTweensOf(window);
window.scrollTo({ top: ecoScrollFor(n, metrics), behavior: reduced ? "auto" : "smooth" });
};
});
function tick() {
if (!metrics || !scene) return;
const s = story.value;
const r = storyAt(window.scrollY, metrics, s.mobile);
scene.set({ ...r.target, active: r.inEco ? r.ecoIndex : s.hover, focus: r.focus });
if (r.inEco && r.ecoIndex !== s.active) { s.dir = r.ecoIndex > s.active ? 1 : -1; s.active = r.ecoIndex; }
if (r.inEco !== s.inEco) s.inEco = r.inEco;
if (r.focus !== s.focus) s.focus = r.focus;
}
const onMove = (e: PointerEvent) => scene?.pointer(e.clientX / innerWidth - 0.5, e.clientY / innerHeight - 0.5);
onBeforeUnmount(() => {
gsap.ticker.remove(tick);
removeEventListener("pointermove", onMove);
triggers.forEach((t) => t.kill());
scene?.dispose();
});
</script>
<template>
<canvas ref="canvas" aria-hidden="true" class="pointer-events-none fixed inset-0 z-0 h-[100lvh] w-full" />
<div class="pointer-events-none fixed inset-0 z-10" :aria-hidden="!story.inEco">
<button
v-for="(p, i) in products" :key="p.slug" :ref="(el) => (labels[i] = el as HTMLElement)"
tabindex="-1" class="star-label absolute left-0 top-0 whitespace-nowrap px-1 text-[14px] opacity-0"
:class="i === story.active ? 'font-bold text-verm' : 'text-ink'" @click="story.go?.(i)"
>{{ p.fa }}</button>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<template>
<section aria-label="مقیاس" class="px-[6vw] py-[20vh] md:py-[26vh]">
<p class="text-[18px] text-mute md:text-[20px]">هر روز</p>
<p class="mt-2 text-[clamp(3.4rem,10vw,11rem)] font-black leading-none">۲٫۴ میلیون نفر</p>
<p class="mt-5 max-w-[30ch] text-[clamp(1.4rem,2.6vw,2.4rem)] font-extralight leading-snug">با دست‌کم یکی از ابزارهای رصد کار می‌کنند.</p>
</section>
<section id="contact" aria-labelledby="contact-h" class="relative flex min-h-[100svh] items-end px-[6vw] pb-[14vh] md:items-center md:pb-0">
<div class="md:w-[46vw]">
<h2 id="contact-h" class="text-[clamp(2.6rem,5.4vw,5.8rem)] font-black leading-[1.05]">ابزار بعدی را<br><span class="font-extralight">با هم بسازیم.</span></h2>
<a href="mailto:hello@rasad.ir" class="mt-10 inline-block border-b-2 border-lapis pb-1 font-mono text-[clamp(1.2rem,2.2vw,1.9rem)] text-lapis md:mt-14"><bdi dir="ltr">hello@rasad.ir</bdi></a>
<dl class="mt-10 grid max-w-[520px] grid-cols-2 gap-6 text-[15px] md:mt-14">
<div><dt class="text-mute">تلفن</dt><dd class="mt-1 font-mono"><bdi dir="ltr">+98 21 8800 1500</bdi></dd></div>
<div><dt class="text-mute">نشانی</dt><dd class="mt-1 leading-7">تهران، خیابان ولیعصر، پلاک ۲۱۱۲</dd></div>
</dl>
</div>
</section>
<footer class="flex justify-between px-[6vw] pb-8 text-[14px] text-mute">
<span>© ۱۴۰۵ رصد</span><span>ساخته‌شده با دقت، در تهران</span>
</footer>
</template>
+74
View File
@@ -0,0 +1,74 @@
<!--
Signature 2 · the rete. 15 scroll steps inside one pinned viewport.
RTL semantics throughout: the next product waits on the inline-end (left) side, so
panels enter from the left, the progress rule fills right→left, ArrowLeft = next,
and a rightward swipe pulls the next star under the rule.
-->
<script setup lang="ts">
import { gsap } from "gsap";
import { products, families, pad } from "~/data/products";
const story = useStory();
const panel = ref<HTMLElement | null>(null);
const p = computed(() => products[story.value.active]);
const fam = computed(() => families.find((f) => f.id === p.value.family)!);
watch(() => story.value.active, async () => {
await nextTick();
if (!panel.value || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
gsap.fromTo(panel.value.children, { x: story.value.dir > 0 ? -36 : 36, opacity: 0 }, { x: 0, opacity: 1, duration: 0.7, ease: "expo.out", stagger: 0.06, overwrite: true });
});
let t0: [number, number] | null = null;
const onTouchStart = (e: TouchEvent) => { const t = e.touches[0]; t0 = [t.clientX, t.clientY]; };
const onTouchEnd = (e: TouchEvent) => {
if (!t0) return; const t = e.changedTouches[0];
const dx = t.clientX - t0[0], dy = t.clientY - t0[1]; t0 = null;
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.3) story.value.go?.(story.value.active + (dx > 0 ? 1 : -1));
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") { e.preventDefault(); story.value.go?.(story.value.active + 1); }
if (e.key === "ArrowRight") { e.preventDefault(); story.value.go?.(story.value.active - 1); }
};
</script>
<template>
<section id="products" aria-labelledby="eco-h" class="relative" style="height: calc(100svh + 15 * 62svh)">
<div
class="sticky top-0 h-[100svh] touch-pan-y overflow-hidden outline-none" tabindex="0" role="group"
aria-roledescription="چرخ محصولات" aria-label="پانزده محصول رصد؛ با کلیدهای جهت حرکت کنید"
@touchstart.passive="onTouchStart" @touchend="onTouchEnd" @keydown="onKey"
>
<h2 id="eco-h" class="sr-only">پانزده محصول، یک آسمان</h2>
<div class="absolute inset-x-0 bottom-[84px] px-[6vw] md:inset-y-0 md:bottom-auto md:start-0 md:end-auto md:flex md:w-[40vw] md:flex-col md:justify-center md:pb-[72px] md:pe-0 md:pt-[124px]" aria-live="polite">
<div ref="panel">
<div>
<p class="mb-2 flex items-baseline gap-3 text-[15px] md:mb-4">
<span class="font-medium tabular-nums">{{ pad(story.active + 1) }}<span class="text-mute"> / ۱۵</span></span>
<span class="text-verm">{{ fam.fa }}</span>
<span class="ms-auto text-[14px] text-mute md:hidden">بکشید ↔</span>
</p>
<h3 class="flex items-baseline gap-4 leading-none">
<span class="text-[clamp(3rem,min(7vw,12vh),7.4rem)] font-black">{{ p.fa }}</span>
<bdi dir="ltr" class="font-mono text-[15px] text-mute">{{ p.en }}</bdi>
</h3>
<p class="mt-3 max-w-[32ch] text-[17px] leading-7 md:mt-5 md:text-[20px] md:leading-8">{{ p.line }}</p>
</div>
<div class="mt-5 max-w-[470px] md:mt-8">
<ProductScreen :product="p" :compact="story.mobile" />
<p class="mt-3 flex items-center gap-2 text-[15px] md:mt-4"><span class="h-[6px] w-[6px] rounded-full bg-verm" />{{ p.value }}</p>
</div>
</div>
</div>
<nav aria-label="فهرست محصولات" class="absolute inset-x-[6vw] bottom-7 flex items-end gap-[6px] md:bottom-9">
<button
v-for="(pr, i) in products" :key="pr.slug" :aria-label="`${pr.fa} (${pr.en})`" :aria-current="i === story.active"
class="group flex h-6 flex-1 items-end" :class="i > 0 && pr.family !== products[i - 1].family ? 'ms-3 md:ms-5' : ''"
@click="story.go?.(i)"
>
<span class="block w-full transition-all duration-500"
:class="i === story.active ? 'h-[18px] bg-verm' : i < story.active ? 'h-[3px] bg-ink' : 'h-[3px] bg-ink/20 group-hover:bg-ink/50'" />
</button>
</nav>
</div>
</section>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { gsap } from "gsap";
const root = ref<HTMLElement | null>(null);
onMounted(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Text arrives after the engraving has started drawing itself (~0.9s).
gsap.from(root.value!.querySelectorAll("[data-rise]"), { y: 18, opacity: 0, duration: 1, ease: "power3.out", stagger: 0.2, delay: 0.9 });
});
</script>
<template>
<section ref="root" class="relative flex h-[100svh] items-end pb-[6vh] md:items-center md:pb-0" aria-labelledby="h1">
<div class="px-[6vw] md:w-[47vw] md:pe-0">
<p data-rise class="mb-5 text-[15px] text-mute md:mb-8">
استودیوی محصول‌سازی · تهران <bdi dir="ltr" class="font-mono text-[13px]">35°42′N</bdi>
</p>
<h1 id="h1" class="leading-[1.02]">
<span data-rise class="block text-[clamp(3.3rem,7.4vw,8.6rem)] font-black">ابزار دقیق</span>
<span data-rise class="block text-[clamp(2.2rem,4.6vw,5.4rem)] font-extralight">برای کارِ هر روز.</span>
</h1>
<p data-rise class="mt-6 max-w-[30ch] text-[18px] leading-8 text-ink/80 md:mt-9 md:text-[20px]">
رصد <b class="font-bold text-ink">پانزده محصول نرم‌افزاری</b> می‌سازد و نگه می‌دارد. هرکدام دقیق، همه روی یک سامانه.
</p>
<a data-rise href="#products" class="mt-8 inline-flex items-center gap-3 text-[16px] font-medium text-lapis md:mt-12">
<span class="h-px w-10 bg-lapis" />چرخ را بچرخانید
</a>
</div>
</section>
</template>
+126
View File
@@ -0,0 +1,126 @@
<!-- Product-specific miniature interfaces, built from type and line, no screenshots to download. -->
<script setup lang="ts">
const props = defineProps<{ product: { fa: string; en: string; kind: string }; compact?: boolean }>();
const k = computed(() => props.product.kind);
const ledger = [["فروش آنلاین", "۱۲٬۴۰۰٬۰۰۰"], ["کارمزد درگاه", "۱۸۶٬۰۰۰"], ["مالیات بر ارزش افزوده", "۱٬۱۱۶٬۰۰۰"], ["حمل و ارسال", "۹۴۰٬۰۰۰"]];
const payroll = [["مریم احمدی", "۴۲٬۸۰۰٬۰۰۰"], ["علی رضایی", "۳۸٬۲۰۰٬۰۰۰"], ["سارا کریمی", "۳۶٬۵۰۰٬۰۰۰"], ["رضا موسوی", "۳۱٬۹۰۰٬۰۰۰"]];
const bars = [30, 42, 38, 55, 48, 62, 58, 70, 66, 80, 76, 92];
const store = [["کوزهٔ لعابی", "۸۲۰", "#2341B8"], ["گلیم ۲×۳", "۴٬۹۰۰", "#C2330F"], ["قوری مسی", "۱٬۲۵۰", "#14213A"]];
const services = [["auth", true], ["checkout", true], ["search", false], ["media", true]] as const;
const results = ["پیش‌نویس قرارداد اجاره — واحد حقوقی", "صورت‌جلسهٔ تمدید اجاره — شعبهٔ شیراز", "پیوست مالی قرارداد — ۱۴۰۲/۰۶"];
const wave = Array.from({ length: 48 }, (_, i) => Math.min(100, 18 + Math.abs(Math.sin(i * 1.7) * 60) + (i % 5) * 4));
const apps = [["دفتر", true], ["پیام", true], ["سنجه", false], ["بندر", true]] as const;
const buckets = [["media-prod", "۷۸%", "78%"], ["backups", "۵۲%", "52%"], ["logs-archive", "۳۱%", "31%"]];
const days = ["ش", "ی", "د", "س", "چ", "پ"];
const booked = new Set([1, 2, 7, 9, 10, 14, 15, 16, 20, 22]);
</script>
<template>
<figure class="flex flex-col overflow-hidden rounded-[12px] border border-ink/10 bg-surface text-ink" :class="compact ? 'h-[min(176px,22svh)]' : 'h-[min(270px,32vh)]'">
<figcaption class="flex items-center justify-between border-b border-ink/[0.07] px-4 py-[9px] text-[14px] text-mute">
<span>{{ product.fa }}</span>
<bdi dir="ltr" class="font-mono text-[13px] tracking-wide">{{ product.en.toLowerCase() }}.rasad.ir</bdi>
</figcaption>
<div class="min-h-0 flex-1 overflow-hidden" :class="compact ? 'p-3' : 'p-5'">
<div v-if="k === 'pay'" class="flex h-full flex-col justify-between">
<div>
<p class="text-[14px] text-mute">تسویهٔ امروز</p>
<p class="mt-1 text-[clamp(1.6rem,3vw,2.4rem)] font-extrabold leading-none">۲٬۴۸۰٬۰۰۰<span class="ms-2 text-[14px] font-normal text-mute">تومان</span></p>
</div>
<div class="flex h-16 items-end gap-[5px]">
<span v-for="(h, i) in bars" :key="i" class="flex-1 rounded-t-[2px]" :class="i === 11 ? 'bg-verm' : 'bg-lapis/25'" :style="{ height: h + '%' }" />
</div>
</div>
<div v-else-if="k === 'ledger' || k === 'payroll'" class="flex h-full flex-col justify-center gap-[10px] text-[14px]">
<div v-for="([a, b], i) in (k === 'ledger' ? ledger : payroll)" :key="i" class="flex items-center justify-between gap-3" :class="i < 3 ? 'border-b border-ink/[0.07] pb-[9px]' : ''">
<span>{{ a }}</span><span class="font-medium tabular-nums">{{ b }}</span>
</div>
</div>
<div v-else-if="k === 'store'" class="grid h-full grid-cols-3 gap-3">
<div v-for="[n, p, c] in store" :key="n" class="flex flex-col gap-2">
<div class="flex-1 rounded-[6px]" :style="{ background: `repeating-linear-gradient(135deg, ${c}22 0 6px, ${c}0d 6px 12px)` }" />
<p class="truncate text-[14px]">{{ n }}</p><p class="text-[14px] text-mute">{{ p }} هزار</p>
</div>
</div>
<div v-else-if="k === 'chart'" class="flex h-full flex-col gap-2">
<p class="text-[14px] text-mute">«فروش هفتگی تهران در برابر اصفهان»</p>
<svg viewBox="0 0 300 110" class="w-full flex-1" preserveAspectRatio="none" aria-hidden="true">
<line v-for="y in [20, 50, 80]" :key="y" x1="0" x2="300" :y1="y" :y2="y" stroke="#14213A" stroke-opacity=".08" />
<polyline fill="none" stroke="#2341B8" stroke-width="2" points="300,80 260,70 220,74 180,52 140,58 100,36 60,40 20,18" />
<polyline fill="none" stroke="#14213A" stroke-opacity=".35" stroke-width="1.5" stroke-dasharray="4 4" points="300,92 260,88 220,84 180,80 140,70 100,72 60,62 20,58" />
<circle cx="100" cy="36" r="4" fill="#C2330F" />
</svg>
</div>
<div v-else-if="k === 'monitor'" class="grid h-full grid-cols-2 gap-x-5 gap-y-3 text-[14px]">
<div v-for="[n, ok] in services" :key="n" class="flex flex-col justify-center gap-1">
<div class="flex items-center justify-between"><bdi dir="ltr" class="font-mono">{{ n }}</bdi><span :class="ok ? 'text-lapis' : 'text-verm'">{{ ok ? 'سالم' : 'کُند' }}</span></div>
<svg viewBox="0 0 100 24" class="h-6 w-full" preserveAspectRatio="none" aria-hidden="true">
<polyline fill="none" :stroke="ok ? '#2341B8' : '#C2330F'" stroke-width="1.5" :points="ok ? '0,16 15,14 30,17 45,12 60,15 75,13 100,14' : '0,18 20,16 40,15 55,6 70,9 85,4 100,7'" />
</svg>
</div>
</div>
<div v-else-if="k === 'search'" class="flex h-full flex-col gap-3 text-[14px]">
<div class="rounded-[6px] border border-ink/15 px-3 py-2">قرارداد اجارهٔ دفتر <span class="text-mute">۱۴۰۲</span></div>
<p v-for="t in results" :key="t" class="truncate"><mark class="bg-lapis/[0.12] text-inherit">{{ t.split(' ')[0] }}</mark> {{ t.split(' ').slice(1).join(' ') }}</p>
</div>
<div v-else-if="k === 'wave'" class="flex h-full flex-col justify-between gap-3">
<div class="flex h-14 items-center gap-[3px]">
<span v-for="(h, i) in wave" :key="i" class="flex-1 rounded-full" :class="i > 30 ? 'bg-ink/15' : 'bg-lapis'" :style="{ height: h + '%' }" />
</div>
<p class="text-[15px] leading-7">سلام، برای پیگیری سفارشم تماس گرفتم، <span class="rounded-[3px] bg-verm/[0.12] px-1 text-verm">کِی</span> می‌رسه؟</p>
</div>
<div v-else-if="k === 'terminal'" dir="ltr" class="flex h-full flex-col justify-center gap-[7px] text-left font-mono text-[14px]">
<p><span class="text-mute">$</span> bandar deploy --prod</p>
<p class="text-mute">build ······ 14.2s</p><p class="text-mute">health ····· 3/3 ok</p>
<p class="text-mute">traffic ···· 0 → 100%</p><p class="text-lapis">● live in 41s</p>
</div>
<div v-else-if="k === 'keys'" class="flex h-full flex-col justify-center gap-[10px] text-[14px]">
<div v-for="[n, on] in apps" :key="n" class="flex items-center justify-between">
<span>{{ n }}</span>
<span class="relative h-[18px] w-8 rounded-full" :class="on ? 'bg-lapis' : 'bg-ink/15'">
<span class="absolute top-[3px] h-3 w-3 rounded-full bg-white" :class="on ? 'end-[3px]' : 'start-[3px]'" />
</span>
</div>
</div>
<div v-else-if="k === 'storage'" class="flex h-full flex-col justify-center gap-3 text-[14px]">
<div v-for="[n, p, w] in buckets" :key="n" class="flex flex-col gap-[6px]">
<div class="flex items-center justify-between"><bdi dir="ltr" class="font-mono">{{ n }}</bdi><span class="text-mute">{{ p }}</span></div>
<span class="h-[6px] rounded-full bg-ink/[0.07]"><span class="block h-[6px] rounded-full bg-lapis" :style="{ width: w }" /></span>
</div>
</div>
<div v-else-if="k === 'api'" dir="ltr" class="flex h-full flex-col justify-center gap-[6px] text-left font-mono text-[14px]">
<p><span class="text-lapis">GET</span> /v2/orders/8841 <span class="text-mute">200 · 2.1ms</span></p>
<p class="text-mute">{</p><p class="ps-4">"status": <span class="text-verm">"shipped"</span>,</p><p class="ps-4">"city": "Tabriz"</p><p class="text-mute">}</p>
</div>
<div v-else-if="k === 'chat'" class="flex h-full flex-col justify-center gap-2 text-[14px]">
<p class="max-w-[80%] self-start rounded-[12px] rounded-ss-[3px] bg-ink/[0.06] px-3 py-2">نسخهٔ جدید «بندر» آماده است؟</p>
<p class="max-w-[80%] self-end rounded-[12px] rounded-se-[3px] bg-lapis px-3 py-2 text-white">بله، ساعت ۱۶ منتشر می‌شود.</p>
<p class="self-start text-mute">نیلوفر در حال نوشتن…</p>
</div>
<div v-else-if="k === 'calendar'" class="grid h-full grid-cols-6 gap-[5px] text-center text-[14px]">
<span v-for="d in days" :key="d" class="text-mute">{{ d }}</span>
<span v-for="i in 24" :key="i" class="rounded-[4px]" :class="i - 1 === 9 ? 'bg-verm' : booked.has(i - 1) ? 'bg-lapis/70' : 'bg-ink/[0.06]'" />
</div>
<svg v-else-if="k === 'map'" viewBox="0 0 300 140" class="h-full w-full" aria-hidden="true">
<line v-for="i in 7" :key="'v' + i" :x1="(i - 1) * 50" :x2="(i - 1) * 50 + 20" y1="0" y2="140" stroke="#14213A" stroke-opacity=".07" />
<line v-for="i in 4" :key="'h' + i" x1="0" x2="300" :y1="(i - 1) * 40 + 10" :y2="(i - 1) * 40 + 18" stroke="#14213A" stroke-opacity=".07" />
<path d="M280 120 C 240 110, 230 70, 190 72 S 130 40, 100 52 S 40 30, 22 20" fill="none" stroke="#2341B8" stroke-width="2.5" />
<circle v-for="([x, y], i) in [[280, 120], [190, 72], [100, 52], [22, 20]]" :key="i" :cx="x" :cy="y" :r="i === 3 ? 6 : 4"
:fill="i === 3 ? '#C2330F' : '#F8F9FB'" :stroke="i === 3 ? 'none' : '#2341B8'" stroke-width="2" />
</svg>
</div>
</figure>
</template>
+14
View File
@@ -0,0 +1,14 @@
<template>
<header class="header-veil fixed inset-x-0 top-0 z-30 flex items-center justify-between px-[6vw] pb-8 pt-5">
<a href="#top" class="flex items-baseline gap-2" aria-label="رصد، صفحهٔ اصلی">
<span class="text-[28px] font-black leading-none">رصد</span>
<span class="h-[7px] w-[7px] rounded-full bg-verm" />
</a>
<nav aria-label="اصلی" class="flex items-center gap-7 text-[15px]">
<a href="#products" class="hidden hover:text-lapis md:inline">محصولات</a>
<a href="#zij" class="hidden hover:text-lapis md:inline">زیج</a>
<a href="#anatomy" class="hidden hover:text-lapis md:inline">ساختار</a>
<a href="#contact" class="border-b border-ink pb-[2px] hover:border-lapis hover:text-lapis">گفت‌وگو</a>
</nav>
</header>
</template>
+45
View File
@@ -0,0 +1,45 @@
<!-- The star table: every product in real, crawlable HTML. Hover / focus turns the rete to that star. -->
<script setup lang="ts">
import { products, families, fa } from "~/data/products";
const story = useStory();
const set = (i: number) => (story.value.hover = i);
</script>
<template>
<section id="zij" aria-labelledby="zij-h" class="relative px-[6vw] pb-[18vh] pt-[46vh] md:pt-[20vh]">
<div class="md:w-[58vw]">
<h2 id="zij-h" class="text-[clamp(3rem,6vw,6.4rem)] font-black leading-none">زیج</h2>
<p class="mt-4 max-w-[36ch] text-[18px] leading-8 text-ink/80 md:text-[20px]">
منجم‌ها ستاره‌ها را در زیج می‌نوشتند. این زیجِ رصد است: پانزده محصول در چهار صورت فلکی.
</p>
<table class="mt-12 w-full border-collapse text-start text-[15px] md:mt-16 md:text-[16px]">
<caption class="sr-only">فهرست کامل محصولات رصد</caption>
<thead class="text-[14px] text-mute">
<tr class="border-b border-ink/15">
<th scope="col" class="py-3 text-start font-normal">محصول</th>
<th scope="col" class="hidden py-3 text-start font-normal md:table-cell">کار</th>
<th scope="col" class="py-3 text-end font-normal">مقیاس</th>
<th scope="col" class="hidden py-3 text-end font-normal md:table-cell">از</th>
</tr>
</thead>
<tbody v-for="f in families" :key="f.id">
<tr><th colspan="4" scope="rowgroup" class="pb-2 pt-9 text-start text-[14px] font-medium text-verm">{{ f.fa }}</th></tr>
<template v-for="(pr, i) in products" :key="pr.slug">
<tr v-if="pr.family === f.id" tabindex="0"
class="border-b border-ink/[0.08] outline-none transition-colors hover:bg-ink/[0.035] focus-visible:bg-lapis/[0.06]"
@mouseenter="set(i)" @mouseleave="set(-1)" @focus="set(i)" @blur="set(-1)" @click="set(i)">
<th scope="row" class="py-4 text-start align-top font-normal">
<span class="text-[19px] font-bold">{{ pr.fa }}</span>
<bdi dir="ltr" class="ms-2 font-mono text-[13px] text-mute">{{ pr.en }}</bdi>
<span class="mt-1 block text-[15px] leading-6 text-ink/75 md:hidden">{{ pr.line }}</span>
</th>
<td class="hidden py-4 align-top leading-7 text-ink/80 md:table-cell">{{ pr.line }}</td>
<td class="whitespace-nowrap py-4 ps-4 text-end align-top tabular-nums">{{ pr.users }}</td>
<td class="hidden py-4 ps-4 text-end align-top tabular-nums text-mute md:table-cell">{{ fa(pr.year) }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</template>