Allinea la barra di navigazione fra iOS e Android

L'altezza della barra era dedotta dai padding delle voci e poi ricopiata a
occhio altrove: `pb-24` nel contenitore delle pagine, `pb-28` nella
celebrazione badge. Su iPhone i conti tornavano per 2 px — barra ~60 px più
34 px di safe area contro 96 px riservati — quindi bastava un inset diverso
(orizzontale, o un modello con home indicator più alta) perché l'ultimo
elemento della pagina finisse sotto la barra. Solo su iOS: su Android
avanzavano 36 px.

Ora la geometria sta in due token, `--altezza-nav` e `--pad-sicura-fondo`, e
le utility `spazio-nav` e `pad-sicura-fondo` la leggono da lì. La striscia
toccabile ha altezza fissa, così misura uguale sui due sistemi e sotto varia
solo l'inset: `max(env(safe-area-inset-bottom), 0.5rem)` dà ad Android un
respiro minimo dove iOS mette la home indicator. Identiche al pixel non
possono essere — quell'inset esiste per un motivo fisico che Android non ha —
ma la differenza è ora un margine in più, non un allineamento diverso.

`100vh` diventa `100dvh` ovunque: su iOS Safari `vh` misura il viewport con la
toolbar collassata, cioè più alto di quello visibile, e le schermate a tutta
altezza risultavano più lunghe dello schermo. `dvh` segue la toolbar.

Aggiunto il ripiego per Chrome su Android, che disattiva `backdrop-filter`
senza accelerazione hardware: senza, su quei device la barra restava
semitrasparente e il contenuto si leggeva attraverso.

