Dà profondità alle superfici e trasforma la barra in vetro

Fondo pagina, carte e `secondary` stavano dentro tre punti di luminosità:
carta e fondo avevano un rapporto di contrasto di 1.04, cioè erano lo stesso
bianco. Le carte non si leggevano come carte, e l'unica cosa che le staccava
era `shadow-card` — 6% di nero, che su un telefono al sole non esiste. Da qui
l'impressione che fondo, barra ed elementi fossero «tre bianchi».

La rampa dei neutri è ridistanziata: fondo 0.93, carta 1.0, `secondary` 0.90.
Carta e fondo passano a 1.23, un elemento dentro una carta sta a 1.35 dalla
carta ed è più scuro di essa, come nelle liste raggruppate di iOS. 0.93 è il
fondo più scuro possibile senza toccare altro: il limite lo impone
`text-accent` appoggiato sul fondo, che a 0.92 scenderebbe sotto 4.5.

Due colori vanno corretti di conseguenza, altrimenti la modifica peggiora la
leggibilità: l'accento scende a 0.54 (a 0.58 come testo faceva 4.07 sul nuovo
fondo; ora fa 4.62, e migliora anche il bianco che ci sta sopra, da 4.65 a
5.51) e `muted-foreground` a 0.48. Verificati convertendo le oklch in sRGB e
calcolando i rapporti, non a occhio.

`--destructive` e `--ring` erano *lo stesso identico colore* dell'accento:
«elimina», «selezionato» e «ho il focus qui» davano il medesimo segnale. Il
distruttivo resta rosso ma più scuro e cupo, il focus viene dal neutro.

Le ombre passano da una a tre quote: `shadow-card` per il contenuto,
`shadow-chrome` per barra, drawer e toast (più profonda, più il filo chiaro sul
bordo alto), `shadow-alza` neutra per l'hover — `premi` metteva un alone rosso
sotto ogni riga di classifica.

La barra ora usa `vetro`: fondo al 62% invece dell'85%, così ci si vede
attraverso, più tre ombre interne che sono il bordo illuminato. L'85% di
`materiale` esiste per tenere allineati WebKit e Blink (608f2ed) su una fascia
larga quanto lo schermo; su una pillola di 60 px lo scarto non si legge.

Dietro `@supports`, un `feDisplacementMap` distorce davvero il fondale: solo
Blink accetta un filtro SVG in `backdrop-filter`, e WebKit ignorando l'url
scarterebbe anche il blur. Miglioria progressiva, non l'effetto principale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 20:02:13 +02:00
co-authored by Claude Opus 5
parent bb4fb624f5
commit c089d9e8ba
6 changed files with 185 additions and 27 deletions
+58 -10
View File
@@ -1,5 +1,7 @@
import { Link } from "@tanstack/react-router";
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { molla } from "@/lib/molla";
// Quattro voci e non cinque: il profilo sta in alto a destra
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
@@ -11,6 +13,8 @@ const items = [
] as const;
export function BottomNav() {
const ridotto = useReducedMotion();
return (
<nav
aria-label="Navigazione principale"
@@ -20,31 +24,75 @@ export function BottomNav() {
// arrivare al contenuto sotto.
className="pad-sicura-fondo pointer-events-none fixed inset-x-0 bottom-0 z-40 px-3"
>
{/*
La mappa di spostamento del vetro. `feTurbulence` a bassa frequenza dà
una deformazione lenta e irregolare — la stessa cosa che fa un vetro non
perfettamente piano — e la sfocatura che segue toglie il granuloso del
rumore, altrimenti il fondale si sgranerebbe invece di piegarsi.
Nessuna dimensione e nessun colore: è solo la definizione del filtro che
il CSS richiama per id. La usa solo Blink (vedi `@supports` in
styles.css); su WebKit questo SVG resta lì senza fare niente.
*/}
<svg aria-hidden="true" className="absolute h-0 w-0" focusable="false">
<filter id="vetro-rifrazione" colorInterpolationFilters="sRGB">
<feTurbulence
type="fractalNoise"
baseFrequency="0.008 0.016"
numOctaves={2}
seed={7}
result="rumore"
/>
<feGaussianBlur in="rumore" stdDeviation={3} result="mappa" />
<feDisplacementMap
in="SourceGraphic"
in2="mappa"
scale={16}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</svg>
{/*
Altezza fissa dal token, non dedotta dal contenuto: la striscia
toccabile misura uguale su iOS e Android, e sotto varia solo l'inset
di sistema. Prima l'altezza dipendeva dai padding delle voci e nessuno
poteva saperla da fuori.
*/}
<div className="materiale pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 overflow-hidden rounded-full border border-border/60 px-1 shadow-lg">
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
<div className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5">
{items.map(({ to, label, icon: Icon }) => (
<Link
key={to}
to={to}
activeOptions={{ exact: to === "/" }}
activeProps={{ "aria-current": "page" }}
// Lo stato attivo non è solo colore: cambia anche il peso del
// testo e compare la barretta sopra l'icona.
className="group relative flex h-full flex-col items-center justify-center gap-1 text-xs font-semibold text-muted-foreground transition-colors data-[status=active]:font-extrabold data-[status=active]:text-accent"
// Lo stato attivo non è solo colore: è la capsula piena sotto la
// voce, più il peso del testo. Chi non distingue i colori vede
// comunque quale voce è quella corrente.
className="group relative flex h-full flex-col items-center justify-center gap-0.5 rounded-full text-xs font-semibold text-muted-foreground transition-colors data-[status=active]:font-extrabold data-[status=active]:text-accent-foreground"
>
{({ isActive }) => (
<>
<span
aria-hidden="true"
className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-accent opacity-0 transition-opacity group-data-[status=active]:opacity-100"
/>
<Icon className="h-5 w-5" strokeWidth={isActive ? 2.6 : 2.2} />
{label}
{isActive ? (
<motion.span
// `layoutId`: la capsula è un solo elemento che scivola da
// una voce all'altra invece di sparire e ricomparire. La
// molla è interrompibile, quindi due tocchi rapidi non
// fanno saltare la posizione.
layoutId="capsula-nav"
aria-hidden="true"
className="bg-accent-grad absolute inset-0 rounded-full shadow-pop"
transition={ridotto ? { duration: 0 } : molla.ui}
/>
) : null}
{/* Sopra la capsula, che è in flusso normale sotto di loro. */}
<span className="relative flex flex-col items-center gap-0.5">
<Icon className="h-5 w-5" strokeWidth={isActive ? 2.6 : 2.2} />
{label}
</span>
</>
)}
</Link>