diff --git a/apps/web/app/(site)/globals.css b/apps/web/app/(site)/globals.css index 7bb419f..faa4d0d 100644 --- a/apps/web/app/(site)/globals.css +++ b/apps/web/app/(site)/globals.css @@ -321,26 +321,6 @@ /* Additional global styles from BaseLayout */ -/* Custom scrollbar */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: #f1f5f9; -} - -::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 4px; - transition: background 0.2s ease; -} - -::-webkit-scrollbar-thumb:hover { - background: #94a3b8; -} - /* Selection color */ ::selection { background: #0f172a; @@ -1079,8 +1059,72 @@ pre:has(code[class*="language-"]) { transform: translate(0, 0); } +/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */ +.bit-glow { + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + opacity: 0; + transition: opacity 400ms ease; +} +@media (hover: hover) { + [data-glow]:hover > .bit-glow { + opacity: 1; + } +} +/* Dark surfaces: the dot grid lights up around the pointer. */ +.bit-glow-dots { + background-image: + radial-gradient( + 320px circle at var(--gx, 50%) var(--gy, 50%), + rgba(148, 163, 184, 0.22), + transparent 70% + ), + radial-gradient(#e2e8f0 1.25px, transparent 1.25px); + background-size: + auto, + 20px 20px; + -webkit-mask-image: radial-gradient( + 280px circle at var(--gx, 50%) var(--gy, 50%), + #000, + transparent 75% + ); + mask-image: radial-gradient( + 280px circle at var(--gx, 50%) var(--gy, 50%), + #000, + transparent 75% + ); +} +/* Cards: only the border lights up, in the card's text colour. */ +.bit-glow-edge { + inset: -1px; + padding: 2px; + background: radial-gradient( + 320px circle at var(--gx, 50%) var(--gy, 50%), + currentColor, + transparent 70% + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box exclude, + linear-gradient(#000 0 0); +} +/* Dark buttons: a spot of light under the pointer. */ +.bit-glow-spot { + background: radial-gradient( + 90px circle at var(--gx, 50%) var(--gy, 50%), + rgba(129, 140, 248, 0.5), + transparent 70% + ); +} + @media (prefers-reduced-motion: reduce) { - .bit-burst { + .bit-burst, + .bit-glow { display: none; } .bit-card::before, @@ -1088,3 +1132,252 @@ pre:has(code[class*="language-"]) { transition: none; } } + +/* + * Calls to action: a light travels round the edge of the pill and glows softly + * behind it; on hover the glow comes up. `bit-cta-light` is for white pills on + * dark surfaces, `bit-cta-quiet` only lights up on hover. + */ +@property --cta-angle { + syntax: ""; + inherits: false; + initial-value: 0deg; +} +.bit-cta { + --cta-a: rgba(129, 140, 248, 0.95); + --cta-b: #ffffff; + position: relative; + isolation: isolate; +} +.bit-cta-light { + --cta-a: rgba(99, 102, 241, 0.95); + --cta-b: #312e81; +} +.bit-cta-quiet { + --cta-a: rgba(99, 102, 241, 0.9); + --cta-b: #0f172a; +} +.bit-cta::before, +.bit-cta::after { + content: ""; + position: absolute; + border-radius: inherit; + pointer-events: none; + background: conic-gradient( + from var(--cta-angle), + transparent 0 58%, + var(--cta-a) 76%, + var(--cta-b) 86%, + var(--cta-a) 93%, + transparent 100% + ); + animation: bit-cta-turn 6s linear infinite; +} +/* The edge itself. */ +.bit-cta::before { + inset: -1px; + padding: 1.5px; + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box exclude, + linear-gradient(#000 0 0); +} +/* The glow behind the edge. */ +.bit-cta::after { + inset: -2px; + z-index: -1; + padding: 6px; + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box exclude, + linear-gradient(#000 0 0); + filter: blur(10px); + opacity: 0.55; + transition: opacity 300ms ease; +} +.bit-cta-quiet::before, +.bit-cta-quiet::after { + opacity: 0; + transition: opacity 300ms ease; +} +@media (hover: hover) { + .bit-cta:hover::after, + .group:hover > .bit-cta::after { + opacity: 1; + } + .bit-cta-quiet:hover::before { + opacity: 1; + } + .bit-cta-quiet:hover::after { + opacity: 0.6; + } +} +@keyframes bit-cta-turn { + to { + --cta-angle: 360deg; + } +} + +/* Slow light drifting over a dark picture (see PortraitSection). */ +.bit-aurora { + position: absolute; + inset: -10%; + pointer-events: none; + mix-blend-mode: screen; + background: + radial-gradient( + 38% 46% at 38% 42%, + rgba(99, 102, 241, 0.55), + transparent 70% + ), + radial-gradient( + 30% 40% at 64% 72%, + rgba(129, 140, 248, 0.35), + transparent 70% + ); + filter: blur(40px); + animation: bit-aurora-drift 16s ease-in-out infinite alternate; +} +@keyframes bit-aurora-drift { + from { + transform: translate3d(-4%, -3%, 0) scale(1); + } + to { + transform: translate3d(5%, 4%, 0) scale(1.12); + } +} + +/* The current station on the career line: a node that breathes light. */ +.bit-node-live { + animation: bit-node-breathe 3.2s ease-in-out infinite; +} +@keyframes bit-node-breathe { + 0%, + 100% { + box-shadow: + 0 0 0 4px rgba(129, 140, 248, 0.22), + 0 0 18px 2px rgba(129, 140, 248, 0.55); + } + 50% { + box-shadow: + 0 0 0 8px rgba(129, 140, 248, 0.12), + 0 0 34px 8px rgba(129, 140, 248, 0.85); + } +} + +/* + * Native feel: the page should behave like a well-made app on every input type. + */ +html { + color-scheme: light; + /* Anchor jumps stop below the fixed header. */ + scroll-padding-top: 6rem; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + accent-color: #0f172a; + caret-color: #0f172a; +} + +body { + text-rendering: optimizeLegibility; + font-kerning: normal; +} + +/* Headlines break into even lines, running text avoids lonely last words. */ +h1, +h2, +h3, +h4 { + text-wrap: balance; +} +p, +li, +dd, +summary { + text-wrap: pretty; +} + +/* Controls react at once to a tap and are not selected like text. */ +a, +button, +summary, +[role="tab"], +[role="button"] { + touch-action: manipulation; +} +button, +summary, +[role="tab"], +[role="button"], +a[href^="#"] { + -webkit-user-select: none; + user-select: none; +} + +/* Decoration is neither selectable nor draggable. */ +[aria-hidden="true"], +canvas, +img, +svg { + -webkit-user-select: none; + user-select: none; +} +img, +svg, +a { + -webkit-user-drag: none; +} + +/* One visible keyboard focus, none for mouse and touch. */ +:focus:not(:focus-visible) { + outline: none; +} +:focus-visible { + outline: 2px solid #0f172a; + outline-offset: 3px; +} +a:focus-visible, +button:focus-visible, +summary:focus-visible { + border-radius: 6px; +} + +/* A short press-in on anything that can be pressed. */ +@media (prefers-reduced-motion: no-preference) { + button:active, + [role="tab"]:active, + a[class*="rounded-full"]:active, + a:active > [class*="rounded-full"] { + scale: 0.97; + transition-duration: 80ms; + } +} + +/* Dark surfaces: light selection, light focus ring, dark system controls. */ +.bg-slate-900, +.bg-slate-950 { + color-scheme: dark; +} +.bg-slate-900 ::selection, +.bg-slate-950 ::selection { + background: #f8fafc; + color: #0f172a; +} +.bg-slate-900 :focus-visible, +.bg-slate-950 :focus-visible { + outline-color: #f8fafc; +} + +/* Fixed controls stay clear of the home indicator and rounded corners. */ +@supports (padding: env(safe-area-inset-bottom)) { + body { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } +} diff --git a/apps/web/app/(site)/layout.tsx b/apps/web/app/(site)/layout.tsx index 3ff5e85..ee78ffa 100644 --- a/apps/web/app/(site)/layout.tsx +++ b/apps/web/app/(site)/layout.tsx @@ -1,23 +1,32 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Inter, JetBrains_Mono, Newsreader } from "next/font/google"; import { Analytics } from "@/src/components/Analytics"; import { BitBurst } from "@/src/components/bits/BitBurst"; +import { BitGlow } from "@/src/components/bits/BitGlow"; import { Footer } from "@/src/components/Footer"; import { Header } from "@/src/components/Header"; import { InteractiveElements } from "@/src/components/InteractiveElements"; import { + INDEXING, SITE_DESCRIPTION, + SITE_LANGUAGE, SITE_NAME, SITE_TITLE, SITE_URL, } from "@/src/content/site"; +import { robotsMeta } from "@/src/domain/indexing"; import "./globals.css"; const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); +// A real monospaced fallback has almost the same letter width. The default +// fallback (a resized Arial) wraps the mono labels differently, so the hero +// jumped when the font arrived. const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains-mono", display: "swap", + adjustFontFallback: false, + fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"], }); const newsreader = Newsreader({ subsets: ["latin"], @@ -36,6 +45,7 @@ export const metadata: Metadata = { applicationName: SITE_NAME, authors: [{ name: SITE_NAME, url: SITE_URL }], creator: SITE_NAME, + robots: robotsMeta(INDEXING), icons: { icon: "/favicon.svg", apple: "/apple-touch-icon.png", @@ -56,6 +66,12 @@ export const metadata: Metadata = { }, }; +export const viewport: Viewport = { + themeColor: "#ffffff", + colorScheme: "light", + viewportFit: "cover", +}; + export default function RootLayout({ children, }: { @@ -63,7 +79,7 @@ export default function RootLayout({ }) { return ( @@ -72,6 +88,7 @@ export default function RootLayout({