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
+2 -2
View File
@@ -5,8 +5,8 @@
"lang": "it", "lang": "it",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#fafafb", "background_color": "#e4e8ed",
"theme_color": "#fafafb", "theme_color": "#e4e8ed",
"orientation": "portrait", "orientation": "portrait",
"icons": [ "icons": [
{ {
+58 -10
View File
@@ -1,5 +1,7 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { CalendarDays, Home, Trophy, Users } from "lucide-react"; 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 // Quattro voci e non cinque: il profilo sta in alto a destra
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS. // nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
@@ -11,6 +13,8 @@ const items = [
] as const; ] as const;
export function BottomNav() { export function BottomNav() {
const ridotto = useReducedMotion();
return ( return (
<nav <nav
aria-label="Navigazione principale" aria-label="Navigazione principale"
@@ -20,31 +24,75 @@ export function BottomNav() {
// arrivare al contenuto sotto. // arrivare al contenuto sotto.
className="pad-sicura-fondo pointer-events-none fixed inset-x-0 bottom-0 z-40 px-3" 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 Altezza fissa dal token, non dedotta dal contenuto: la striscia
toccabile misura uguale su iOS e Android, e sotto varia solo l'inset 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 di sistema. Prima l'altezza dipendeva dai padding delle voci e nessuno
poteva saperla da fuori. 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 }) => ( {items.map(({ to, label, icon: Icon }) => (
<Link <Link
key={to} key={to}
to={to} to={to}
activeOptions={{ exact: to === "/" }} activeOptions={{ exact: to === "/" }}
activeProps={{ "aria-current": "page" }} activeProps={{ "aria-current": "page" }}
// Lo stato attivo non è solo colore: cambia anche il peso del // Lo stato attivo non è solo colore: è la capsula piena sotto la
// testo e compare la barretta sopra l'icona. // voce, più il peso del testo. Chi non distingue i colori vede
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" // 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 }) => ( {({ isActive }) => (
<> <>
<span {isActive ? (
aria-hidden="true" <motion.span
className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-accent opacity-0 transition-opacity group-data-[status=active]:opacity-100" // `layoutId`: la capsula è un solo elemento che scivola da
/> // una voce all'altra invece di sparire e ricomparire. La
<Icon className="h-5 w-5" strokeWidth={isActive ? 2.6 : 2.2} /> // molla è interrompibile, quindi due tocchi rapidi non
{label} // 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> </Link>
+1 -1
View File
@@ -38,7 +38,7 @@ const DrawerContent = React.forwardRef<
<DrawerPrimitive.Content <DrawerPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background shadow-chrome",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -9,7 +9,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-chrome",
description: "group-[.toast]:text-muted-foreground", description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
+1 -1
View File
@@ -100,7 +100,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
{ name: "author", content: "CRAP Volley" }, { name: "author", content: "CRAP Volley" },
// Deve combaciare con --background, altrimenti la barra di stato resta // Deve combaciare con --background, altrimenti la barra di stato resta
// nera sopra un'interfaccia chiara. // nera sopra un'interfaccia chiara.
{ name: "theme-color", content: "#fafafb" }, { name: "theme-color", content: "#e4e8ed" },
{ property: "og:title", content: "CrAPP — L'app del CRAP Volley" }, { property: "og:title", content: "CrAPP — L'app del CRAP Volley" },
{ {
property: "og:description", property: "og:description",
+122 -12
View File
@@ -75,7 +75,25 @@
*/ */
--altezza-nav: 3.75rem; --altezza-nav: 3.75rem;
--pad-sicura-fondo: max(env(safe-area-inset-bottom), 0.5rem); --pad-sicura-fondo: max(env(safe-area-inset-bottom), 0.5rem);
--background: oklch(0.985 0.002 240); /*
* Rampa dei neutri, distanziata per essere vista.
*
* Prima fondo (0.985), carta (1.0) e `secondary` (0.955) 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`, che su un telefono al sole
* sparisce.
*
* Ora fondo 0.93 e carta 1.0 stanno a 1.23, e `secondary` (0.90) sta a 1.35
* dalla carta: un elemento dentro una carta è più scuro della carta, 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 qui vale 4.62 e a 0.92
* scenderebbe a 4.48, sotto AA. Per andare più giù va scurito anche
* l'accento.
*/
--background: oklch(0.93 0.008 255);
--foreground: oklch(0.16 0.01 260); --foreground: oklch(0.16 0.01 260);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.16 0.01 260); --card-foreground: oklch(0.16 0.01 260);
@@ -83,17 +101,29 @@
--popover-foreground: oklch(0.16 0.01 260); --popover-foreground: oklch(0.16 0.01 260);
--primary: oklch(0.16 0.012 265); --primary: oklch(0.16 0.012 265);
--primary-foreground: oklch(0.99 0 0); --primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.955 0.004 250); --secondary: oklch(0.9 0.01 255);
--secondary-foreground: oklch(0.2 0.012 265); --secondary-foreground: oklch(0.2 0.012 265);
--muted: oklch(0.955 0.004 250); --muted: oklch(0.925 0.008 255);
--muted-foreground: oklch(0.52 0.015 260); /* 0.48 e non 0.52: sul nuovo `secondary` il vecchio valore scendeva a 4.2. */
--accent: oklch(0.58 0.22 27); --muted-foreground: oklch(0.48 0.015 260);
/*
* Accento più scuro (era 0.58): su un fondo più scuro il rosso di prima
* scendeva a 4.07 come testo. A 0.54 migliora in tutte e tre le direzioni —
* 4.62 sul fondo, 5.67 sulla carta, 5.51 per il bianco che ci sta sopra.
*/
--accent: oklch(0.54 0.22 27);
--accent-foreground: oklch(0.99 0 0); --accent-foreground: oklch(0.99 0 0);
--destructive: oklch(0.58 0.22 27); /*
* Distruttivo e accento erano lo stesso identico colore: «elimina» e
* «selezionato» davano lo stesso segnale. Resta rosso — è la convenzione —
* ma più scuro e più cupo dell'accento del brand.
*/
--destructive: oklch(0.47 0.19 18);
--destructive-foreground: oklch(0.99 0 0); --destructive-foreground: oklch(0.99 0 0);
--border: oklch(0.915 0.006 255); --border: oklch(0.875 0.01 255);
--input: oklch(0.915 0.006 255); --input: oklch(0.875 0.01 255);
--ring: oklch(0.58 0.22 27); /* Il focus non è una selezione: viene dal neutro, non dall'accento. */
--ring: oklch(0.35 0.03 265);
/* Luminosità scelte perché il testo bianco sopra superi 4.5:1 (WCAG AA). */ /* Luminosità scelte perché il testo bianco sopra superi 4.5:1 (WCAG AA). */
--success: oklch(0.53 0.16 152); --success: oklch(0.53 0.16 152);
--success-foreground: oklch(0.99 0 0); --success-foreground: oklch(0.99 0 0);
@@ -117,9 +147,26 @@
oklch(0.24 0.03 20) 55%, oklch(0.24 0.03 20) 55%,
oklch(0.45 0.19 27) oklch(0.45 0.19 27)
); );
--gradient-accent: linear-gradient(120deg, oklch(0.58 0.22 27), oklch(0.68 0.19 40)); /* Parte dall'accento nuovo: era rimasto sul vecchio 0.58 e il gradiente
risultava più chiaro del colore pieno che gli sta accanto. */
--gradient-accent: linear-gradient(120deg, oklch(0.54 0.22 27), oklch(0.64 0.19 40));
/*
* Tre quote, non una. Prima `shadow-card` valeva sia per una tessera 3x3 sia
* per la barra di navigazione: superfici grandi e piccole sullo stesso piano.
* Una superficie più grande deve leggersi più spessa.
*/
--shadow-card: 0 1px 2px oklch(0.16 0.01 260 / 6%), 0 12px 28px -18px oklch(0.16 0.01 260 / 35%); --shadow-card: 0 1px 2px oklch(0.16 0.01 260 / 6%), 0 12px 28px -18px oklch(0.16 0.01 260 / 35%);
--shadow-pop: 0 18px 40px -20px oklch(0.58 0.22 27 / 55%); /*
* Chrome flottante: barra, drawer. Ombra più profonda e più diffusa, più il
* filo chiaro sul bordo alto — la luce che prende il materiale, ed è quello
* che lo fa leggere come un oggetto sopra il contenuto invece che un buco.
*/
--shadow-chrome:
inset 0 1px 0 oklch(1 0 0 / 60%), 0 1px 3px oklch(0.16 0.01 260 / 10%),
0 24px 48px -24px oklch(0.16 0.01 260 / 45%);
/* Sollevamento al passaggio del mouse: neutro, non tinto di accento. */
--shadow-alza: 0 12px 28px -14px oklch(0.16 0.01 260 / 40%);
--shadow-pop: 0 18px 40px -20px oklch(0.54 0.22 27 / 55%);
} }
@theme inline { @theme inline {
@@ -158,6 +205,10 @@
box-shadow: var(--shadow-pop); box-shadow: var(--shadow-pop);
} }
@utility shadow-chrome {
box-shadow: var(--shadow-chrome);
}
/* /*
* Il tracking del display è specifico per taglia: Bebas Neue a corpo grande * Il tracking del display è specifico per taglia: Bebas Neue a corpo grande
* legge troppo spaziato (serve tracking negativo), a corpo piccolo troppo * legge troppo spaziato (serve tracking negativo), a corpo piccolo troppo
@@ -290,7 +341,9 @@
@media (hover: hover) { @media (hover: hover) {
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: var(--shadow-pop); /* Neutra: `shadow-pop` è un alone di accento, e sotto una riga di
classifica era colore senza significato. */
box-shadow: var(--shadow-alza);
} }
} }
} }
@@ -378,6 +431,63 @@
-webkit-backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%);
} }
/*
* Vetro della barra flottante. È `materiale` spinto: si vede attraverso.
*
* `materiale` sta all'85% di card perché è una superficie larga quanto lo
* schermo, dove il divario di blur fra WebKit e Blink si nota (608f2ed). Qui
* la superficie è una pillola piccola: si può scendere al 62% e lasciare che
* sia il blur a fare il lavoro, che è la condizione perché si legga come vetro
* invece che come plastica bianca.
*
* Le tre ombre interne sono la luce sul bordo, non decorazione: filo chiaro in
* alto (la luce che entra), filo scuro in basso (lo spessore del vetro), alone
* chiaro diffuso appena dentro il bordo (il riflesso sulla curva). Senza queste
* un rettangolo sfocato resta un rettangolo sfocato.
*/
@utility vetro {
background-color: color-mix(in srgb, var(--color-card) 62%, transparent);
backdrop-filter: blur(18px) saturate(180%) brightness(1.04);
-webkit-backdrop-filter: blur(18px) saturate(180%) brightness(1.04);
box-shadow:
inset 0 1px 0 oklch(1 0 0 / 85%),
inset 0 -1px 0 oklch(0.16 0.01 260 / 8%),
inset 0 0 12px oklch(1 0 0 / 35%),
0 1px 3px oklch(0.16 0.01 260 / 10%),
0 24px 48px -24px oklch(0.16 0.01 260 / 45%);
}
/*
* La distorsione vera: `feDisplacementMap` sposta i pixel del fondale, quindi
* quello che sta sotto si deforma davvero invece di sfocarsi soltanto.
*
* Sta dietro `@supports` perché solo Blink accetta un filtro SVG dentro
* `backdrop-filter`: WebKit lo ignora — e ignorandolo scarterebbe tutta la
* dichiarazione, blur compreso — quindi su iPhone resta la regola sopra. È una
* miglioria progressiva, non l'effetto principale: se sparisse domani la barra
* resterebbe quella giusta.
*/
@supports (backdrop-filter: url("#vetro-rifrazione")) {
.vetro {
backdrop-filter: url("#vetro-rifrazione") blur(18px) saturate(180%) brightness(1.04);
}
}
/* Il vetro traslucido è un vezzo: se l'utente chiede meno trasparenza sparisce. */
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
.vetro {
background-color: var(--color-card);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.vetro {
background-color: var(--color-card);
}
}
/* /*
* Chrome su Android disattiva `backdrop-filter` quando l'accelerazione * Chrome su Android disattiva `backdrop-filter` quando l'accelerazione
* hardware non è disponibile: senza questo, su quei device la barra resta * hardware non è disponibile: senza questo, su quei device la barra resta