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 (
+
+
+ {
+ e.currentTarget.style.color = "rgba(0, 0, 0, 0.7)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = "rgba(0, 0, 0, 0.4)";
+ }}
+ >
+
+
+
+ {" "}Index
+
+ {tocItems.map((item) => {
+ const isActive = `#${activeId}` === item.href;
+ const defaultColor = isActive ? "rgba(0, 0, 0, 0.9)" : "rgba(0, 0, 0, 0.4)";
+ return (
+ {
+ if (!isActive) {
+ e.currentTarget.style.color = "rgba(0, 0, 0, 0.7)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = defaultColor;
+ }}
+ >
+ {item.label}
+
+ );
+ })}
+
+
+ );
+}
+
+/* =========================================================================
+ 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 */}
+
+ {lines.map((_, i) => {
+ return (
+
+ {i + 1}
+
+ );
+ })}
+
+ {/* 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 (
+
+ {header}
+
+ );
+ })}
+
+
+
+ {rows.map(([prop, type, def]) => {
+ return (
+
+
+ {prop}
+
+
+ {type}
+
+
+ {def}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+/* =========================================================================
+ Main page
+ ========================================================================= */
+
+export default function LivelinePage() {
+ return (
+
+
+
+
+
+ {/* Page header */}
+
+
+ Liveline
+
+
+ 16 February, 2026
+
+
+
+
+ {/* 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%
+ );
+}