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.
This commit is contained in:
Generated
+19
-2
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,6 +15,21 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin=""
|
||||
/>
|
||||
{/* Inter from rsms (same source as next/font) for weight fidelity */}
|
||||
<link
|
||||
href="https://rsms.me/inter/inter.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
|
||||
@@ -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<HTMLElement>("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 (
|
||||
<aside
|
||||
className="fixed top-[80px] hidden lg:block"
|
||||
style={{ left: "max(1rem, calc((100vw - 550px) / 2 - 200px))", width: "122px" }}
|
||||
>
|
||||
<nav>
|
||||
<a
|
||||
href="/"
|
||||
className="no-underline transition-colors flex items-center gap-1"
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 475,
|
||||
lineHeight: "20px",
|
||||
letterSpacing: "-0.09px",
|
||||
padding: "4px 0",
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "rgba(0, 0, 0, 0.7)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "rgba(0, 0, 0, 0.4)";
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path
|
||||
d="M12.25 7H1.75M1.75 7L6.125 2.625M1.75 7L6.125 11.375"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{" "}Index
|
||||
</a>
|
||||
{tocItems.map((item) => {
|
||||
const isActive = `#${activeId}` === item.href;
|
||||
const defaultColor = isActive ? "rgba(0, 0, 0, 0.9)" : "rgba(0, 0, 0, 0.4)";
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block no-underline"
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: 475,
|
||||
lineHeight: "15.6px",
|
||||
letterSpacing: "-0.04px",
|
||||
padding: "3px 0",
|
||||
color: defaultColor,
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
transition: "color 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.color = "rgba(0, 0, 0, 0.7)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = defaultColor;
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Back button (fixed top-right)
|
||||
========================================================================= */
|
||||
|
||||
function BackButton() {
|
||||
return (
|
||||
<a
|
||||
href="/"
|
||||
className="fixed top-5 right-5 z-[100000] flex items-center justify-center w-10 h-10 rounded-full bg-white no-underline"
|
||||
style={{
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
boxShadow:
|
||||
"rgba(0, 0, 0, 0.08) 0px 2px 8px, rgba(0, 0, 0, 0.04) 0px 4px 16px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset",
|
||||
transition: "color 0.15s, transform 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
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)";
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path
|
||||
d="M12.25 7H1.75M1.75 7L6.125 2.625M1.75 7L6.125 11.375"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Reusable components
|
||||
========================================================================= */
|
||||
|
||||
function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<h1
|
||||
id={id}
|
||||
className="scroll-mt-[5.25rem]"
|
||||
style={{
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
fontSize: "14px",
|
||||
fontWeight: 560,
|
||||
lineHeight: "20px",
|
||||
letterSpacing: "-0.09px",
|
||||
color: "var(--ll-text-primary)",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
transform: "translateY(-10px)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function Paragraph({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<p
|
||||
className={className}
|
||||
style={{
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
fontSize: "14px",
|
||||
fontWeight: 475,
|
||||
lineHeight: "20px",
|
||||
letterSpacing: "-0.09px",
|
||||
color: "var(--ll-text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Caption({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
fontSize: "12px",
|
||||
fontWeight: 475,
|
||||
lineHeight: "20px",
|
||||
letterSpacing: "-0.09px",
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
background: "rgb(242, 242, 242)",
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ children, lang = "jsx" }: { children: string; lang?: string }) {
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
const lines = children.split("\n");
|
||||
|
||||
useEffect(() => {
|
||||
if (codeRef.current) {
|
||||
Prism.highlightElement(codeRef.current);
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<figure className="m-0">
|
||||
<div className="relative">
|
||||
<pre
|
||||
className="overflow-x-auto"
|
||||
style={{
|
||||
background: "var(--ll-code-bg)",
|
||||
borderRadius: "8px",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
padding: "12px 8px 8px",
|
||||
fontFamily: "var(--ll-font-code)",
|
||||
fontSize: "12px",
|
||||
fontWeight: 400,
|
||||
lineHeight: "18px",
|
||||
letterSpacing: "normal",
|
||||
color: "var(--ll-text-primary)",
|
||||
tabSize: 2,
|
||||
}}
|
||||
>
|
||||
{/* Line numbers */}
|
||||
<span
|
||||
className="select-none shrink-0"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
color: "rgba(0, 0, 0, 0.15)",
|
||||
textAlign: "right",
|
||||
paddingRight: "20px",
|
||||
minWidth: "28px",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{lines.map((_, i) => {
|
||||
return (
|
||||
<span key={i} className="block">
|
||||
{i + 1}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{/* Highlighted code */}
|
||||
<code
|
||||
ref={codeRef}
|
||||
className={`language-${lang}`}
|
||||
style={{ whiteSpace: "pre", background: "none", padding: 0 }}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineCode({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<code
|
||||
style={{
|
||||
fontFamily: "var(--ll-font-code)",
|
||||
fontSize: "12.25px",
|
||||
fontWeight: 475,
|
||||
lineHeight: "12.25px",
|
||||
letterSpacing: "-0.09px",
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartPlaceholder({ height = 200, label }: { height?: number; label?: string }) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<div
|
||||
className="w-full rounded-lg overflow-hidden relative"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
background: "rgb(17, 17, 17)",
|
||||
}}
|
||||
>
|
||||
{/* Simulated chart line */}
|
||||
<svg
|
||||
viewBox="0 0 550 200"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="chartFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60 L550,200 L0,200 Z"
|
||||
fill="url(#chartFill)"
|
||||
/>
|
||||
{/* Live dot */}
|
||||
<circle cx="550" cy="60" r="4" fill="#3b82f6">
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="4;6;4"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="1;0.6;1"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
{label && (
|
||||
<div
|
||||
className="absolute top-3 right-3 px-2 py-1 rounded text-xs"
|
||||
style={{
|
||||
background: "rgba(59, 130, 246, 0.15)",
|
||||
color: "#3b82f6",
|
||||
fontFamily: "var(--ll-font-code)",
|
||||
fontWeight: 475,
|
||||
fontSize: "11px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropsTable({
|
||||
title,
|
||||
rows,
|
||||
}: {
|
||||
title?: string;
|
||||
rows: Array<[string, string, string]>;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full max-w-full overflow-x-auto" style={{ padding: "8px 0" }}>
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
fontSize: "11px",
|
||||
fontWeight: 400,
|
||||
color: "rgba(0, 0, 0, 0.35)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.02em",
|
||||
padding: "0 0 6px",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<table
|
||||
className="w-full"
|
||||
style={{
|
||||
borderSpacing: 0,
|
||||
borderCollapse: "collapse",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{["Prop", "Type", "Default"].map((header) => {
|
||||
return (
|
||||
<th
|
||||
key={header}
|
||||
className="text-left"
|
||||
style={{
|
||||
padding: "4.8px 12px 4.8px 0",
|
||||
fontSize: "11px",
|
||||
fontWeight: 400,
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
color: "rgba(0, 0, 0, 0.35)",
|
||||
borderBottom: "1px solid var(--ll-border)",
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([prop, type, def]) => {
|
||||
return (
|
||||
<tr key={prop}>
|
||||
<td
|
||||
style={{
|
||||
padding: "4.8px 12px 4.8px 0",
|
||||
fontSize: "11px",
|
||||
fontWeight: 400,
|
||||
fontFamily: "var(--ll-font-code)",
|
||||
color: "var(--ll-text-primary)",
|
||||
borderBottom: "1px solid var(--ll-border)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{prop}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "4.8px 12px 4.8px 0",
|
||||
fontSize: "11px",
|
||||
fontWeight: 400,
|
||||
fontFamily: "var(--ll-font-code)",
|
||||
color: "rgba(0, 0, 0, 0.35)",
|
||||
borderBottom: "1px solid var(--ll-border)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "4.8px 12px 4.8px 0",
|
||||
fontSize: "11px",
|
||||
fontWeight: 400,
|
||||
fontFamily: "var(--ll-font-code)",
|
||||
color: "rgba(0, 0, 0, 0.35)",
|
||||
borderBottom: "1px solid var(--ll-border)",
|
||||
}}
|
||||
>
|
||||
{def}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Main page
|
||||
========================================================================= */
|
||||
|
||||
export default function LivelinePage() {
|
||||
return (
|
||||
<div
|
||||
className="liveline-page relative min-h-screen"
|
||||
style={{
|
||||
background: "var(--ll-bg)",
|
||||
color: "var(--ll-text-primary)",
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
textRendering: "optimizeLegibility",
|
||||
}}
|
||||
>
|
||||
<BackButton />
|
||||
<TableOfContents />
|
||||
|
||||
<div
|
||||
className="mx-auto"
|
||||
style={{ width: "550px", maxWidth: "calc(100% - 2rem)", padding: "0 1rem 6rem" }}
|
||||
>
|
||||
{/* Page header */}
|
||||
<header style={{ padding: "80px 0 40px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
fontSize: "13px",
|
||||
fontWeight: 475,
|
||||
lineHeight: "15.6px",
|
||||
letterSpacing: "-0.04px",
|
||||
color: "rgba(18, 18, 18, 0.4)",
|
||||
margin: 0,
|
||||
padding: "0 0 16px",
|
||||
}}
|
||||
>
|
||||
Liveline
|
||||
</h2>
|
||||
<time
|
||||
style={{
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
fontSize: "13px",
|
||||
fontWeight: 400,
|
||||
lineHeight: "15.6px",
|
||||
letterSpacing: "-0.04px",
|
||||
color: "rgba(18, 18, 18, 0.25)",
|
||||
}}
|
||||
>
|
||||
16 February, 2026
|
||||
</time>
|
||||
</header>
|
||||
|
||||
<article className="liveline-article flex flex-col gap-[16px]">
|
||||
{/* Intro */}
|
||||
<Paragraph>
|
||||
Liveline is a real-time animated line chart component for React. One{" "}
|
||||
<InlineCode>{"<canvas>"}</InlineCode>, no dependencies beyond React
|
||||
18, smooth interpolation at 60fps.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={300} label="684.89" />
|
||||
<Caption>
|
||||
Degen mode (chart shake and particles) with momentum arrows.
|
||||
</Caption>
|
||||
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Getting started */}
|
||||
<SectionHeading id="getting-started">Getting started</SectionHeading>
|
||||
|
||||
<CodeBlock lang="bash">npm install liveline</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
The component fills its parent container. Set a height on the
|
||||
wrapper.
|
||||
</Paragraph>
|
||||
|
||||
<CodeBlock>
|
||||
{`import { Liveline } from 'liveline'
|
||||
|
||||
function Chart({ data, value }) {
|
||||
return (
|
||||
<div style={{ height: 200 }}>
|
||||
<Liveline data={data} value={value} />
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<Paragraph>
|
||||
<InlineCode>data</InlineCode> is an array of{" "}
|
||||
<InlineCode>{"{ time, value }"}</InlineCode> points.{" "}
|
||||
<InlineCode>value</InlineCode> is the latest number.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={200} label="303.34" />
|
||||
<Caption>Two props. That's it.</Caption>
|
||||
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={200} label="98 bpm" />
|
||||
<Caption>
|
||||
Resting heart rate. Custom formatter, exaggerated Y-axis.
|
||||
</Caption>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Momentum */}
|
||||
<SectionHeading id="momentum">Momentum</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
The <InlineCode>momentum</InlineCode> prop adds directional arrows
|
||||
and a glow to the live dot. Green for up, red for down, grey for
|
||||
flat. Pass <InlineCode>true</InlineCode> to auto-detect direction,
|
||||
or force it with{" "}
|
||||
<InlineCode>"up"</InlineCode>,{" "}
|
||||
<InlineCode>"down"</InlineCode>, or{" "}
|
||||
<InlineCode>"flat"</InlineCode>.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={220} label="449.73" />
|
||||
<Caption>
|
||||
Arrows fade out fully before the new direction fades in.
|
||||
</Caption>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Value overlay */}
|
||||
<SectionHeading id="value-overlay">Value overlay</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
<InlineCode>showValue</InlineCode> 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{" "}
|
||||
<InlineCode>valueMomentumColor</InlineCode> to tint the number based
|
||||
on direction.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={204} label="$17,138.03" />
|
||||
<Caption>60fps value overlay with momentum colouring.</Caption>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Time windows */}
|
||||
<SectionHeading id="time-windows">Time windows</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
Pass a <InlineCode>windows</InlineCode> array to render time horizon
|
||||
buttons. Each entry has a <InlineCode>label</InlineCode> and{" "}
|
||||
<InlineCode>secs</InlineCode> value. Three styles are available via{" "}
|
||||
<InlineCode>windowStyle</InlineCode>:{" "}
|
||||
<InlineCode>"default"</InlineCode>,{" "}
|
||||
<InlineCode>"rounded"</InlineCode>, and{" "}
|
||||
<InlineCode>"text"</InlineCode>.
|
||||
</Paragraph>
|
||||
|
||||
<CodeBlock>
|
||||
{`<Liveline
|
||||
windows={[
|
||||
{ label: '1m', secs: 60 },
|
||||
{ label: '5m', secs: 300 },
|
||||
]}
|
||||
windowStyle="rounded"
|
||||
/>`}
|
||||
</CodeBlock>
|
||||
|
||||
<ChartPlaceholder height={186} label="30%" />
|
||||
<Caption>
|
||||
CPU usage with occasional spikes. Rounded time windows.
|
||||
</Caption>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Reference line */}
|
||||
<SectionHeading id="reference-line">Reference line</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
<InlineCode>referenceLine</InlineCode> draws a horizontal line at a
|
||||
fixed value. Pass an object with <InlineCode>value</InlineCode> and
|
||||
an optional <InlineCode>label</InlineCode>.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={186} label="$-18,576" />
|
||||
<Caption>
|
||||
Polymarket-style prediction line. "Will Bitcoin stay above
|
||||
$67,500?"
|
||||
</Caption>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Orderbook */}
|
||||
<SectionHeading id="orderbook">Orderbook</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
Pass an <InlineCode>orderbook</InlineCode> prop with{" "}
|
||||
<InlineCode>bids</InlineCode> and <InlineCode>asks</InlineCode>{" "}
|
||||
arrays to render streaming order labels behind the line. Each entry
|
||||
is a <InlineCode>[price, size]</InlineCode> tuple. Labels spawn at
|
||||
the bottom, drift upward, and fade out. Green for bids, red for
|
||||
asks. Bigger orders appear brighter.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={260} label="$61,904" />
|
||||
<Caption>
|
||||
Kalshi-style orderbook stream. Bid and ask sizes float upward behind
|
||||
the price line.
|
||||
</Caption>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Theming */}
|
||||
<SectionHeading id="theming">Theming</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
Pass any CSS colour string to <InlineCode>color</InlineCode> 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.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={204} label="540.42" />
|
||||
<Caption>Dark theme. Same component, different colour.</Caption>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* More features */}
|
||||
<SectionHeading id="more-features">More features</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
Everything is off by default or has sensible defaults. A few more
|
||||
things you can turn on:
|
||||
</Paragraph>
|
||||
|
||||
<ul
|
||||
className="m-0 pl-5"
|
||||
style={{
|
||||
fontFamily: "var(--ll-font-primary)",
|
||||
fontSize: "14px",
|
||||
fontWeight: 475,
|
||||
lineHeight: "20px",
|
||||
letterSpacing: "-0.09px",
|
||||
color: "var(--ll-text-primary)",
|
||||
listStyleType: "disc",
|
||||
}}
|
||||
>
|
||||
<li style={{ padding: "0 0 8px 12px" }}>
|
||||
<InlineCode>exaggerate</InlineCode> 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.
|
||||
</li>
|
||||
<li style={{ padding: "0 0 8px 12px" }}>
|
||||
<InlineCode>scrub</InlineCode> shows a crosshair with time and
|
||||
value tooltips on hover. On by default.
|
||||
</li>
|
||||
<li style={{ padding: "0 0 8px 12px" }}>
|
||||
<InlineCode>degen</InlineCode> enables burst particles and chart
|
||||
shake on momentum swings. For when subtlety is not the goal.
|
||||
</li>
|
||||
<li style={{ padding: "0 0 8px 12px" }}>
|
||||
<InlineCode>badgeVariant="minimal"</InlineCode> renders a
|
||||
quieter white pill instead of the accent-colored default. Or{" "}
|
||||
<InlineCode>{"badge={false}"}</InlineCode> to remove it entirely.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* How it works */}
|
||||
<SectionHeading id="how-it-works">How it works</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
One <InlineCode>{"<canvas>"}</InlineCode>, one{" "}
|
||||
<InlineCode>requestAnimationFrame</InlineCode> loop. When a new
|
||||
value arrives, nothing jumps. The chart lerps toward the new state at
|
||||
8% per frame (<InlineCode>lerpSpeed</InlineCode>). 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.
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Props */}
|
||||
<SectionHeading id="props">Props</SectionHeading>
|
||||
|
||||
<PropsTable
|
||||
title="Required"
|
||||
rows={[
|
||||
["data", "LivelinePoint[]", "required"],
|
||||
["value", "number", "required"],
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
title="Appearance"
|
||||
rows={[
|
||||
["theme", "'light' | 'dark'", "'dark'"],
|
||||
["color", "string", "'#3b82f6'"],
|
||||
["grid", "boolean", "true"],
|
||||
["badge", "boolean", "true"],
|
||||
["badgeVariant", "'default' | 'minimal'", "'default'"],
|
||||
["badgeTail", "boolean", "true"],
|
||||
["fill", "boolean", "true"],
|
||||
["pulse", "boolean", "true"],
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
title="Behaviour"
|
||||
rows={[
|
||||
["momentum", "boolean | Momentum", "true"],
|
||||
["scrub", "boolean", "true"],
|
||||
["exaggerate", "boolean", "false"],
|
||||
["showValue", "boolean", "false"],
|
||||
["valueMomentumColor", "boolean", "false"],
|
||||
["degen", "boolean | DegenOptions", "false"],
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
title="Time"
|
||||
rows={[
|
||||
["window", "number", "30"],
|
||||
["windows", "WindowOption[]", "\u2014"],
|
||||
["onWindowChange", "(secs: number) => void", "\u2014"],
|
||||
["windowStyle", "'default' | 'rounded' | 'text'", "\u2014"],
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
title="Tooltip"
|
||||
rows={[
|
||||
["tooltipY", "number", "14"],
|
||||
["tooltipOutline", "boolean", "true"],
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
title="Orderbook"
|
||||
rows={[["orderbook", "OrderbookData", "\u2014"]]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
title="Advanced"
|
||||
rows={[
|
||||
["referenceLine", "ReferenceLine", "\u2014"],
|
||||
["formatValue", "(v: number) => 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"],
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Stress testing */}
|
||||
<SectionHeading id="stress-testing">Stress testing</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={200} />
|
||||
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={200} />
|
||||
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={200} />
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Just a line */}
|
||||
<SectionHeading id="just-a-line">Just a line</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
|
||||
<ChartPlaceholder height={180} />
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Acknowledgements */}
|
||||
<SectionHeading id="acknowledgements">
|
||||
Acknowledgements
|
||||
</SectionHeading>
|
||||
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user