Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223<script lang="ts"> import "#lib/components/landing/iso-stack.css"; import { onMount, untrack } from "svelte"; import { prefersReducedMotion } from "svelte/motion"; import { SvelteSet } from "svelte/reactivity"; import { Live } from "#lib/components/landing/live.svelte.ts"; import type { Notification, Party } from "#lib/components/landing/hero/notifications.ts"; import { compactRelativeTime } from "#lib/format.ts";
/* * The hero's notification stack, laid flat in iso. It feeds itself, oldest first, so the * stack reads newest on top: a newer notification drops in at the top, off the thread's knot, and the others step down a * slot — smaller and dimmer each step, the stack receding — until the * oldest fades out at the bottom. Pauses while hovered or focused, off screen or in a hidden tab * (Live); reduced motion keeps it still. * * Laid out in the hero stage's frame (440 × 356). Hidden until `shown`, then the * cards land one by one, top first. Given no notifications it stays empty (the hero never * passes none: it falls back to hero/mock.ts). * * Each card says on the right how long ago it happened. The * time is the browser's alone (the server's clock would disagree by the time it hydrates): * it fills in on mount, while the cards are still landing. */ interface Props { /** What the stack feeds on, newest first; it loops through them. */ notifications: Notification[]; shown: boolean; /** The notifications are a recording (hero/mock.ts): their times count from the newest * one, as if it happened as the page opened. */ replay?: boolean; /** Called once the last card has landed. */ onlanded?: () => void; }
let { notifications, shown, replay = false, onlanded }: Props = $props();
/** The browser's clock, ticking each second while the stack is live; null until mount. */ let now = $state<number | null>(null); let opened = $state(0); onMount(() => (now = opened = Date.now()));
/** How far a recording's times are moved up, so its newest lands on the page's opening. */ const shift = $derived.by(() => { if (!replay || !opened) return 0; const times = notifications.map((n) => (n.at ? Date.parse(n.at) : NaN)); const newest = Math.max(...times.filter((t) => !Number.isNaN(t))); return Number.isFinite(newest) ? opened - newest : 0; });
const ago = (at: string | undefined) => at && now !== null ? compactRelativeTime(new Date(Date.parse(at) + shift), new Date(now)) : "";
/** Faces that wouldn't load (no avatar set): their cards show the initial instead, and * later cards with the same face skip the failed request. */ const faceless = new SvelteSet<string>();
/** The slots, top first: each card's centre and how bright its strokes are. They step * straight down, so a card keeps its size all the way. One more, under them, is where * the oldest fades out. */ const SLOTS = [ { x: 162, y: 120, lit: 1 }, { x: 162, y: 164, lit: 0.75 }, { x: 162, y: 208, lit: 0.5 }, { x: 162, y: 252, lit: 0.3 }, { x: 162, y: 296, lit: 0 } ]; const SHOWN = 4; /** Between the cards landing on load, bottom one first. */ const STAGGER = 70; const FIRST_AT = 1000; const EVERY = 1000;
interface Item { key: number; n: Notification; }
/** The list oldest first, the order the stack plays it in. */ const feed = $derived(notifications.toReversed()); /** The first cards: the oldest few, the newest of them on top. */ const firstOf = (list: Notification[]) => list.toReversed().slice(0, SHOWN).toReversed();
/** The next notification to drop in, as an index into the feed. */ let next = SHOWN; /** Every card's key, never reused — the list can change under the stack. */ let keys = SHOWN; /** How many of the first cards have landed, bottom up. */ let landed = $state(0); let items = $state<Item[]>( firstOf(untrack(() => notifications)).map((n, key) => ({ key: SHOWN - 1 - key, n })) ); let hovered = $state(false); /** Focus is on one of the cards' links: the stack holds, so the card being read stays * in its slot instead of stepping down until it goes inert and drops the focus. */ let focused = $state(false); const live = new Live();
// Land the first cards, the bottom one first, so the newest lands last on top. $effect(() => { if (!shown) return; if (prefersReducedMotion.current) { landed = SHOWN; onlanded?.(); return; } const timers = Array.from({ length: SHOWN }, (_, i) => setTimeout(() => { landed = i + 1; if (i === SHOWN - 1) setTimeout(() => onlanded?.(), 200); }, i * STAGGER) ); return () => timers.forEach(clearTimeout); });
function push() { if (!feed.length) return; const n = feed[next % feed.length]; items = [{ key: keys++, n }, ...items].slice(0, SHOWN + 1); next += 1; }
// A new list (the review toggle): start on it now, from its oldest, rather than waiting // for the old cards to cycle out. let fed = untrack(() => notifications); $effect(() => { if (notifications === fed) return; fed = notifications; next = 0; if (untrack(() => landed) < SHOWN) { items = firstOf(notifications).map((n) => ({ key: keys++, n })); next = SHOWN; return; } untrack(push); });
$effect(() => { if (!opened || !live.running) return; const tick = setInterval(() => (now = Date.now()), 1000); return () => clearInterval(tick); });
$effect(() => { if ( landed < SHOWN || !notifications.length || hovered || focused || !live.running || prefersReducedMotion.current ) return; let timer: ReturnType<typeof setTimeout>; const schedule = (delay: number) => { timer = setTimeout(() => { push(); schedule(EVERY); }, delay); }; schedule(keys === SHOWN ? FIRST_AT : EVERY); return () => clearTimeout(timer); });</script>
{#snippet party(p: Party)} <!-- A new tab, so the visitor keeps their place on the landing page. --> <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- profiles and repos --> <a class="ip" href={p.href} target="_blank" rel="noopener noreferrer"> {#if p.avatar}<span class={["avatar", p.logo && "is-logo"]} >{#if !faceless.has(p.avatar)}<img src={p.avatar} alt="" width="34" height="34" onerror={(e) => { if (p.initial) faceless.add(p.avatar!); else (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} />{:else}<span class="avatar-initial" aria-hidden="true">{p.initial}</span >{/if}</span >{/if} <span class="in" title={p.name}>{p.name}</span> </a>{/snippet}
<!-- A demo of the app's activity feed, not a live region: announcing it would be noise. --><ol class="iso" aria-label="Example activity" {@attach live.watch} onpointerenter={(e) => (hovered = e.pointerType !== "touch")} onpointerleave={() => (hovered = false)} onfocusin={() => (focused = true)} onfocusout={(e) => (focused = e.currentTarget.contains(e.relatedTarget as Node | null))}> {#each items as { key, n }, i (key)} <!-- On load the cards land from the bottom slot up. --> {#if SHOWN - 1 - i < landed || i >= SHOWN} {@const slot = SLOTS[i]} {@const when = ago(n.at)} <li class={["ii at", i >= SHOWN && "gone"]} inert={i >= SHOWN} style:--x="{slot.x}px" style:--y="{slot.y}px" style:--lit={slot.lit} style:z-index={10 - i} > <span class="icard composite rim rim-copy" aria-hidden="true"></span> <span class="icard glass composite rim glint"> {@render party(n.actor)} <span class="iv">{n.verb}</span> {@render party(n.subject)} {#if when}<time class="it" datetime={n.at}>{when}</time>{/if} </span> </li> {/if} {/each}</ol>