Portfolio New
An editorial designer portfolio with filterable selected work, case-study overlays, a career timeline, services and a contact form.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/apps-portfolio.json02
Or copy the source below (3 files). Dependencies: lucide-react.
Source
components/niceui/pages/apps/portfolio/PortfolioApp.tsx
import { useEffect, useState, type FormEvent } from 'react';
import { ArrowRight, ArrowUpRight, Check, Mail, Menu, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Reveal } from '@/components/niceui/pages/landing/shared/motion';
import { artwork } from '@/components/niceui/gallery/art';
const WORK = [
{ id: 0, title: 'Tidewater Bank', kind: 'Brand identity', year: 2026, img: 3, summary: 'A calmer identity for a 140-year-old regional bank: a new wordmark, a tide-line motif and a colour system built for screens first.', role: 'Creative direction, identity, motion', results: ['+38% brand recall', '12 markets', '6-week rollout'] },
{ id: 1, title: 'Halo Health', kind: 'Product design', year: 2025, img: 2, summary: 'A patient app that turns lab results into plain language, with a care timeline families can share.', role: 'Product strategy, UX, design system', results: ['4.8★ App Store', '−41% support tickets', '220k patients'] },
{ id: 2, title: 'Northfield Press', kind: 'Editorial web', year: 2025, img: 8, summary: 'A reading-first magazine site with variable type, slow scroll stories and an archive that feels like a library.', role: 'Art direction, web design', results: ['2.4× time on page', 'Webby honoree', '18 long reads'] },
{ id: 3, title: 'Ember Coffee', kind: 'Packaging', year: 2024, img: 11, summary: 'Seasonal packaging where each origin gets its own landscape, printed with two inks to keep costs honest.', role: 'Packaging, illustration', results: ['Sold out in 9 days', '3 retail partners', 'Pentawards shortlist'] },
{ id: 4, title: 'Atlas Maps', kind: 'Product design', year: 2024, img: 0, summary: 'Offline-first trail maps for hikers, designed for gloves, glare and low batteries.', role: 'UX research, interface design', results: ['1.1M downloads', '30% less battery', 'Apple feature'] },
{ id: 5, title: 'Fable Studio', kind: 'Brand identity', year: 2023, img: 9, summary: 'An identity for an animation studio built around a single flickering frame.', role: 'Identity, motion system', results: ['Cannes Lions bronze', '40 assets', 'Full guidelines'] },
];
const TIMELINE = [
{ year: '2023 — now', role: 'Founder, Studio Ora', note: 'Independent practice for brand and product work.' },
{ year: '2019 — 2023', role: 'Design Lead, Lumen', note: 'Led a team of eight across web, app and brand.' },
{ year: '2016 — 2019', role: 'Senior Designer, Field & Co', note: 'Identity systems for culture and finance clients.' },
{ year: '2014 — 2016', role: 'Designer, Northfield Press', note: 'Editorial layouts, type and art direction.' },
];
const SERVICES = [
{ n: '01', t: 'Brand identity', d: 'Strategy, naming support, logo systems, type, colour and guidelines that teams actually use.' },
{ n: '02', t: 'Product design', d: 'Research, flows and interfaces for web and mobile, shipped as a living design system.' },
{ n: '03', t: 'Editorial & web', d: 'Art-directed websites and digital publications with a strong typographic voice.' },
{ n: '04', t: 'Motion', d: 'Logo animation, product films and motion guidelines for brands that move.' },
];
const FILTERS = ['All', 'Brand identity', 'Product design', 'Editorial web', 'Packaging'] as const;
export function PortfolioApp() {
const [filter, setFilter] = useState<(typeof FILTERS)[number]>('All');
const [open, setOpen] = useState<number | null>(null);
const [anchor, setAnchor] = useState(0);
const [menu, setMenu] = useState(false);
const [sent, setSent] = useState(false);
const shown = WORK.filter((w) => filter === 'All' || w.kind === filter);
const project = open === null ? null : WORK[open];
useEffect(() => {
if (open === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(null);
if (e.key === 'ArrowRight') setOpen((o) => (o === null ? o : (o + 1) % WORK.length));
if (e.key === 'ArrowLeft') setOpen((o) => (o === null ? o : (o + WORK.length - 1) % WORK.length));
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
const go = (id: string) => {
setMenu(false);
document.getElementById(`pf-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const submit = (e: FormEvent) => {
e.preventDefault();
setSent(true);
};
return (
<div className="relative min-h-screen overflow-x-clip bg-[#f6f3ee] font-sans text-stone-900 antialiased selection:bg-orange-200">
{/* nav */}
<header className="sticky top-0 z-40 border-b border-stone-900/5 bg-[#f6f3ee]/85 backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-5 sm:px-8">
<button onClick={() => go('top')} className="flex items-center gap-2 text-[15px] font-semibold tracking-[-0.02em]">
<span className="grid h-7 w-7 place-items-center rounded-full bg-stone-900 font-serif text-[15px] italic text-[#f6f3ee]">o</span>
Ora Lindqvist
</button>
<nav className="hidden items-center gap-1 sm:flex">
{['work', 'about', 'services', 'contact'].map((s) => (
<button key={s} onClick={() => go(s)} className="rounded-full px-3.5 py-1.5 text-[14px] capitalize text-stone-600 transition-colors hover:bg-stone-900/5 hover:text-stone-900">
{s}
</button>
))}
</nav>
<div className="flex items-center gap-2">
<span className="hidden items-center gap-2 rounded-full bg-emerald-100 px-3 py-1 text-[12px] font-medium text-emerald-800 md:flex">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-600" /> Booking Q1 2027
</span>
<button onClick={() => setMenu((m) => !m)} aria-label="Menu" aria-expanded={menu} className="grid h-9 w-9 place-items-center rounded-full hover:bg-stone-900/5 sm:hidden">
{menu ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{menu && (
<div className="border-t border-stone-900/5 px-5 pb-4 sm:hidden">
{['work', 'about', 'services', 'contact'].map((s) => (
<button key={s} onClick={() => go(s)} className="block w-full py-3 text-left text-[18px] capitalize">
{s}
</button>
))}
</div>
)}
</header>
{/* hero */}
<section id="pf-top" className="mx-auto max-w-6xl px-5 pb-16 pt-16 sm:px-8 sm:pt-24">
<p className="font-mono text-[12px] uppercase tracking-[0.18em] text-stone-500 animate-fade-up">Independent designer · Lisbon</p>
<h1 className="mt-6 max-w-5xl text-[44px] font-medium leading-[1.02] tracking-[-0.045em] animate-fade-up sm:text-[76px] lg:text-[92px]" style={{ animationDelay: '100ms' }}>
I design brands and products that feel <em className="font-serif font-normal italic text-orange-700">quietly</em> inevitable.
</h1>
<div className="mt-10 flex flex-col gap-6 animate-fade-up sm:flex-row sm:items-end sm:justify-between" style={{ animationDelay: '220ms' }}>
<p className="max-w-md text-[17px] leading-relaxed text-stone-600">Twelve years of identity, product and editorial work for banks, health companies, publishers and the odd coffee roaster.</p>
<div className="flex gap-3">
<button onClick={() => go('work')} className="inline-flex h-12 items-center gap-2 rounded-full bg-stone-900 px-6 text-[15px] font-medium text-[#f6f3ee] transition-transform active:scale-[0.97]">
See selected work <ArrowRight className="h-4 w-4" />
</button>
<button onClick={() => go('contact')} className="inline-flex h-12 items-center rounded-full px-5 text-[15px] font-medium ring-1 ring-stone-900/15 hover:bg-stone-900/5">
Say hello
</button>
</div>
</div>
</section>
{/* work */}
<section id="pf-work" className="mx-auto max-w-6xl scroll-mt-16 px-5 py-16 sm:px-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<h2 className="text-[34px] font-medium tracking-[-0.04em] sm:text-[44px]">
Selected <em className="font-serif font-normal italic">work</em>
</h2>
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Filter work">
{FILTERS.map((f) => (
<button key={f} role="tab" aria-selected={filter === f} onClick={() => setFilter(f)} className={cn('rounded-full px-3 py-1.5 text-[13px] transition-colors', filter === f ? 'bg-stone-900 text-[#f6f3ee]' : 'text-stone-600 ring-1 ring-stone-900/10 hover:text-stone-900')}>
{f}
</button>
))}
</div>
</div>
<div className="mt-10 grid gap-6 sm:grid-cols-2">
{shown.map((w, i) => (
<Reveal key={w.id} delay={(i % 2) * 90}>
<button onClick={(e) => { setAnchor(e.currentTarget.getBoundingClientRect().top + window.scrollY); setOpen(w.id); }} className="group block w-full text-left" aria-label={`Open case study: ${w.title}`}>
<div className="relative aspect-[4/3] overflow-hidden rounded-[20px] bg-stone-200">
<img src={artwork(w.img, 960, 720)} alt="" className="h-full w-full object-cover transition-transform duration-700 ease-smooth group-hover:scale-[1.04]" />
<span className="absolute right-4 top-4 grid h-10 w-10 translate-y-2 place-items-center rounded-full bg-[#f6f3ee] opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100">
<ArrowUpRight className="h-4 w-4" />
</span>
</div>
<div className="mt-4 flex items-baseline justify-between gap-4">
<h3 className="text-[20px] font-medium tracking-[-0.02em]">{w.title}</h3>
<span className="font-mono text-[12px] text-stone-500">
{w.kind} · {w.year}
</span>
</div>
</button>
</Reveal>
))}
</div>
</section>
{/* about */}
<section id="pf-about" className="scroll-mt-16 border-y border-stone-900/10 bg-white/50">
<div className="mx-auto grid max-w-6xl gap-12 px-5 py-20 sm:px-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Reveal>
<h2 className="text-[34px] font-medium leading-[1.05] tracking-[-0.04em] sm:text-[44px]">
Twelve years, <em className="font-serif font-normal italic">one</em> obsession: clarity.
</h2>
<p className="mt-6 max-w-md text-[16px] leading-relaxed text-stone-600">I work with a small group of collaborators on a few projects a year, so every client gets a senior designer from first sketch to final file.</p>
<div className="mt-8 grid max-w-md grid-cols-3 gap-4">
{[
['60+', 'brands'],
['14', 'awards'],
['9', 'countries'],
].map(([a, b]) => (
<div key={b}>
<p className="text-[32px] font-medium tracking-[-0.04em]">{a}</p>
<p className="text-[13px] text-stone-500">{b}</p>
</div>
))}
</div>
</Reveal>
<ol className="relative border-l border-stone-900/15 pl-6">
{TIMELINE.map((t, i) => (
<Reveal key={t.year} delay={i * 80}>
<li className="relative pb-8 last:pb-0">
<span className={cn('absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[#f6f3ee]', i === 0 ? 'bg-orange-600' : 'bg-stone-400')} />
<p className="font-mono text-[12px] text-stone-500">{t.year}</p>
<p className="mt-1 text-[17px] font-medium">{t.role}</p>
<p className="mt-1 text-[14px] text-stone-600">{t.note}</p>
</li>
</Reveal>
))}
</ol>
</div>
</section>
{/* services */}
<section id="pf-services" className="mx-auto max-w-6xl scroll-mt-16 px-5 py-20 sm:px-8">
<h2 className="text-[34px] font-medium tracking-[-0.04em] sm:text-[44px]">
What I <em className="font-serif font-normal italic">do</em>
</h2>
<div className="mt-10 divide-y divide-stone-900/10 border-y border-stone-900/10">
{SERVICES.map((s) => (
<div key={s.n} className="group grid gap-2 py-6 transition-colors sm:grid-cols-[80px_minmax(0,1fr)_minmax(0,1.3fr)] sm:items-baseline">
<span className="font-mono text-[13px] text-stone-400">{s.n}</span>
<h3 className="text-[22px] font-medium tracking-[-0.02em] transition-transform duration-300 group-hover:translate-x-1">{s.t}</h3>
<p className="text-[15px] leading-relaxed text-stone-600">{s.d}</p>
</div>
))}
</div>
</section>
{/* contact */}
<section id="pf-contact" className="scroll-mt-16 px-3 pb-3 sm:px-4">
<div className="mx-auto grid max-w-[1400px] gap-10 rounded-[28px] bg-stone-900 px-6 py-14 text-[#f6f3ee] sm:px-12 lg:grid-cols-2">
<div>
<h2 className="text-[38px] font-medium leading-[1.02] tracking-[-0.045em] sm:text-[56px]">
Have a project <em className="font-serif font-normal italic text-orange-300">in mind?</em>
</h2>
<p className="mt-5 max-w-sm text-[16px] leading-relaxed text-stone-400">Tell me a little about it. I reply to every message within two working days.</p>
<a href="#" onClick={(e) => e.preventDefault()} className="mt-8 inline-flex items-center gap-2 text-[16px] underline decoration-stone-600 underline-offset-4 hover:decoration-orange-300">
<Mail className="h-4 w-4" /> hello@studio-ora.design
</a>
</div>
{sent ? (
<div className="flex flex-col items-start justify-center rounded-2xl bg-white/5 p-8 animate-scale-in">
<span className="grid h-10 w-10 place-items-center rounded-full bg-emerald-400 text-stone-900">
<Check className="h-5 w-5" />
</span>
<p className="mt-4 text-[20px] font-medium">Thanks, message received.</p>
<p className="mt-1 text-[14px] text-stone-400">I'll be in touch soon.</p>
<button onClick={() => setSent(false)} className="mt-6 text-[13px] text-stone-400 underline underline-offset-4">Send another</button>
</div>
) : (
<form onSubmit={submit} className="grid gap-3">
{[
['name', 'Your name', 'text'],
['email', 'Email', 'email'],
].map(([n, l, t]) => (
<label key={n} className="grid gap-1.5 text-[13px] text-stone-400">
{l}
<input required name={n} type={t} className="h-11 rounded-xl border border-white/10 bg-white/5 px-3.5 text-[15px] text-white outline-none focus:border-orange-300" />
</label>
))}
<label className="grid gap-1.5 text-[13px] text-stone-400">
About the project
<textarea required rows={4} className="resize-none rounded-xl border border-white/10 bg-white/5 px-3.5 py-3 text-[15px] text-white outline-none focus:border-orange-300" />
</label>
<button type="submit" className="mt-2 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-[#f6f3ee] text-[15px] font-medium text-stone-900 transition-transform active:scale-[0.98]">
Send message <ArrowRight className="h-4 w-4" />
</button>
</form>
)}
</div>
</section>
<footer className="mx-auto flex max-w-6xl flex-col items-start justify-between gap-3 px-5 py-8 text-[13px] text-stone-500 sm:flex-row sm:items-center sm:px-8">
<span>© 2026 Ora Lindqvist · Studio Ora</span>
<span className="flex gap-5">
{['Instagram', 'Dribbble', 'LinkedIn', 'Read.cv'].map((s) => (
<a key={s} href="#" onClick={(e) => e.preventDefault()} className="hover:text-stone-900">
{s}
</a>
))}
</span>
</footer>
{/* case study overlay */}
{project && (
<div className="absolute inset-0 z-50 bg-stone-900/40 p-3 backdrop-blur-sm animate-fade-in sm:p-8" onClick={() => setOpen(null)} role="dialog" aria-modal="true" aria-label={project.title}>
<article style={{ marginTop: Math.max(0, anchor - 120) }} className="mx-auto max-w-4xl overflow-hidden rounded-[24px] bg-[#f6f3ee] animate-scale-in" onClick={(e) => e.stopPropagation()}>
<div className="relative aspect-[16/9] bg-stone-200">
<img src={artwork(project.img, 1280, 720)} alt="" className="h-full w-full object-cover" />
<button onClick={() => setOpen(null)} aria-label="Close" className="absolute right-4 top-4 grid h-10 w-10 place-items-center rounded-full bg-[#f6f3ee] shadow">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid gap-8 p-6 sm:p-10 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div>
<p className="font-mono text-[12px] uppercase tracking-[0.16em] text-stone-500">
{project.kind} · {project.year}
</p>
<h3 className="mt-3 text-[34px] font-medium tracking-[-0.04em]">{project.title}</h3>
<p className="mt-4 text-[16px] leading-relaxed text-stone-600">{project.summary}</p>
<p className="mt-4 text-[14px] text-stone-500">Role: {project.role}</p>
</div>
<ul className="space-y-3">
{project.results.map((r) => (
<li key={r} className="rounded-2xl bg-white px-4 py-3 text-[15px] font-medium ring-1 ring-stone-900/5">
{r}
</li>
))}
</ul>
</div>
<div className="flex items-center justify-between border-t border-stone-900/10 px-6 py-4 text-[13px] text-stone-500 sm:px-10">
<button onClick={() => setOpen((project.id + WORK.length - 1) % WORK.length)} className="hover:text-stone-900">← Previous</button>
<span>Esc to close</span>
<button onClick={() => setOpen((project.id + 1) % WORK.length)} className="hover:text-stone-900">Next →</button>
</div>
</article>
</div>
)}
</div>
);
}components/niceui/pages/landing/shared/motion.tsx
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
export function useInView<T extends Element>(threshold = 0.2) {
const ref = useRef<T>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
io.disconnect();
}
},
{ threshold },
);
io.observe(el);
return () => io.disconnect();
}, [threshold]);
return { ref, inView };
}
export function Reveal({ children, delay = 0, className }: { children: ReactNode; delay?: number; className?: string }) {
const { ref, inView } = useInView<HTMLDivElement>(0.15);
return (
<div
ref={ref}
className={cn('transition-all duration-1000 ease-smooth', inView ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-6 opacity-0 blur-[2px]', className)}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
export function useCountUp(target: number, active: boolean, duration = 1800) {
const [val, setVal] = useState(0);
useEffect(() => {
if (!active) return;
let raf = 0;
const start = performance.now();
const loop = (now: number) => {
const t = Math.min(1, (now - start) / duration);
setVal(target * (1 - Math.pow(2, -10 * t)));
if (t < 1) raf = requestAnimationFrame(loop);
else setVal(target);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [target, active, duration]);
return val;
}components/niceui/gallery/art.ts
/*
* Procedural artwork for the gallery demos: soft landscapes and abstract mesh
* gradients with film grain, rendered once to data URLs. Swap `src` for real
* photos in your app — every gallery component just takes image URLs.
*/
export type Artwork = { id: number; title: string; artist: string; place: string; year: number; tone: 'warm' | 'cool' | 'dusk' | 'mono' };
const PALETTES: string[][] = [
['#0b1d3a', '#1f4e79', '#f2a65a', '#f7d9a8'],
['#120c26', '#4b2a77', '#e66b9d', '#ffd1b3'],
['#06222b', '#0f5f6b', '#7fd1c7', '#e8f6f1'],
['#1b1410', '#5b3a26', '#d98c4a', '#f6e1c3'],
['#0e0f13', '#2b2f3a', '#8a93a6', '#e6e8ee'],
['#0a1a12', '#1f5134', '#9cc98a', '#f1f7da'],
['#1a0f14', '#6b1f3a', '#ef6f5e', '#ffd8a8'],
['#071426', '#123f73', '#5aa9e6', '#d6ecff'],
['#191325', '#3b2d5c', '#b39ddb', '#f3e9ff'],
['#201608', '#6e4a12', '#e0b34a', '#fff1c4'],
['#0b1216', '#29424f', '#6fa3b8', '#f0e6d2'],
['#170b0b', '#4a1414', '#c84b31', '#f5c26b'],
];
export const ARTWORKS: Artwork[] = [
{ id: 0, title: 'Low Tide, Dawn', artist: 'Mira Okafor', place: 'Comporta', year: 2025, tone: 'warm' },
{ id: 1, title: 'Neon Bloom', artist: 'Ilya Varga', place: 'Kyoto', year: 2024, tone: 'dusk' },
{ id: 2, title: 'Glass Lagoon', artist: 'Sana Iqbal', place: 'Faroe Islands', year: 2025, tone: 'cool' },
{ id: 3, title: 'Copper Dunes', artist: 'Teo Marchetti', place: 'Merzouga', year: 2023, tone: 'warm' },
{ id: 4, title: 'Fog Study No. 4', artist: 'Lena Brandt', place: 'Dolomites', year: 2024, tone: 'mono' },
{ id: 5, title: 'Green Hour', artist: 'Ravi Menon', place: 'Munnar', year: 2025, tone: 'cool' },
{ id: 6, title: 'Ember Coast', artist: 'Noor Haddad', place: 'Big Sur', year: 2024, tone: 'warm' },
{ id: 7, title: 'Blue Hour Harbour', artist: 'Jonas Weber', place: 'Bergen', year: 2025, tone: 'cool' },
{ id: 8, title: 'Lavender Field', artist: 'Camille Roy', place: 'Valensole', year: 2023, tone: 'dusk' },
{ id: 9, title: 'Golden Ridge', artist: 'Aiko Tanaka', place: 'Hokkaido', year: 2025, tone: 'warm' },
{ id: 10, title: 'Salt Flats', artist: 'Diego Alvarez', place: 'Uyuni', year: 2024, tone: 'mono' },
{ id: 11, title: 'Red Canyon', artist: 'Hana Kobayashi', place: 'Arizona', year: 2025, tone: 'warm' },
];
let seedState = 1;
const rand = () => {
seedState = (seedState * 1664525 + 1013904223) >>> 0;
return seedState / 4294967296;
};
const cache = new Map<string, string>();
/** A deterministic artwork as a data URL. `kind` picks a landscape or an abstract mesh. */
export function artwork(id: number, w = 960, h = 640, kind: 'landscape' | 'mesh' = id % 3 === 1 ? 'mesh' : 'landscape'): string {
const key = `${id}-${w}-${h}-${kind}`;
const hit = cache.get(key);
if (hit) return hit;
if (typeof document === 'undefined') return '';
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const g = c.getContext('2d');
if (!g) return '';
seedState = (id + 1) * 9973;
const [deep, mid, hot, light] = PALETTES[id % PALETTES.length];
const sky = g.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, deep);
sky.addColorStop(0.55, mid);
sky.addColorStop(1, kind === 'mesh' ? deep : hot);
g.fillStyle = sky;
g.fillRect(0, 0, w, h);
// soft light blobs
const blobs = kind === 'mesh' ? 6 : 3;
for (let i = 0; i < blobs; i++) {
const x = rand() * w;
const y = rand() * h * (kind === 'mesh' ? 1 : 0.6);
const r = (0.25 + rand() * 0.45) * Math.max(w, h);
const rg = g.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, [hot, light, mid][i % 3] + 'cc');
rg.addColorStop(1, 'transparent');
g.globalCompositeOperation = 'screen';
g.fillStyle = rg;
g.fillRect(0, 0, w, h);
}
g.globalCompositeOperation = 'source-over';
if (kind === 'landscape') {
// sun
const sx = (0.25 + rand() * 0.5) * w;
const sy = h * (0.38 + rand() * 0.12);
const sr = Math.min(w, h) * (0.07 + rand() * 0.05);
const glow = g.createRadialGradient(sx, sy, 0, sx, sy, sr * 5);
glow.addColorStop(0, light + 'aa');
glow.addColorStop(1, 'transparent');
g.fillStyle = glow;
g.fillRect(0, 0, w, h);
g.fillStyle = light;
g.beginPath();
g.arc(sx, sy, sr, 0, Math.PI * 2);
g.fill();
// ridges, back to front
for (let layer = 0; layer < 4; layer++) {
const base = h * (0.5 + layer * 0.12);
const amp = h * (0.12 - layer * 0.02);
const f1 = 1.5 + rand() * 2;
const f2 = 4 + rand() * 4;
const ph = rand() * 10;
g.beginPath();
g.moveTo(0, h);
for (let x = 0; x <= w; x += 4) {
const u = x / w;
const y = base - amp * (0.6 * Math.sin(u * f1 * Math.PI + ph) + 0.3 * Math.sin(u * f2 * Math.PI + ph * 2) + 0.1 * Math.sin(u * 23 + ph));
g.lineTo(x, y);
}
g.lineTo(w, h);
g.closePath();
const t = layer / 3;
const lg = g.createLinearGradient(0, base - amp, 0, h);
lg.addColorStop(0, mix(mid, deep, 0.25 + t * 0.6));
lg.addColorStop(1, mix(deep, '#000000', t * 0.5));
g.fillStyle = lg;
g.globalAlpha = 0.92;
g.fill();
g.globalAlpha = 1;
}
} else {
// flowing ribbons
for (let i = 0; i < 3; i++) {
g.beginPath();
const y0 = h * (0.2 + rand() * 0.6);
g.moveTo(-50, y0);
g.bezierCurveTo(w * 0.3, y0 - h * (rand() - 0.5), w * 0.7, y0 + h * (rand() - 0.5), w + 50, y0 + h * (rand() - 0.5) * 0.6);
g.lineWidth = Math.min(w, h) * (0.08 + rand() * 0.12);
g.strokeStyle = [light, hot, mid][i] + '55';
g.filter = `blur(${Math.round(Math.min(w, h) * 0.04)}px)`;
g.stroke();
g.filter = 'none';
}
}
// vignette
const vg = g.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.75);
vg.addColorStop(0, 'transparent');
vg.addColorStop(1, 'rgba(0,0,0,0.45)');
g.fillStyle = vg;
g.fillRect(0, 0, w, h);
// film grain
const img = g.getImageData(0, 0, w, h);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const n = (rand() - 0.5) * 18;
d[i] += n;
d[i + 1] += n;
d[i + 2] += n;
}
g.putImageData(img, 0, 0);
const url = c.toDataURL('image/jpeg', 0.86);
cache.set(key, url);
return url;
}
function mix(a: string, b: string, t: number) {
const pa = parseInt(a.slice(1), 16);
const pb = parseInt(b.slice(1), 16);
const ch = (s: number) => Math.round(((pa >> s) & 255) * (1 - t) + ((pb >> s) & 255) * t);
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
}