From a8481af89b70670655199d4e9890ad9a0b901afd Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Thu, 19 Feb 2026 09:56:30 +0100 Subject: [PATCH] add liveline page: recreation of benji.org/liveline editorial design Recreates the benji.org/liveline page in the website folder using React, Tailwind, and the same editorial design system. Charts are replaced with static SVG placeholders. Typography: - Inter variable font from rsms.me (same source as next/font) - font-weight 475 body, 560 headings, font-optical-sizing: auto - 14px body, 20px line-height, -0.09px letter-spacing - Near-black text rgb(17,17,17), 40% opacity secondary - Display P3 accent colors with @supports fallback Layout: - 550px narrow content column, fixed TOC sidebar - Stagger-in entrance animation with cascading delays - Fixed header fade gradient (multi-stop white-to-transparent) - Scroll-spy TOC highlighting via IntersectionObserver Code blocks: - Prism.js syntax highlighting with GitHub-light color palette - Line numbers column with 20px right padding - 12px / 18px font size Props tables, dividers, captions, code blocks, bullet lists all styled to match the original computed values. --- pnpm-lock.yaml | 21 +- website/package.json | 2 + website/src/root.tsx | 15 + website/src/routes/liveline.tsx | 990 ++++++++++++++++++++++++++ website/src/styles/liveline-prism.css | 135 ++++ website/src/styles/liveline.css | 147 ++++ 6 files changed, 1308 insertions(+), 2 deletions(-) create mode 100644 website/src/routes/liveline.tsx create mode 100644 website/src/styles/liveline-prism.css create mode 100644 website/src/styles/liveline.css diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b98131d..9db9be0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -427,6 +427,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.18) + '@types/prismjs': + specifier: ^1.26.6 + version: 1.26.6 '@vercel/react-router': specifier: ^1.2.4 version: 1.2.4(@react-router/dev@7.11.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(tsx@4.20.6)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))(yaml@2.6.0))(@react-router/node@7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3))(isbot@5.1.32)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -442,6 +445,9 @@ importers: lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) + prismjs: + specifier: ^1.30.0 + version: 1.30.0 react: specifier: ^19.2.3 version: 19.2.3 @@ -499,7 +505,7 @@ importers: version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)) vitest: specifier: ^4.0.16 - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.8(vitest@4.0.8))(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0) packages: @@ -2773,6 +2779,9 @@ packages: '@types/pg@8.6.1': resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -5467,6 +5476,10 @@ packages: engines: {node: '>=14'} hasBin: true + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -9257,6 +9270,8 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 + '@types/prismjs@1.26.6': {} + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -12368,6 +12383,8 @@ snapshots: prettier@3.7.4: {} + prismjs@1.30.0: {} + process-warning@5.0.0: {} progress@2.0.3: {} @@ -13516,7 +13533,7 @@ snapshots: tsx: 4.20.6 yaml: 2.6.0 - vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.8(vitest@4.0.8))(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0): dependencies: '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)) diff --git a/website/package.json b/website/package.json index 91caa9e..a6b2e1c 100644 --- a/website/package.json +++ b/website/package.json @@ -16,11 +16,13 @@ "@react-router/node": "^7.11.0", "@sentry/node": "^10.32.1", "@tailwindcss/typography": "^0.5.19", + "@types/prismjs": "^1.26.6", "@vercel/react-router": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "isbot": "^5.1.32", "lucide-react": "^0.562.0", + "prismjs": "^1.30.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router": "^7.11.0", diff --git a/website/src/root.tsx b/website/src/root.tsx index edb45f8..9943cb3 100644 --- a/website/src/root.tsx +++ b/website/src/root.tsx @@ -15,6 +15,21 @@ export function Layout({ children }: { children: React.ReactNode }) { + + + {/* Inter from rsms (same source as next/font) for weight fidelity */} + + diff --git a/website/src/routes/liveline.tsx b/website/src/routes/liveline.tsx new file mode 100644 index 0000000..579bf88 --- /dev/null +++ b/website/src/routes/liveline.tsx @@ -0,0 +1,990 @@ +/* + * Recreation of benji.org/liveline page design. + * Uses the same editorial layout, typography, and styling system. + * Chart canvas components are replaced with dark placeholder boxes. + * + * Prism.js is used for syntax highlighting with a custom light theme + * that matches the original benji.org subtle code block style. + */ + +import { useEffect, useRef, useState } from "react"; +import Prism from "prismjs"; +import "prismjs/components/prism-jsx"; +import "prismjs/components/prism-tsx"; +import "prismjs/components/prism-bash"; +import "website/src/styles/liveline.css"; +import "website/src/styles/liveline-prism.css"; + +/* ========================================================================= + TOC sidebar (fixed left) + ========================================================================= */ + +/* + * Tracks which section heading is currently in the top ~25% of the viewport. + * Uses IntersectionObserver with rootMargin to create a detection zone: + * -80px top (fixed header offset), -75% bottom (only top quarter triggers). + * When multiple headings intersect, the last one in DOM order wins since + * that's the section the user is actually reading. + */ +function useActiveTocId() { + const [activeId, setActiveId] = useState(""); + + useEffect(() => { + const headings = document.querySelectorAll("h1[id]"); + if (headings.length === 0) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + // Collect all currently-intersecting heading ids + const visible: string[] = []; + entries.forEach((entry) => { + if (entry.isIntersecting && entry.target.id) { + visible.push(entry.target.id); + } + }); + + if (visible.length > 0) { + // Pick the last one in DOM order (furthest down the page) + const sorted = visible.sort((a, b) => { + const elA = document.getElementById(a); + const elB = document.getElementById(b); + if (!elA || !elB) { + return 0; + } + return elA.getBoundingClientRect().top - elB.getBoundingClientRect().top; + }); + setActiveId(sorted[sorted.length - 1]); + } + }, + { + // 80px for fixed header, bottom -75% so only top quarter triggers + rootMargin: "-80px 0px -75% 0px", + threshold: 0, + }, + ); + + headings.forEach((heading) => { + observer.observe(heading); + }); + + return () => { + observer.disconnect(); + }; + }, []); + + return activeId; +} + +const tocItems = [ + { label: "Getting started", href: "#getting-started" }, + { label: "Momentum", href: "#momentum" }, + { label: "Value overlay", href: "#value-overlay" }, + { label: "Time windows", href: "#time-windows" }, + { label: "Reference line", href: "#reference-line" }, + { label: "Orderbook", href: "#orderbook" }, + { label: "Theming", href: "#theming" }, + { label: "More features", href: "#more-features" }, + { label: "How it works", href: "#how-it-works" }, + { label: "Props", href: "#props" }, + { label: "Stress testing", href: "#stress-testing" }, + { label: "Just a line", href: "#just-a-line" }, + { label: "Acknowledgements", href: "#acknowledgements" }, +]; + +function TableOfContents() { + const activeId = useActiveTocId(); + + return ( + + ); +} + +/* ========================================================================= + Back button (fixed top-right) + ========================================================================= */ + +function BackButton() { + return ( + { + e.currentTarget.style.color = "rgba(0, 0, 0, 0.7)"; + e.currentTarget.style.transform = "scale(1.05)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = "rgba(0, 0, 0, 0.4)"; + e.currentTarget.style.transform = "scale(1)"; + }} + onMouseDown={(e) => { + e.currentTarget.style.transform = "scale(0.95)"; + }} + onMouseUp={(e) => { + e.currentTarget.style.transform = "scale(1.05)"; + }} + > + + + + + ); +} + +/* ========================================================================= + Reusable components + ========================================================================= */ + +function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function Paragraph({ children, className = "" }: { children: React.ReactNode; className?: string }) { + return ( +

+ {children} +

+ ); +} + +function Caption({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function Divider() { + return ( +
+ ); +} + +function CodeBlock({ children, lang = "jsx" }: { children: string; lang?: string }) { + const codeRef = useRef(null); + const lines = children.split("\n"); + + useEffect(() => { + if (codeRef.current) { + Prism.highlightElement(codeRef.current); + } + }, [children]); + + return ( +
+
+
+          
+ {/* Line numbers */} + + {/* Highlighted code */} + + {children} + +
+
+
+
+ ); +} + +function InlineCode({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function ChartPlaceholder({ height = 200, label }: { height?: number; label?: string }) { + return ( +
+
+ {/* Simulated chart line */} + + + + + + + + + + {/* Live dot */} + + + + + + {label && ( +
+ {label} +
+ )} +
+
+ ); +} + +function PropsTable({ + title, + rows, +}: { + title?: string; + rows: Array<[string, string, string]>; +}) { + return ( +
+ {title && ( +
+ {title} +
+ )} + + + + {["Prop", "Type", "Default"].map((header) => { + return ( + + ); + })} + + + + {rows.map(([prop, type, def]) => { + return ( + + + + + + ); + })} + +
+ {header} +
+ {prop} + + {type} + + {def} +
+
+ ); +} + +/* ========================================================================= + Main page + ========================================================================= */ + +export default function LivelinePage() { + return ( +
+ + + +
+ {/* Page header */} +
+

+ Liveline +

+ +
+ +
+ {/* Intro */} + + Liveline is a real-time animated line chart component for React. One{" "} + {""}, no dependencies beyond React + 18, smooth interpolation at 60fps. + + + + + Degen mode (chart shake and particles) with momentum arrows. + + + + I built this because every charting library I tried was either too + heavy for a simple live feed, or too rigid to feel alive. Liveline + does one thing: draw a line that moves smoothly as new data arrives. + Everything else is opt-in. + + + + + {/* Getting started */} + Getting started + + npm install liveline + + + The component fills its parent container. Set a height on the + wrapper. + + + + {`import { Liveline } from 'liveline' + +function Chart({ data, value }) { + return ( +
+ +
+ ) +}`} +
+ + + data is an array of{" "} + {"{ time, value }"} points.{" "} + value is the latest number. + + + + Two props. That's it. + + + Feed it data however you like. WebSocket, polling, random walk. + Liveline interpolates between updates so even infrequent data looks + smooth. It works for anything with a value that changes over time. + + + + + Resting heart rate. Custom formatter, exaggerated Y-axis. + + + + + {/* Momentum */} + Momentum + + + The momentum prop adds directional arrows + and a glow to the live dot. Green for up, red for down, grey for + flat. Pass true to auto-detect direction, + or force it with{" "} + "up",{" "} + "down", or{" "} + "flat". + + + + + Arrows fade out fully before the new direction fades in. + + + + + {/* Value overlay */} + Value overlay + + + showValue renders the current value as a + large number over the chart. It updates at 60fps through direct DOM + manipulation, not React re-renders. Pair it with{" "} + valueMomentumColor to tint the number based + on direction. + + + + 60fps value overlay with momentum colouring. + + + + {/* Time windows */} + Time windows + + + Pass a windows array to render time horizon + buttons. Each entry has a label and{" "} + secs value. Three styles are available via{" "} + windowStyle:{" "} + "default",{" "} + "rounded", and{" "} + "text". + + + + {``} + + + + + CPU usage with occasional spikes. Rounded time windows. + + + + + {/* Reference line */} + Reference line + + + referenceLine draws a horizontal line at a + fixed value. Pass an object with value and + an optional label. + + + + + Polymarket-style prediction line. "Will Bitcoin stay above + $67,500?" + + + + + {/* Orderbook */} + Orderbook + + + Pass an orderbook prop with{" "} + bids and asks{" "} + arrays to render streaming order labels behind the line. Each entry + is a [price, size] tuple. Labels spawn at + the bottom, drift upward, and fade out. Green for bids, red for + asks. Bigger orders appear brighter. + + + + The stream speed reacts to price momentum and orderbook churn (how + much the bid/ask totals are changing). Calm markets drift slowly, + volatile ones rush. + + + + + Kalshi-style orderbook stream. Bid and ask sizes float upward behind + the price line. + + + + + {/* Theming */} + Theming + + + Pass any CSS colour string to color and + Liveline derives the full palette. Line, fill gradient, glow, badge, + grid labels. It converts the input to HSL and generates every variant + from there. + + + + Dark theme. Same component, different colour. + + + + {/* More features */} + More features + + + Everything is off by default or has sensible defaults. A few more + things you can turn on: + + +
    +
  • + exaggerate tightens the Y-axis range so + small movements fill the full chart height. Useful for values that + move in tiny increments, like the heart rate demo above. +
  • +
  • + scrub shows a crosshair with time and + value tooltips on hover. On by default. +
  • +
  • + degen enables burst particles and chart + shake on momentum swings. For when subtlety is not the goal. +
  • +
  • + badgeVariant="minimal" renders a + quieter white pill instead of the accent-colored default. Or{" "} + {"badge={false}"} to remove it entirely. +
  • +
+ + + + {/* How it works */} + How it works + + + One {""}, one{" "} + requestAnimationFrame loop. When a new + value arrives, nothing jumps. The chart lerps toward the new state at + 8% per frame (lerpSpeed). The Y-axis range, + the badge, the grid labels all use the same lerp. The range snaps + outward instantly when data exceeds it, so the line is never clipped. + That's why it feels like one thing breathing rather than a bunch + of parts updating independently. + + + + + {/* Props */} + Props + + + + + + + + void", "\u2014"], + ["windowStyle", "'default' | 'rounded' | 'text'", "\u2014"], + ]} + /> + + + + + + string", "v.toFixed(2)"], + ["formatTime", "(t: number) => string", "HH:MM:SS"], + ["lerpSpeed", "number", "0.08"], + [ + "padding", + "Padding", + "{ top: 12, right: 80, bottom: 28, left: 12 }", + ], + ["onHover", "(point: HoverPoint | null) => void", "\u2014"], + ["cursor", "string", "'crosshair'"], + ["className", "string", "\u2014"], + ["style", "CSSProperties", "\u2014"], + ]} + /> + + + + {/* Stress testing */} + Stress testing + + + A chart that only looks good on calm data isn't much use. These + demos throw the worst stuff I could think of at it: wild volatility, + sharp direction changes, isolated spikes on flat lines, and irregular + data arrival with random gaps. + + + + + + Sharp reversals are the classic breaking point. The first chart + hammers the line with frequent direction changes at 60ms. The second + holds nearly flat, then fires massive isolated spikes. The third is + just chaos. + + + + + + Real-world data doesn't arrive at regular intervals. WebSocket + connections drop, batch updates land all at once, mobile networks + stall. This one simulates that: long quiet stretches of 1–3 + seconds between points, then sudden bursts at 40–80ms. The + tick interval itself is random. + + + + + + + {/* Just a line */} + Just a line + + + Liveline can do a lot. Momentum arrows, particles, orderbooks, + scrubbing, time windows. But at the end of the day, if you just want + a line that moves when a number changes, it does that just fine too. + + + + + + + {/* Acknowledgements */} + + Acknowledgements + + + + Built with React and HTML Canvas. Inspired by TradingView, Robinhood, + and Polymarket chart aesthetics. The interpolation approach is + borrowed from game development — lerp everything, snap nothing. + +
+
+
+ ); +} diff --git a/website/src/styles/liveline-prism.css b/website/src/styles/liveline-prism.css new file mode 100644 index 0000000..9336ebb --- /dev/null +++ b/website/src/styles/liveline-prism.css @@ -0,0 +1,135 @@ +/* + * Prism.js light theme matching benji.org's GitHub-style syntax highlighting. + * + * Color palette extracted from the original: + * - Keywords (import, function, return): #CF222E (red) + * - Strings: #0A3069 (dark blue) + * - Functions/components: #8250DF (purple) + * - Parameters/properties: #953800 (orange-brown) + * - HTML tags: #116329 (green) + * - Attributes/numbers: #0550AE (blue) + * - Punctuation/text: #1F2328 (near-black) + */ + +/* Base code style - override Prism defaults */ +code[class*="language-"], +pre[class*="language-"] { + color: #1f2328; + background: none; + text-shadow: none; + font-family: "SF Mono", "SFMono-Regular", "Consolas", "Liberation Mono", + Menlo, Courier, monospace; + font-size: 12px; + line-height: 18px; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + tab-size: 2; + hyphens: none; +} + +/* Keywords: import, from, function, return, const, let, var, export, default */ +.token.keyword, +.token.module, +.token.imports, +.token.control-flow { + color: #cf222e; +} + +/* Strings and template literals */ +.token.string, +.token.template-string, +.token.attr-value { + color: #0a3069; +} + +/* Function names */ +.token.function, +.token.class-name { + color: #8250df; +} + +/* Object properties, parameters, variables in destructuring */ +.token.property, +.token.parameter { + color: #953800; +} + +/* HTML/JSX tag names */ +.token.tag .token.tag { + color: #116329; +} + +/* Also color component names (PascalCase tags) in JSX */ +.token.tag .token.class-name { + color: #116329; +} + +/* Attributes in JSX/HTML */ +.token.tag .token.attr-name, +.token.attr-name { + color: #0550ae; +} + +/* Numbers */ +.token.number { + color: #0550ae; +} + +/* Operators: =, ===, =>, +, - */ +.token.operator { + color: #cf222e; +} + +/* Punctuation: brackets, parens, commas, semicolons */ +.token.punctuation { + color: #1f2328; +} + +/* Booleans, null, undefined */ +.token.boolean, +.token.constant, +.token.builtin { + color: #0550ae; +} + +/* Comments */ +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #6e7781; + font-style: italic; +} + +/* JSX specific: tag punctuation < > / */ +.token.tag .token.punctuation { + color: #1f2328; +} + +/* Spread operator, rest */ +.token.spread { + color: #cf222e; +} + +/* Plain text inside tags */ +.token.plain-text { + color: #1f2328; +} + +/* Regex */ +.token.regex { + color: #0a3069; +} + +/* Shell/bash specific */ +.language-bash .token.function { + color: #0550ae; +} + +/* No text shadow on any token */ +.token { + text-shadow: none; +} diff --git a/website/src/styles/liveline.css b/website/src/styles/liveline.css new file mode 100644 index 0000000..7403bd9 --- /dev/null +++ b/website/src/styles/liveline.css @@ -0,0 +1,147 @@ +/* + * Liveline page styles - recreating benji.org's editorial design system. + * + * Key design decisions from the original: + * - Inter variable font at weight 460 (between normal and medium) + * - Near-black text (#111) not pure black, with 40% opacity for secondary + * - Narrow 550px content column for optimal reading + * - Negative letter-spacing on headings for tight, refined feel + * - 7-layer box shadows for realistic depth + * - Display P3 accent colors (wider gamut than sRGB) + * - Staggered entrance animations with subtle translateY + * - Custom cubic-bezier easings with slight overshoot ("snappy", "swift") + * - Fading header gradient pseudo-element + * - Small font sizes (14px body) for editorial/compact aesthetic + */ + +/* ======================================================================== + Design tokens + ======================================================================== */ + +.liveline-page { + --ll-font-primary: "Inter var", "Inter", system-ui, -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --ll-font-secondary: "Newsreader", Georgia, "Times New Roman", serif; + --ll-font-code: "SF Mono", "SFMono-Regular", "Consolas", "Liberation Mono", + Menlo, Courier, monospace; + + /* Spacing scale */ + --ll-spacing-xxs: 0.5rem; + --ll-spacing-xs: 1rem; + --ll-spacing-sm: 1.5rem; + --ll-spacing-md: 2rem; + --ll-spacing-lg: 2.5rem; + --ll-spacing-xl: 3rem; + --ll-spacing-xxl: 3.5rem; + + /* Timing */ + --ll-duration-snappy: 220ms; + --ll-ease-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.1); + --ll-duration-swift: 800ms; + --ll-ease-swift: cubic-bezier(0.175, 0.885, 0.32, 1.275); + --ll-duration-smooth: 300ms; + --ll-ease-smooth: cubic-bezier(0.19, 1, 0.22, 1); + + /* Colors - near-black, not pure black */ + --ll-text-primary: rgb(17, 17, 17); + --ll-text-secondary: rgba(0, 0, 0, 0.4); + --ll-text-tertiary: rgba(0, 0, 0, 0.25); + --ll-bg: #fff; + --ll-border: #e3e3e3; + --ll-code-bg: rgba(0, 0, 0, 0.02); + --ll-selection-bg: rgba(0, 0, 0, 0.08); + + /* P3 accent colors (wider gamut) */ + --ll-primary: #3e9fff; + --ll-secondary: #f09637; + + /* Overlay / glass */ + --ll-overlay-filter: blur(1rem); + --ll-overlay-bg: hsla(0, 0%, 100%, 0.8); + --ll-overlay-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), + 0 1.625rem 3.375rem rgba(0, 0, 0, 0.04), + 0 1rem 2rem rgba(0, 0, 0, 0.03), + 0 0.625rem 1rem rgba(0, 0, 0, 0.024), + 0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02), + 0 0.125rem 0.25rem rgba(0, 0, 0, 0.016), + 0 0 0.125rem rgba(0, 0, 0, 0.01); +} + +@supports (color: color(display-p3 1 1 1)) { + .liveline-page { + --ll-primary: color(display-p3 0.243137 0.623529 1 / 1); + --ll-secondary: color(display-p3 0.941176 0.588235 0.215686 / 1); + } +} + +/* ======================================================================== + Selection + ======================================================================== */ + +.liveline-page { + font-optical-sizing: auto; + font-feature-settings: "liga" 1, "calt" 1; +} + +.liveline-page ::selection { + background: var(--ll-selection-bg); +} + +/* ======================================================================== + Stagger-in animation + ======================================================================== */ + +@keyframes ll-stagger-in { + 0% { + opacity: 0; + transform: translateY(8px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.liveline-article > * { + animation: ll-stagger-in 0.5s ease both; +} + +.liveline-article > :nth-child(1) { animation-delay: 0s; } +.liveline-article > :nth-child(2) { animation-delay: 0.05s; } +.liveline-article > :nth-child(3) { animation-delay: 0.1s; } +.liveline-article > :nth-child(4) { animation-delay: 0.15s; } +.liveline-article > :nth-child(5) { animation-delay: 0.2s; } +.liveline-article > :nth-child(6) { animation-delay: 0.25s; } +.liveline-article > :nth-child(7) { animation-delay: 0.3s; } +.liveline-article > :nth-child(8) { animation-delay: 0.35s; } +.liveline-article > :nth-child(n + 9) { animation-delay: 0.4s; } + +/* ======================================================================== + Header fade gradient + ======================================================================== */ + +.liveline-page::before { + content: ""; + pointer-events: none; + z-index: 9; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 8rem; + background: linear-gradient( + rgb(255, 255, 255) 0px, + rgba(255, 255, 255, 0.737) 19%, + rgba(255, 255, 255, 0.541) 34%, + rgba(255, 255, 255, 0.382) 47%, + rgba(255, 255, 255, 0.278) 56.5%, + rgba(255, 255, 255, 0.194) 65%, + rgba(255, 255, 255, 0.126) 73%, + rgba(255, 255, 255, 0.075) 80.2%, + rgba(255, 255, 255, 0.042) 86.1%, + rgba(255, 255, 255, 0.021) 91%, + rgba(255, 255, 255, 0.008) 95.2%, + rgba(255, 255, 255, 0.002) 98.2%, + rgba(255, 255, 255, 0) 100% + ); +}