Resta fuori portata una differenza sola: in Safari non installata la toolbar
di sistema sta in basso e collassa allo scroll, quindi la barra si muove con
essa. Su Chrome la toolbar è in alto. Si risolve installando la PWA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 14:50:32 +02:00
co-authored by Claude Opus 5
parent 5a534547ae
commit 78ae3fbbd7
6 changed files with 113 additions and 62 deletions
+64 -53
View File
@@ -5,7 +5,7 @@ description: Apple's approach to interface design and fluid, physical motion, tr
# Apple Design # Apple Design
How Apple builds interfaces that stop feeling like a computer and start feeling like an extension of you. This knowledge comes from Apple's WWDC design talks — chiefly *Designing Fluid Interfaces* (WWDC 2018) — distilled and translated into the web platform (CSS, Pointer Events, `requestAnimationFrame`, spring libraries like Motion/Framer Motion). How Apple builds interfaces that stop feeling like a computer and start feeling like an extension of you. This knowledge comes from Apple's WWDC design talks — chiefly _Designing Fluid Interfaces_ (WWDC 2018) — distilled and translated into the web platform (CSS, Pointer Events, `requestAnimationFrame`, spring libraries like Motion/Framer Motion).
The through-line: **an interface feels alive when motion starts from the current on-screen value, inherits the user's velocity, projects momentum forward, and can be grabbed and reversed at any instant.** Springs are the tool that makes all of this natural, because they are inherently interruptible and velocity-aware. The through-line: **an interface feels alive when motion starts from the current on-screen value, inherits the user's velocity, projects momentum forward, and can be grabbed and reversed at any instant.** Springs are the tool that makes all of this natural, because they are inherently interruptible and velocity-aware.
@@ -23,7 +23,7 @@ The moment lag appears, the feeling of directness "falls off a cliff." Response
- **Respond on pointer-down, not on release.** Highlight a button the instant it's pressed. Waiting for `click`/touch-up to show feedback feels dead. - **Respond on pointer-down, not on release.** Highlight a button the instant it's pressed. Waiting for `click`/touch-up to show feedback feels dead.
- **Be vigilant about every latency.** Audit debounces, artificial timers, transition waits, and the ~300ms tap delay. Anything on the input path that isn't essential is a regression. - **Be vigilant about every latency.** Audit debounces, artificial timers, transition waits, and the ~300ms tap delay. Anything on the input path that isn't essential is a regression.
- **Feedback must be continuous *during* the interaction, not just at the end.** For a drag, slider, or drawer, update the UI 1:1 with the pointer the whole way through — never animate only when the gesture completes. - **Feedback must be continuous _during_ the interaction, not just at the end.** For a drag, slider, or drawer, update the UI 1:1 with the pointer the whole way through — never animate only when the gesture completes.
```css ```css
/* Feedback lives on the press, and it's instant */ /* Feedback lives on the press, and it's instant */
@@ -37,13 +37,13 @@ The moment lag appears, the feeling of directness "falls off a cliff." Response
> "Touch and content should move together." > "Touch and content should move together."
When the user drags something, it must stay glued to the finger — and respect the offset from *where they grabbed it*. Snapping to the element's center on grab breaks the illusion immediately. When the user drags something, it must stay glued to the finger — and respect the offset from _where they grabbed it_. Snapping to the element's center on grab breaks the illusion immediately.
- Use Pointer Events with `setPointerCapture` so tracking continues even when the pointer leaves the element's bounds. - Use Pointer Events with `setPointerCapture` so tracking continues even when the pointer leaves the element's bounds.
- Track a short **velocity/position history** (last few `pointermove` events), not just the current point — you'll need velocity at release. - Track a short **velocity/position history** (last few `pointermove` events), not just the current point — you'll need velocity at release.
```js ```js
el.addEventListener('pointerdown', (e) => { el.addEventListener("pointerdown", (e) => {
el.setPointerCapture(e.pointerId); el.setPointerCapture(e.pointerId);
const grabOffset = e.clientY - el.getBoundingClientRect().top; // respect where they grabbed const grabOffset = e.clientY - el.getBoundingClientRect().top; // respect where they grabbed
// ...track position + timestamp history for velocity // ...track position + timestamp history for velocity
@@ -57,9 +57,9 @@ el.addEventListener('pointerdown', (e) => {
Every animation must be interruptible and redirectable at any moment. A user must be able to grab a moving element mid-flight and reverse it without waiting for the animation to finish. A closing modal the user grabs again should follow the finger — not finish closing first, then reopen. Every animation must be interruptible and redirectable at any moment. A user must be able to grab a moving element mid-flight and reverse it without waiting for the animation to finish. A closing modal the user grabs again should follow the finger — not finish closing first, then reopen.
- **Never lock out input during a transition.** - **Never lock out input during a transition.**
- **Always animate from the *presentation* (current) value, never the target value.** On interrupt, read the element's live on-screen transform and start the new animation from there. Starting from the logical/target value causes a visible jump. - **Always animate from the _presentation_ (current) value, never the target value.** On interrupt, read the element's live on-screen transform and start the new animation from there. Starting from the logical/target value causes a visible jump.
- **Avoid CSS transitions and `@keyframes` for anything gesture-driven** — they can't be smoothly grabbed and reversed mid-flight. Springs animate from the current value by default, which is exactly what interruption needs. - **Avoid CSS transitions and `@keyframes` for anything gesture-driven** — they can't be smoothly grabbed and reversed mid-flight. Springs animate from the current value by default, which is exactly what interruption needs.
- **When a gesture reverses, blend velocity — don't hard-cut it.** Replacing one animation with another at a reversal creates a velocity discontinuity, a "brick wall." Spring libraries that carry velocity through a re-target avoid it. (This is what iOS's *additive animations* do natively; on the web, choose a spring library that re-targets from the current velocity.) - **When a gesture reverses, blend velocity — don't hard-cut it.** Replacing one animation with another at a reversal creates a velocity discontinuity, a "brick wall." Spring libraries that carry velocity through a re-target avoid it. (This is what iOS's _additive animations_ do natively; on the web, choose a spring library that re-targets from the current velocity.)
- **Decompose 2D motion into independent X and Y springs.** A single spring on a 2D distance desyncs when X and Y have different velocities. - **Decompose 2D motion into independent X and Y springs.** A single spring on a 2D distance desyncs when X and Y have different velocities.
## 4. Behavior over animation — use springs ## 4. Behavior over animation — use springs
@@ -74,27 +74,28 @@ Apple deliberately replaced the physics triplet (mass/stiffness/damping) with tw
- **Response** — how quickly the value reaches the target, in seconds. Lower = snappier. **This is not "duration"** — a spring has no fixed duration; its settle time emerges from the parameters. - **Response** — how quickly the value reaches the target, in seconds. Lower = snappier. **This is not "duration"** — a spring has no fixed duration; its settle time emerges from the parameters.
**Defaults:** **Defaults:**
- Start most UI at **damping `1.0`** (critically damped) — graceful and non-distracting. - Start most UI at **damping `1.0`** (critically damped) — graceful and non-distracting.
- Add bounce (**damping ~`0.8`**) **only when the gesture itself carried momentum** (a flick, a throw, a drag release). Overshoot on a menu that just faded in feels wrong; overshoot on a card you flicked feels right. - Add bounce (**damping ~`0.8`**) **only when the gesture itself carried momentum** (a flick, a throw, a drag release). Overshoot on a menu that just faded in feels wrong; overshoot on a card you flicked feels right.
**Concrete values Apple ships:** **Concrete values Apple ships:**
| Interaction | Damping | Response | | Interaction | Damping | Response |
| --- | --- | --- | | ---------------------------- | ------- | -------- |
| Move / reposition (e.g. PiP) | `1.0` | `0.4` | | Move / reposition (e.g. PiP) | `1.0` | `0.4` |
| Rotation | `0.8` | `0.4` | | Rotation | `0.8` | `0.4` |
| Drawer / sheet | `0.8` | `0.3` | | Drawer / sheet | `0.8` | `0.3` |
**Web mapping (Motion / Framer Motion):** the `bounce` + `duration` spring API maps closely to Apple's damping + response. A safe house style is `damping: 1.0` springs everywhere by default; reserve bounce for momentum-driven, physical interactions. **Web mapping (Motion / Framer Motion):** the `bounce` + `duration` spring API maps closely to Apple's damping + response. A safe house style is `damping: 1.0` springs everywhere by default; reserve bounce for momentum-driven, physical interactions.
```js ```js
import { animate } from 'motion'; import { animate } from "motion";
// Critically damped default (no overshoot) // Critically damped default (no overshoot)
animate(el, { y: 0 }, { type: 'spring', bounce: 0, duration: 0.4 }); animate(el, { y: 0 }, { type: "spring", bounce: 0, duration: 0.4 });
// Momentum interaction — a little bounce, only because a flick preceded it // Momentum interaction — a little bounce, only because a flick preceded it
animate(el, { y: target }, { type: 'spring', bounce: 0.2, duration: 0.4 }); animate(el, { y: target }, { type: "spring", bounce: 0.2, duration: 0.4 });
``` ```
## 5. Velocity handoff — the seam between drag and animation ## 5. Velocity handoff — the seam between drag and animation
@@ -109,26 +110,26 @@ relativeVelocity = gestureVelocity / (targetValue currentValue)
Example: element at `y=50`, target `y=150` (100px to go), finger moving 50px/s → initial spring velocity = `50 / 100 = 0.5`. Framer Motion / Motion take absolute px/s velocity directly (`velocity` option), so you usually hand it the raw value. Example: element at `y=50`, target `y=150` (100px to go), finger moving 50px/s → initial spring velocity = `50 / 100 = 0.5`. Framer Motion / Motion take absolute px/s velocity directly (`velocity` option), so you usually hand it the raw value.
## 6. Momentum projection — animate to where the gesture is *going* ## 6. Momentum projection — animate to where the gesture is _going_
> "Take a small input and make a big output." > "Take a small input and make a big output."
Don't snap to the nearest boundary from the *release point*. Use velocity to **project the resting position** — exactly like scroll deceleration — then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element. Don't snap to the nearest boundary from the _release point_. Use velocity to **project the resting position** — exactly like scroll deceleration — then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element.
Apple's exact projection function (from the *Designing Fluid Interfaces* sample code): Apple's exact projection function (from the _Designing Fluid Interfaces_ sample code):
```js ```js
// decelerationRate ≈ 0.998 for normal scroll feel; 0.99 for snappier // decelerationRate ≈ 0.998 for normal scroll feel; 0.99 for snappier
function project(initialVelocity /* px/s */, decelerationRate = 0.998) { function project(initialVelocity /* px/s */, decelerationRate = 0.998) {
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate); return ((initialVelocity / 1000) * decelerationRate) / (1 - decelerationRate);
} }
const projectedEndpoint = currentPosition + project(releaseVelocity); const projectedEndpoint = currentPosition + project(releaseVelocity);
const target = nearestSnapPoint(projectedEndpoint); // choose target from the projection const target = nearestSnapPoint(projectedEndpoint); // choose target from the projection
animateSpringTo(target, { velocity: releaseVelocity }); // then hand off velocity (§5) animateSpringTo(target, { velocity: releaseVelocity }); // then hand off velocity (§5)
``` ```
Note: the physics-textbook `v²/(2·decel)` is *not* what Apple ships — use the exponential-decay form above. This is the standard behavior in good bottom-sheets and carousels (Vaul, Embla). Note: the physics-textbook `v²/(2·decel)` is _not_ what Apple ships — use the exponential-decay form above. This is the standard behavior in good bottom-sheets and carousels (Vaul, Embla).
## 7. Spatial consistency — symmetric paths, anchored origins ## 7. Spatial consistency — symmetric paths, anchored origins
@@ -155,14 +156,14 @@ function rubberband(overshoot, dimension, constant = 0.55) {
## 10. Gesture design details (the "feel" checklist) ## 10. Gesture design details (the "feel" checklist)
- **Tap:** highlight on touch-*down* (instant), commit on touch-*up*. Add ~10px of hysteresis/hit padding around the target, and allow cancel-by-dragging-away and back. - **Tap:** highlight on touch-_down_ (instant), commit on touch-_up_. Add ~10px of hysteresis/hit padding around the target, and allow cancel-by-dragging-away and back.
- **Drag/swipe:** require a small movement threshold (hysteresis, ~10px) before committing to a direction, then track 1:1. - **Drag/swipe:** require a small movement threshold (hysteresis, ~10px) before committing to a direction, then track 1:1.
- **Detect all plausible gestures in parallel from the first move**, then confidently cancel the losers once intent is clear. Avoid recognizers that only report a *final* state (`swipeleft`-type events) — they throw away the continuous tracking you need for feedback. - **Detect all plausible gestures in parallel from the first move**, then confidently cancel the losers once intent is clear. Avoid recognizers that only report a _final_ state (`swipeleft`-type events) — they throw away the continuous tracking you need for feedback.
- **Minimize disambiguation delays.** Double-tap detection unavoidably delays single taps; only pay that cost where double-tap truly exists. - **Minimize disambiguation delays.** Double-tap detection unavoidably delays single taps; only pay that cost where double-tap truly exists.
## 11. Frame-level smoothness ## 11. Frame-level smoothness
Smoothness is about *what's in the frames*, not just the frame rate. Smoothness is about _what's in the frames_, not just the frame rate.
- Keep the per-frame positional change below the perception threshold to avoid strobing. - Keep the per-frame positional change below the perception threshold to avoid strobing.
- For very fast motion, a subtle **motion blur / stretch** encodes speed and reads better than a hard sharp streak. - For very fast motion, a subtle **motion blur / stretch** encodes speed and reads better than a hard sharp streak.
@@ -175,7 +176,7 @@ Apple uses translucent materials as a floating functional layer that brings stru
- **Build nav/toolbars/sheets as translucent layers** (`backdrop-filter: blur()` + a semi-transparent background) with content scrolling underneath — not opaque bars that consume a fixed strip. - **Build nav/toolbars/sheets as translucent layers** (`backdrop-filter: blur()` + a semi-transparent background) with content scrolling underneath — not opaque bars that consume a fixed strip.
- **Material weight encodes hierarchy:** darker/heavier materials separate structural regions (sidebars); lighter materials draw attention to interactive elements (buttons). **Never stack a light translucent surface on another** — legibility collapses. - **Material weight encodes hierarchy:** darker/heavier materials separate structural regions (sidebars); lighter materials draw attention to interactive elements (buttons). **Never stack a light translucent surface on another** — legibility collapses.
- **Bigger surfaces should read as thicker:** stronger blur + a deeper shadow than small chips. Consider context-aware shadow — heavier over busy/text content for separation, lighter over plain backgrounds. - **Bigger surfaces should read as thicker:** stronger blur + a deeper shadow than small chips. Consider context-aware shadow — heavier over busy/text content for separation, lighter over plain backgrounds.
- **Dim to focus, separate to keep flow.** A modal task pairs the surface with a dimming scrim and pushes the background back/down. A parallel, non-blocking panel uses translucency and offset *without* a scrim so the flow isn't broken. For stacked sheets, progressively dim and push back each parent layer. - **Dim to focus, separate to keep flow.** A modal task pairs the surface with a dimming scrim and pushes the background back/down. A parallel, non-blocking panel uses translucency and offset _without_ a scrim so the flow isn't broken. For stacked sheets, progressively dim and push back each parent layer.
- **Vibrancy keeps text legible over changing backgrounds.** Over blurred/translucent surfaces, don't use flat gray text — use higher-contrast, slightly heavier weight, and a small letter-spacing bump. Put color on a solid layer, not the translucent foreground. - **Vibrancy keeps text legible over changing backgrounds.** Over blurred/translucent surfaces, don't use flat gray text — use higher-contrast, slightly heavier weight, and a small letter-spacing bump. Put color on a solid layer, not the translucent foreground.
- **Scroll edge effects, not hard dividers.** Instead of a 1px border under a sticky header, fade a small blur/gradient mask where content meets floating chrome — only where floating UI actually overlaps content. - **Scroll edge effects, not hard dividers.** Instead of a 1px border under a sticky header, fade a small blur/gradient mask where content meets floating chrome — only where floating UI actually overlaps content.
- **Materialize, don't just fade.** For glass/blur surfaces, animate blur radius and scale together on enter/exit, so the surface reads as a real material arriving rather than a plain opacity fade. - **Materialize, don't just fade.** For glass/blur surfaces, animate blur radius and scale together on enter/exit, so the surface reads as a real material arriving rather than a plain opacity fade.
@@ -190,7 +191,7 @@ Apple uses translucent materials as a floating functional layer that brings stru
## 13. Multimodal feedback — motion + sound + haptics ## 13. Multimodal feedback — motion + sound + haptics
Three rules for combining senses (from *Designing Audio-Haptic Experiences*): Three rules for combining senses (from _Designing Audio-Haptic Experiences_):
1. **Causality** — it must be obvious what caused the feedback. Trigger it on the actual causal event (the toggle flipping, the item snapping home), and match its character to the action's physicality. 1. **Causality** — it must be obvious what caused the feedback. Trigger it on the actual causal event (the toggle flipping, the item snapping home), and match its character to the action's physicality.
2. **Harmony** — the visual, the sound, and the haptic must fire on the **same frame**. Latency between them destroys the illusion. Don't let a CSS transition lag the audio/haptic (Vibration API). 2. **Harmony** — the visual, the sound, and the haptic must fire on the **same frame**. Latency between them destroys the illusion. Don't let a CSS transition lag the audio/haptic (Vibration API).
@@ -198,7 +199,7 @@ Three rules for combining senses (from *Designing Audio-Haptic Experiences*):
## 14. Reduced motion & accessibility ## 14. Reduced motion & accessibility
Reduced motion doesn't mean *no* feedback — it means a gentler, non-vestibular equivalent. Respond to three independent signals and bake them into your components: Reduced motion doesn't mean _no_ feedback — it means a gentler, non-vestibular equivalent. Respond to three independent signals and bake them into your components:
- **`prefers-reduced-motion: reduce`** — replace slides/springs/parallax with short opacity **cross-fades or static transitions**. Drop elastic/overshoot. Keep opacity/color changes that aid comprehension. - **`prefers-reduced-motion: reduce`** — replace slides/springs/parallax with short opacity **cross-fades or static transitions**. Drop elastic/overshoot. Keep opacity/color changes that aid comprehension.
- **`prefers-reduced-transparency: reduce`** — make translucent surfaces frostier/solid: raise background opacity, drop the blur. - **`prefers-reduced-transparency: reduce`** — make translucent surfaces frostier/solid: raise background opacity, drop the blur.
@@ -208,44 +209,54 @@ Also: avoid full-viewport moving backgrounds, slow looping oscillations (near 0.
```css ```css
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.sheet { transition: opacity 200ms ease; transform: none !important; } .sheet {
transition: opacity 200ms ease;
transform: none !important;
}
} }
@media (prefers-reduced-transparency: reduce) { @media (prefers-reduced-transparency: reduce) {
.toolbar { background: white; backdrop-filter: none; } .toolbar {
background: white;
backdrop-filter: none;
}
} }
``` ```
## 15. Typography — optical sizing, tracking, leading ## 15. Typography — optical sizing, tracking, leading
Apple designs type to change shape with size; the same discipline applies on the web. (From *The Details of UI Typography*, WWDC 2020.) Apple designs type to change shape with size; the same discipline applies on the web. (From _The Details of UI Typography_, WWDC 2020.)
- **Tracking (letter-spacing) is size-specific — never one value for all sizes.** Large display text wants *negative* tracking (letters read too far apart as they grow); small text wants slightly *positive* tracking for legibility. A fixed `letter-spacing` is wrong somewhere. Tighten headings, leave body near `0`. - **Tracking (letter-spacing) is size-specific — never one value for all sizes.** Large display text wants _negative_ tracking (letters read too far apart as they grow); small text wants slightly _positive_ tracking for legibility. A fixed `letter-spacing` is wrong somewhere. Tighten headings, leave body near `0`.
- **Leading (line-height) tracks size inversely.** Tight on large headings, looser on body copy. Increase it for scripts with tall ascenders/descenders; tighten it for dense, information-heavy UI. - **Leading (line-height) tracks size inversely.** Tight on large headings, looser on body copy. Increase it for scripts with tall ascenders/descenders; tighten it for dense, information-heavy UI.
- **Build hierarchy from weight + size + leading as a set,** not size alone. Emphasize with weight — it adds presence without taking more space. - **Build hierarchy from weight + size + leading as a set,** not size alone. Emphasize with weight — it adds presence without taking more space.
- **Respect the user's text-size setting** (Dynamic Type). Scale layout *with* the text — spacing in `rem`/`em`, not fixed px — so a larger font doesn't break the layout. - **Respect the user's text-size setting** (Dynamic Type). Scale layout _with_ the text — spacing in `rem`/`em`, not fixed px — so a larger font doesn't break the layout.
- **Default to the platform's system font** before a custom face; it already ships optical sizing, tracking tables, and legibility tuning. Override only with a reason. - **Default to the platform's system font** before a custom face; it already ships optical sizing, tracking tables, and legibility tuning. Override only with a reason.
```css ```css
:root { font: 100%/1.5 system-ui, sans-serif; } /* body: system font, comfortable leading */ :root {
font:
100%/1.5 system-ui,
sans-serif;
} /* body: system font, comfortable leading */
.display { .display {
font-size: clamp(2rem, 5vw, 4rem); font-size: clamp(2rem, 5vw, 4rem);
line-height: 1.05; /* tight leading for large text */ line-height: 1.05; /* tight leading for large text */
letter-spacing: -0.02em; /* negative tracking as it grows */ letter-spacing: -0.02em; /* negative tracking as it grows */
font-optical-sizing: auto; font-optical-sizing: auto;
} }
``` ```
## 16. Design foundations — the eight principles ## 16. Design foundations — the eight principles
The motion and craft above serve Apple's eight design principles (*Principles of Great Design*, WWDC 2026). Use these as the names you reason with: The motion and craft above serve Apple's eight design principles (_Principles of Great Design_, WWDC 2026). Use these as the names you reason with:
1. **Purpose.** Make with intention; decide what *not* to build. Every feature asks for the user's time, attention, and trust — spend that budget only where it pays off. 1. **Purpose.** Make with intention; decide what _not_ to build. Every feature asks for the user's time, attention, and trust — spend that budget only where it pays off.
2. **Agency.** Keep people in control: offer choices, don't force a single path. Back it with forgiveness — easy undo for slips, a confirmation dialog only for genuinely destructive, irreversible actions (use sparingly; overusing it trains people to click through). 2. **Agency.** Keep people in control: offer choices, don't force a single path. Back it with forgiveness — easy undo for slips, a confirmation dialog only for genuinely destructive, irreversible actions (use sparingly; overusing it trains people to click through).
3. **Responsibility.** Act in the user's interest. Privacy: ask at the right moment, only for what's needed, transparently. Safety: anticipate misuse and harm — especially with AI (an allergy-aware recipe app must not suggest a harmful ingredient). Add previews, confirmations, disclaimers; cut a feature whose risk outweighs its value. 3. **Responsibility.** Act in the user's interest. Privacy: ask at the right moment, only for what's needed, transparently. Safety: anticipate misuse and harm — especially with AI (an allergy-aware recipe app must not suggest a harmful ingredient). Add previews, confirmations, disclaimers; cut a feature whose risk outweighs its value.
4. **Familiarity.** Build on what people already know. Use metaphors that are neither too literal nor too abstract (a trash can means delete), and honor their physics. Be consistent: things that look the same must behave the same and live in the same place (close is always top-left on macOS) so people can predict what happens next. Only break a familiar pattern if you can prove it's better — then test it, don't assume. 4. **Familiarity.** Build on what people already know. Use metaphors that are neither too literal nor too abstract (a trash can means delete), and honor their physics. Be consistent: things that look the same must behave the same and live in the same place (close is always top-left on macOS) so people can predict what happens next. Only break a familiar pattern if you can prove it's better — then test it, don't assume.
5. **Flexibility.** Design for different contexts, devices, and the full range of abilities. Adapt to the platform (iPhone = quick touch; desktop = deep workflows with precise pointer control) and to the situation. Design inclusively (age, language, expertise, accessibility). When no single layout fits everyone, let people personalize — rearrange controls, hide what they don't use. 5. **Flexibility.** Design for different contexts, devices, and the full range of abilities. Adapt to the platform (iPhone = quick touch; desktop = deep workflows with precise pointer control) and to the situation. Design inclusively (age, language, expertise, accessibility). When no single layout fits everyone, let people personalize — rearrange controls, hide what they don't use.
6. **Simplicity — not minimalism.** Strip the unnecessary so the core purpose shines; burying everything in one place looks minimal but isn't simple. Be concise (plain language, no jargon, fewer steps) and clear (use hierarchy — order, spacing, contrast — so the most important thing is the most obvious). Every element earns its place; sometimes *adding* context simplifies (a video scrubber that shows time remaining). Show the common path first, advanced options one level deeper. 6. **Simplicity — not minimalism.** Strip the unnecessary so the core purpose shines; burying everything in one place looks minimal but isn't simple. Be concise (plain language, no jargon, fewer steps) and clear (use hierarchy — order, spacing, contrast — so the most important thing is the most obvious). Every element earns its place; sometimes _adding_ context simplifies (a video scrubber that shows time remaining). Show the common path first, advanced options one level deeper.
7. **Craft.** Uncompromising attention to detail builds trust. Beautiful typography, colors that adapt to light/dark, clear iconography, and responsive animations that give immediate, natural feedback. Nothing is random — every spacing, timing, and alignment value is a deliberate choice you can defend. Jittery scroll, misaligned icons, and layouts that break on rotation read as carelessness. Craft needs iteration and longevity — keep evolving the design as features and hardware change. 7. **Craft.** Uncompromising attention to detail builds trust. Beautiful typography, colors that adapt to light/dark, clear iconography, and responsive animations that give immediate, natural feedback. Nothing is random — every spacing, timing, and alignment value is a deliberate choice you can defend. Jittery scroll, misaligned icons, and layouts that break on rotation read as carelessness. Craft needs iteration and longevity — keep evolving the design as features and hardware change.
8. **Delight.** The result of getting the other seven right, not confetti tacked on top. Decide the emotion you want people to feel (calm, confident, excited) and reinforce it in every decision. 8. **Delight.** The result of getting the other seven right, not confetti tacked on top. Decide the emotion you want people to feel (calm, confident, excited) and reinforce it in every decision.
@@ -264,19 +275,19 @@ Tactical rules that serve these:
## Quick Reference ## Quick Reference
| Need | Technique | Concrete value | | Need | Technique | Concrete value |
| --- | --- | --- | | --------------------------- | ------------------------------------ | ---------------------------------------------------- |
| Default UI spring | Critically damped, no overshoot | `damping 1.0`, `response 0.30.4` | | Default UI spring | Critically damped, no overshoot | `damping 1.0`, `response 0.30.4` |
| Momentum / flick spring | Under-damped, slight bounce | `damping ~0.8`, `response 0.30.4` | | Momentum / flick spring | Under-damped, slight bounce | `damping ~0.8`, `response 0.30.4` |
| Gesture → spring velocity | Hand off release velocity | `gestureVelocity / (target current)` if normalized | | Gesture → spring velocity | Hand off release velocity | `gestureVelocity / (target current)` if normalized |
| Flick landing point | Project momentum | `current + (v/1000)·d/(1d)`, `d ≈ 0.998` | | Flick landing point | Project momentum | `current + (v/1000)·d/(1d)`, `d ≈ 0.998` |
| Interrupt cleanly | Start from presentation (live) value | read the on-screen transform | | Interrupt cleanly | Start from presentation (live) value | read the on-screen transform |
| Avoid reversal "brick wall" | Carry velocity through re-target | spring that blends velocity | | Avoid reversal "brick wall" | Carry velocity through re-target | spring that blends velocity |
| Reversible transition | Mirror the easing curve | inverse cubic-bézier | | Reversible transition | Mirror the easing curve | inverse cubic-bézier |
| Decide reverse vs. commit | Use velocity **sign**, not position | at release | | Decide reverse vs. commit | Use velocity **sign**, not position | at release |
| 1:1 drag | Pointer Events + capture | respect the grab offset | | 1:1 drag | Pointer Events + capture | respect the grab offset |
| Feedback | On pointer-down, continuous | never only at the end | | Feedback | On pointer-down, continuous | never only at the end |
| Boundary | Rubber-band, don't hard-stop | progressive resistance | | Boundary | Rubber-band, don't hard-stop | progressive resistance |
| Translucent chrome | `backdrop-filter` layer | content scrolls under | | Translucent chrome | `backdrop-filter` layer | content scrolls under |
| Type tracking | Size-specific, never fixed | tighten large text (`-0.02em`), body near `0` | | Type tracking | Size-specific, never fixed | tighten large text (`-0.02em`), body near `0` |
| Reduced motion | Cross-fade, not slide/spring | `@media (prefers-reduced-motion)` | | Reduced motion | Cross-fade, not slide/spring | `@media (prefers-reduced-motion)` |
+9 -3
View File
@@ -15,9 +15,15 @@ export function BottomNav() {
aria-label="Navigazione principale" aria-label="Navigazione principale"
// `materiale` + `bordo-sfumato`: la barra è un vetro sotto cui il // `materiale` + `bordo-sfumato`: la barra è un vetro sotto cui il
// contenuto scorre, con una sfumatura al posto della riga netta. // contenuto scorre, con una sfumatura al posto della riga netta.
className="materiale bordo-sfumato fixed inset-x-0 bottom-0 z-40" className="materiale bordo-sfumato pad-sicura-fondo fixed inset-x-0 bottom-0 z-40"
> >
<div className="mx-auto grid max-w-md grid-cols-5 px-1 pb-[env(safe-area-inset-bottom)]"> {/*
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="mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-5 px-1">
{items.map(({ to, label, icon: Icon }) => ( {items.map(({ to, label, icon: Icon }) => (
<Link <Link
key={to} key={to}
@@ -26,7 +32,7 @@ export function BottomNav() {
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: cambia anche il peso del
// testo e compare la barretta sopra l'icona. // testo e compare la barretta sopra l'icona.
className="group relative flex min-h-11 flex-col items-center gap-1 py-2.5 text-xs font-semibold text-muted-foreground transition-colors data-[status=active]:font-extrabold data-[status=active]:text-accent" 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"
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
+1 -1
View File
@@ -32,7 +32,7 @@ export function CelebrazioneBadge() {
if (!notifica) return null; if (!notifica) return null;
return ( return (
<div className="anim-reveal fixed inset-0 z-50 grid place-items-end bg-foreground/40 p-4 pb-28 backdrop-blur-sm"> <div className="anim-reveal spazio-nav fixed inset-0 z-50 grid place-items-end bg-foreground/40 px-4 pt-4 backdrop-blur-sm">
<div <div
className={cn( className={cn(
"anim-pop w-full max-w-md rounded-3xl bg-card p-5 shadow-pop ring-2", "anim-pop w-full max-w-md rounded-3xl bg-card p-5 shadow-pop ring-2",
+4 -4
View File
@@ -22,7 +22,7 @@ import { useSessione } from "../lib/auth";
function NotFoundComponent() { function NotFoundComponent() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background px-4"> <div className="flex min-h-dvh items-center justify-center bg-background px-4">
<div className="max-w-md text-center"> <div className="max-w-md text-center">
<h1 className="font-display-lg text-7xl text-foreground">404</h1> <h1 className="font-display-lg text-7xl text-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold text-foreground">Pagina non trovata</h2> <h2 className="mt-4 text-xl font-semibold text-foreground">Pagina non trovata</h2>
@@ -50,7 +50,7 @@ function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
}, [error]); }, [error]);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background px-4"> <div className="flex min-h-dvh items-center justify-center bg-background px-4">
<div className="max-w-md text-center"> <div className="max-w-md text-center">
<h1 className="text-xl font-semibold tracking-tight text-foreground"> <h1 className="text-xl font-semibold tracking-tight text-foreground">
Questa pagina non si è caricata Questa pagina non si è caricata
@@ -187,7 +187,7 @@ function AppShell() {
if (!mounted || !pronta) { if (!mounted || !pronta) {
return ( return (
<div className="grid min-h-screen place-items-center bg-background"> <div className="grid min-h-dvh place-items-center bg-background">
<TeamLogo className="h-16 w-16 animate-pulse" /> <TeamLogo className="h-16 w-16 animate-pulse" />
</div> </div>
); );
@@ -195,7 +195,7 @@ function AppShell() {
return ( return (
<> <>
<div className="mx-auto min-h-screen max-w-md bg-background pb-24"> <div className="spazio-nav mx-auto min-h-dvh max-w-md bg-background">
{/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */} {/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
<Outlet /> <Outlet />
</div> </div>
+1 -1
View File
@@ -92,7 +92,7 @@ function Benvenuto() {
const inAttesaCollegamento = !!utenteId && !mioSlot && !erroreCollegamento; const inAttesaCollegamento = !!utenteId && !mioSlot && !erroreCollegamento;
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 py-12"> <div className="flex min-h-dvh flex-col items-center justify-center px-6 py-12">
<TeamLogo className="h-20 w-20" /> <TeamLogo className="h-20 w-20" />
<h1 className="mt-6 text-center font-display text-4xl uppercase leading-none"> <h1 className="mt-6 text-center font-display text-4xl uppercase leading-none">
Benvenuto in CrAPP Benvenuto in CrAPP
+34
View File
@@ -62,6 +62,19 @@
:root { :root {
color-scheme: light; color-scheme: light;
--radius: 1rem; --radius: 1rem;
/*
* Geometria della barra di navigazione, unica sorgente di verità: prima
* l'altezza era ricopiata a occhio come `pb-24` nel contenuto e `pb-28`
* nella celebrazione badge, e su iPhone i conti tornavano per 2 px.
*
* `--pad-sicura-fondo` è il motivo per cui la barra su iPhone e su Android
* non può essere alta uguale: `env(safe-area-inset-bottom)` vale ~34 px
* sopra la home indicator e 0 su Android, dove la barra di sistema sta
* fuori dal viewport. Il `max()` dà comunque ad Android un respiro minimo,
* così la differenza è un margine in più, non un allineamento diverso.
*/
--altezza-nav: 3.75rem;
--pad-sicura-fondo: max(env(safe-area-inset-bottom), 0.5rem);
--background: oklch(0.985 0.002 240); --background: oklch(0.985 0.002 240);
--foreground: oklch(0.16 0.01 260); --foreground: oklch(0.16 0.01 260);
--card: oklch(1 0 0); --card: oklch(1 0 0);
@@ -351,6 +364,27 @@
-webkit-backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);
} }
/*
* Chrome su Android disattiva `backdrop-filter` quando l'accelerazione
* hardware non è disponibile: senza questo, su quei device la barra resta
* solo semitrasparente e il contenuto si legge attraverso.
*/
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.materiale {
background-color: var(--color-card);
}
}
/** Padding inferiore della chrome flottante. */
@utility pad-sicura-fondo {
padding-bottom: var(--pad-sicura-fondo);
}
/** Spazio che il contenuto deve lasciare sotto di sé per non finire dietro la barra. */
@utility spazio-nav {
padding-bottom: calc(var(--altezza-nav) + var(--pad-sicura-fondo) + 1rem);
}
@utility bordo-sfumato { @utility bordo-sfumato {
&::before { &::before {
content: ""; content: "";