@@ -232,12 +228,12 @@ function Upload() {
}
const TILES = [
- { title: "Plays what you have", line: "FLAC, MP3, AAC, Opus, Ogg and WAV, served as-is.", body:
},
- { title: "Lyrics", line: "Time-synced when available, in a side panel.", body:
},
- { title: "Last.fm scrobbling", line: "Per user, now-playing and scrobbles.", body:
},
- { title: "Discord Rich Presence", line: "“Listening to Lumen”, with cover art.", body:
},
- { title: "Command palette", line: "⌘K to jump to any track, album, or artist.", body:
},
- { title: "Uploads & tag editing", line: "From the browser or your phone.", body:
},
+ { title: "Plays what you have", line: "FLAC, MP3, AAC, Opus, Ogg and WAV, served as-is", body:
},
+ { title: "Lyrics", line: "Time-synced when available, in a side panel", body:
},
+ { title: "Last.fm scrobbling", line: "Per user, now-playing and scrobbles", body:
},
+ { title: "Discord Rich Presence", line: "“Listening to Lumen”, with cover art", body:
},
+ { title: "Command palette", line: "⌘K to jump to any track, album, or artist", body:
},
+ { title: "Uploads & tag editing", line: "From the browser or your phone", body:
},
];
export default function MiniFeatures() {
diff --git a/landing/src/components/features/PlaylistDemo.tsx b/landing/src/components/features/PlaylistDemo.tsx
index bfc43384..55c7905e 100644
--- a/landing/src/components/features/PlaylistDemo.tsx
+++ b/landing/src/components/features/PlaylistDemo.tsx
@@ -22,7 +22,7 @@ function Avatar({ person, className }: { person: number; className?: string }) {
return (
-
Collaborative
-
Late drives
+
Collaborative
+
Late drives
{20 + rows[0].key} tracks
diff --git a/landing/src/components/features/ReplayDemo.tsx b/landing/src/components/features/ReplayDemo.tsx
index b05e99a8..f1a84d83 100644
--- a/landing/src/components/features/ReplayDemo.tsx
+++ b/landing/src/components/features/ReplayDemo.tsx
@@ -56,10 +56,10 @@ export default function ReplayDemo() {
-
+
{active === null ? "Replay 2026" : `${MONTH_NAMES[active]} 2026`}
-
+
{value.toLocaleString("en-US")}
min
@@ -116,8 +116,8 @@ export default function ReplayDemo() {
})}
- JAN
- DEC
+ Jan
+ Dec
);
diff --git a/landing/src/components/ui.tsx b/landing/src/components/ui.tsx
index 1d1544c0..3ea1dd57 100644
--- a/landing/src/components/ui.tsx
+++ b/landing/src/components/ui.tsx
@@ -1,10 +1,7 @@
import clsx from "clsx";
-import { Check, Copy } from "lucide-react";
import { useId, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
-import IconSwap from "./IconSwap";
-import { useCopy } from "../lib/hooks";
-/** Stagger for `.reveal` elements. */
+/** Stagger for the hero's `.enter` elements. */
export const delay = (ms: number) => ({ "--delay": `${ms}ms` }) as CSSProperties;
type Variant = "primary" | "outline" | "ghost";
@@ -12,7 +9,7 @@ type Size = "sm" | "md" | "lg";
const variants: Record
= {
primary: "bg-primary text-primary-foreground hover:bg-primary/85",
- outline: "border border-border bg-background/60 backdrop-blur hover:bg-accent dark:bg-input/20 dark:hover:bg-input/40",
+ outline: "border border-border bg-background hover:bg-accent dark:bg-input/20 dark:hover:bg-input/40",
ghost: "hover:bg-accent dark:hover:bg-accent/60",
};
const sizes: Record = {
@@ -70,21 +67,7 @@ export function IconButton({
);
}
-/** Small pill label above section headings. */
-export function Eyebrow({ children, className }: { children: ReactNode; className?: string }) {
- return (
-
-
- {children}
-
- );
-}
-
+/** A section's name in small muted text, then its heading. */
export function SectionHeading({
eyebrow,
children,
@@ -95,47 +78,9 @@ export function SectionHeading({
align?: "center" | "left";
}) {
return (
-
-
{eyebrow}
-
- {children}
-
+
+ {eyebrow}
+
{children}
);
}
-
-/** A shell command with a copy button. */
-export function CommandPill({
- command,
- className,
- style,
-}: {
- command: string;
- className?: string;
- style?: CSSProperties;
-}) {
- const { copied, copy } = useCopy();
- return (
-
- );
-}
diff --git a/landing/src/index.css b/landing/src/index.css
index bec90ecd..4ffc55c4 100644
--- a/landing/src/index.css
+++ b/landing/src/index.css
@@ -77,10 +77,7 @@
to { background-color: transparent; }
}
@keyframes enter {
- from { opacity: 0; transform: translateY(12px); filter: blur(4px); }
- }
- @keyframes enter-plain {
- from { opacity: 0; transform: translateY(24px); }
+ from { opacity: 0; transform: translateY(12px); }
}
@keyframes fade-in {
from { opacity: 0; }
@@ -145,7 +142,7 @@
--brand: oklch(0.205 0 0);
--brand-soft: oklch(0 0 0 / 0.05);
--success: oklch(0.6 0.13 155);
- --grid-line: oklch(0 0 0 / 0.05);
+ --shadow-card: 0 1px 2px oklch(0 0 0 / 0.05);
--shadow-float: 0 1px 2px oklch(0 0 0 / 0.06), 0 12px 40px -12px oklch(0 0 0 / 0.18);
color-scheme: light;
}
@@ -170,7 +167,8 @@
--brand: oklch(0.985 0 0);
--brand-soft: oklch(1 0 0 / 0.07);
--success: oklch(0.72 0.13 155);
- --grid-line: oklch(1 0 0 / 0.045);
+ /* Depth on dark comes from the surface step and the hairline. */
+ --shadow-card: none;
--shadow-float: 0 1px 2px oklch(0 0 0 / 0.4), 0 24px 60px -20px oklch(0 0 0 / 0.7);
color-scheme: dark;
}
@@ -199,83 +197,21 @@ body {
/* ---------- Page-level treatments ---------- */
-/* Blueprint grid behind the hero, faded out at the edges. */
-.bg-grid {
- background-image:
- linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
- linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
- background-size: 48px 48px;
- mask-image: radial-gradient(ellipse 70% 60% at 50% 30%, black 30%, transparent 75%);
-}
-
-.glow {
- background:
- radial-gradient(40% 50% at 50% 50%, color-mix(in oklch, var(--foreground) 10%, transparent), transparent 70%),
- radial-gradient(30% 40% at 30% 60%, color-mix(in oklch, var(--foreground) 5%, transparent), transparent 70%);
- filter: blur(40px);
-}
-
-.text-gradient {
- background: linear-gradient(180deg, var(--foreground) 30%, color-mix(in oklch, var(--foreground) 55%, transparent));
- -webkit-background-clip: text;
- background-clip: text;
- color: transparent;
-}
-
-.text-brand-gradient {
- background: linear-gradient(100deg, var(--foreground) 10%, var(--muted-foreground) 100%);
- -webkit-background-clip: text;
- background-clip: text;
- color: transparent;
-}
-
-/* Card with a hairline highlight on the top edge, like a lit bezel. */
+/* Raised card: one surface step up and a hairline. */
.card-surface {
background: var(--card);
border: 1px solid var(--border);
- box-shadow:
- inset 0 1px 0 0 oklch(1 0 0 / 0.04),
- var(--shadow-float);
+ box-shadow: var(--shadow-card);
}
-/* Pointer spotlight on feature cards; --mx/--my are set from JS. */
-.spotlight {
- position: relative;
- isolation: isolate;
-}
-.spotlight::before {
- content: "";
- position: absolute;
- inset: 0;
- border-radius: inherit;
- background: radial-gradient(360px circle at var(--mx, 50%) var(--my, -40%), var(--brand-soft), transparent 60%);
- opacity: 0;
- /* Hidden, not just transparent, while idle, so contrast checkers can see
- the card behind the text. visibility flips at the right end of the fade
- in both directions. */
- visibility: hidden;
- transition:
- opacity 200ms ease,
- visibility 200ms;
- pointer-events: none;
- z-index: -1;
-}
-@media (hover: hover) and (pointer: fine) {
- .spotlight:hover::before {
- opacity: 1;
- visibility: visible;
- }
-}
-
-/* Scroll reveal. JS adds data-shown once the element enters the viewport.
- Marketing entrance, so it may run longer than UI motion. */
+/* Scroll reveal, once per section. JS adds data-shown once the section
+ enters the viewport. */
.reveal {
opacity: 0;
- transform: translateY(16px);
+ transform: translateY(12px);
transition:
- opacity 600ms var(--ease-out),
- transform 600ms var(--ease-out);
- transition-delay: var(--delay, 0ms);
+ opacity 500ms var(--ease-out),
+ transform 500ms var(--ease-out);
}
.reveal[data-shown] {
opacity: 1;
@@ -286,12 +222,7 @@ body {
JS reveal, so it starts without waiting for React and the observer, and
stays smooth while the page is still loading. */
.enter {
- animation: enter 800ms var(--ease-out) both;
- animation-delay: var(--delay, 0ms);
-}
-/* Same, without the blur: filters are expensive on large layers. */
-.enter-plain {
- animation: enter-plain 900ms var(--ease-out) both;
+ animation: enter 500ms var(--ease-out) both;
animation-delay: var(--delay, 0ms);
}
@@ -323,8 +254,7 @@ body {
transform: none;
transition: opacity 400ms ease;
}
- .enter,
- .enter-plain {
+ .enter {
animation: fade-in 400ms ease both;
animation-delay: var(--delay, 0ms);
}
@@ -334,7 +264,6 @@ body {
/* Decorative loops stop; the demos' autoplay is paused in JS. */
.animate-eq,
.animate-blink,
- .animate-ping,
.animate-pulse,
.animate-spin,
.animate-row-in,
diff --git a/landing/src/lib/hooks.ts b/landing/src/lib/hooks.ts
index 68ce05d9..2d658081 100644
--- a/landing/src/lib/hooks.ts
+++ b/landing/src/lib/hooks.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
+import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
export type Theme = "light" | "dark";
const THEME_KEY = "lumen-landing-theme";
@@ -65,7 +65,9 @@ export function usePrefersReducedMotion(onReduce?: () => void) {
return reduced;
}
-/** Marks every `.reveal` element with data-shown once it scrolls into view. */
+/** Marks every `.reveal` element with data-shown once its top edge scrolls
+ * into view. Threshold 0, since a whole section can be taller than the
+ * viewport. */
export function useRevealOnScroll() {
useEffect(() => {
const els = document.querySelectorAll
(".reveal:not([data-shown])");
@@ -78,7 +80,7 @@ export function useRevealOnScroll() {
}
}
},
- { rootMargin: "0px 0px -10% 0px", threshold: 0.1 },
+ { rootMargin: "0px 0px -10% 0px", threshold: 0 },
);
els.forEach((el) => io.observe(el));
return () => io.disconnect();
@@ -112,14 +114,6 @@ export function useInterval(tick: () => void, ms: number, active = true) {
}, [ms, active]);
}
-/** Feeds pointer position to the `.spotlight` gradient. */
-export function spotlight(e: PointerEvent) {
- const el = e.currentTarget;
- const r = el.getBoundingClientRect();
- el.style.setProperty("--mx", `${e.clientX - r.left}px`);
- el.style.setProperty("--my", `${e.clientY - r.top}px`);
-}
-
/** Clipboard API where available; otherwise the legacy execCommand path.
* navigator.clipboard only exists in secure contexts (HTTPS, localhost), so
* plain-HTTP self-hosted or LAN setups need the fallback. */
diff --git a/landing/tests/page.ui.ts b/landing/tests/page.ui.ts
index bc2c6ca0..45cf6e00 100644
--- a/landing/tests/page.ui.ts
+++ b/landing/tests/page.ui.ts
@@ -3,7 +3,7 @@ import { expectUI } from "lintapple/playwright";
test.beforeEach(async ({ page }) => {
// Fixed GitHub data keeps the run offline and renders the stars and the
- // release pill.
+ // release tag.
await page.route("https://api.github.com/repos/**", (route) => {
const releases = new URL(route.request().url()).pathname.endsWith("/releases");
return route.fulfill({
diff --git a/landing/ui-policy.json b/landing/ui-policy.json
index 1cfc1f0a..ad22703c 100644
--- a/landing/ui-policy.json
+++ b/landing/ui-policy.json
@@ -22,16 +22,6 @@
"rules": ["font-family", "font-size", "text-contrast"],
"reason": "Text drawn inside an SVG illustration: the self-host diagram and the MacBook's macOS menu bar. It is sized in viewBox units and axe cannot measure its background. Pixel sampling measured the diagram labels at 5.1:1 or more."
},
- {
- "selector": ".text-gradient, .text-gradient *, .text-brand-gradient, .text-brand-gradient *",
- "rules": ["text-contrast"],
- "reason": "Gradient-filled headings (background-clip: text), which axe cannot measure. Pixel sampling measured the lightest end of the gradient at 4.1:1 or more; these are large text, which needs 3:1."
- },
- {
- "selector": ".card-surface > .bg-grid ~ * *",
- "rules": ["text-contrast"],
- "reason": "Buttons in the closing call to action sit on translucent surfaces over the decorative grid and glow layers, which axe cannot see through. Pixel sampling measured 5.4:1 or more."
- },
{
"selector": ".truncate, .truncate *",
"rules": ["content-clipping"],