# Due pannelli: piano di lavoro

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** sugli schermi larghi almeno 700 punti (tablet verticale, iPhone Duo aperto) le categorie e le ricette stanno a sinistra e la ricetta aperta a destra; sul telefono non cambia niente.

**Architecture:** le decisioni stanno in un modulo puro nuovo, `src/ui/logica-pannelli.ts`, provato con `node --test`. La navigazione ha una pila principale con una pagina «Principale», che contiene una pila «sfoglia» (Categorie, Elenco) e, sugli schermi larghi, il pannello della ricetta accanto. Il contenuto del Dosatore diventa il componente `SchedaRicetta`, usato sia dalla pagina del telefono sia dal pannello.

**Tech Stack:** Expo SDK 57, React Native 0.86, React Navigation 7 (native-stack), expo-sqlite, TypeScript, test con `node --test`.

Progetto: `docs/superpowers/specs/2026-09-24-due-pannelli-design.md`.

## Global Constraints

- Soglia dei due pannelli: **700 punti** di larghezza della finestra, compresi.
- Pannello sfoglia: **40%** della larghezza, tenuto fra **320 e 420** punti.
- Colonne delle griglie: `floor(larghezza / 180)`, **almeno 2, al massimo 4**; le righe restano una colonna.
- Chiave dell'ultima ricetta aperta nella tabella `impostazioni`: `ricetta.ultima`.
- Testo dell'invito: it «Scegli una ricetta», en «Pick a recipe» (chiave `pannelli.invito`).
- Nessuna decisione dentro i `.tsx`: tutto quello che si può sbagliare sta in `logica-pannelli.ts` (i test non caricano i componenti).
- Commenti e nomi in italiano, nello stile dei file vicini. Niente trattini lunghi nei testi per l'utente.
- Dopo ogni task: `npm test` verde e `npx tsc --noEmit` pulito. Si lavora in `/Users/paolo/dev/quantobasta`.
- `supportsTablet` resta `false`; il blocco verticale (`plugins/with-android-solo-verticale.js`) resta.

---

### Task 1: le decisioni dei pannelli, in un modulo puro

**Files:**
- Create: `src/ui/logica-pannelli.ts`
- Create: `src/ui/logica-pannelli.test.ts`
- Modify: `src/ui/logica-viste.ts` (la funzione `colonne`)
- Modify: `src/ui/logica-viste.test.ts` (il test «la griglia ha due colonne»)
- Modify: `src/ui/schermate/Categorie.tsx:403` e `src/ui/schermate/Elenco.tsx:309` (le chiamate a `colonne`)

**Interfaces:**
- Produces:
  - `SOGLIA_DUE_PANNELLI = 700`, `CHIAVE_RICETTA_ULTIMA = 'ricetta.ultima'`
  - `duePannelli(larghezza: number): boolean`
  - `larghezzaSfoglia(larghezza: number): number` (sul telefono è la larghezza intera)
  - `colonneGriglia(larghezza: number): number`
  - `ricettaIniziale(salvata: string | null, esiste: boolean): string | null`
  - `interface Rotta { name: string; params?: { ricettaId?: string } & Record<string, unknown>; key?: string }`
  - `interface Disposizione { rotte: Rotta[]; selezionata: string | null }`
  - `dopoCambioLarghezza(prima: boolean, dopo: boolean, d: Disposizione): Disposizione`
  - `creaMemoriaScorrimento(): { ricorda(id: string, y: number): void; posizione(id: string): number; dimentica(id: string): void }`
  - in `logica-viste.ts`: `colonne(vista: Vista, larghezza: number): number`

- [ ] **Step 1: scrivere i test**

`src/ui/logica-pannelli.test.ts`:

```ts
/**
 * Test dei due pannelli. Gira con `npm test`: il modulo non importa React.
 */
import { test } from 'node:test';
import assert from 'node:assert/strict';

import {
  colonneGriglia,
  creaMemoriaScorrimento,
  dopoCambioLarghezza,
  duePannelli,
  larghezzaSfoglia,
  ricettaIniziale,
} from './logica-pannelli.ts';

test('i due pannelli partono da 700 punti, compresi', () => {
  assert.equal(duePannelli(699), false);
  assert.equal(duePannelli(700), true);
  assert.equal(duePannelli(390), false);
  assert.equal(duePannelli(800), true);
});

test('lo sfoglia prende il 40%, fra 320 e 420; sul telefono tutto', () => {
  assert.equal(larghezzaSfoglia(390), 390);
  assert.equal(larghezzaSfoglia(699), 699);
  assert.equal(larghezzaSfoglia(700), 320); // 280 sarebbe troppo stretto
  assert.equal(larghezzaSfoglia(900), 360);
  assert.equal(larghezzaSfoglia(1280), 420); // 512 sarebbe troppo largo
});

test('le colonne della griglia: una ogni 180, fra 2 e 4', () => {
  assert.equal(colonneGriglia(320), 2);
  assert.equal(colonneGriglia(359), 2);
  assert.equal(colonneGriglia(360), 2);
  assert.equal(colonneGriglia(540), 3);
  assert.equal(colonneGriglia(720), 4);
  assert.equal(colonneGriglia(2000), 4);
});

test("la ricetta iniziale è quella salvata, se c'è ancora", () => {
  assert.equal(ricettaIniziale('r1', true), 'r1');
  assert.equal(ricettaIniziale('r1', false), null); // cancellata: l'invito
  assert.equal(ricettaIniziale(null, false), null); // mai aperta
  assert.equal(ricettaIniziale('', true), null);
});

const P = { name: 'Principale' };

test('stessa disposizione se la larghezza non cambia modo', () => {
  const d = { rotte: [P, { name: 'Dosatore', params: { ricettaId: 'r1' } }], selezionata: null };
  assert.deepEqual(dopoCambioLarghezza(false, false, d), d);
  assert.deepEqual(dopoCambioLarghezza(true, true, d), d);
});

test("aprendo il Duo, la ricetta in primo piano passa nel pannello", () => {
  const d = { rotte: [P, { name: 'Dosatore', params: { ricettaId: 'r1' } }], selezionata: 'r0' };
  assert.deepEqual(dopoCambioLarghezza(false, true, d), { rotte: [P], selezionata: 'r1' });
});

test('aprendo il Duo con la modifica sopra la ricetta, la modifica resta', () => {
  const mod = { name: 'Modifica', params: { ricettaId: 'r1' } };
  const d = { rotte: [P, { name: 'Dosatore', params: { ricettaId: 'r1' } }, mod], selezionata: null };
  assert.deepEqual(dopoCambioLarghezza(false, true, d), { rotte: [P, mod], selezionata: 'r1' });
});

test('aprendo il Duo senza ricetta in primo piano, resta quella scelta prima', () => {
  const d = { rotte: [P], selezionata: 'r0' };
  assert.deepEqual(dopoCambioLarghezza(false, true, d), d);
});

test('chiudendo il Duo con una ricetta nel pannello, la ricetta va a tutto schermo', () => {
  const d = { rotte: [P], selezionata: 'r1' };
  assert.deepEqual(dopoCambioLarghezza(true, false, d), {
    rotte: [P, { name: 'Dosatore', params: { ricettaId: 'r1' } }],
    selezionata: 'r1',
  });
});

test("chiudendo il Duo con Impostazioni aperte, la ricetta va sotto e Impostazioni resta sopra", () => {
  const imp = { name: 'Impostazioni' };
  const d = { rotte: [P, imp], selezionata: 'r1' };
  assert.deepEqual(dopoCambioLarghezza(true, false, d), {
    rotte: [P, { name: 'Dosatore', params: { ricettaId: 'r1' } }, imp],
    selezionata: 'r1',
  });
});

test('chiudendo il Duo senza ricetta nel pannello, non cambia niente', () => {
  const d = { rotte: [P], selezionata: null };
  assert.deepEqual(dopoCambioLarghezza(true, false, d), d);
});

test('la memoria dello scorrimento ricorda per ricetta, e dimentica a richiesta', () => {
  const m = creaMemoriaScorrimento();
  assert.equal(m.posizione('r1'), 0);
  m.ricorda('r1', 420);
  m.ricorda('r2', 80);
  assert.equal(m.posizione('r1'), 420);
  m.dimentica('r1');
  assert.equal(m.posizione('r1'), 0);
  assert.equal(m.posizione('r2'), 80);
});
```

In `src/ui/logica-viste.test.ts` il test esistente diventa:

```ts
test('le righe hanno una colonna, la griglia quante ne stanno', () => {
  assert.equal(colonne('righe', 800), 1);
  assert.equal(colonne('griglia', 390), 2);
  assert.equal(colonne('griglia', 800), 4);
});
```

- [ ] **Step 2: farli fallire**

Run: `npm test 2>&1 | tail -5`
Expected: FAIL, `Cannot find module './logica-pannelli.ts'` e il test di `colonne`.

- [ ] **Step 3: scrivere il modulo**

`src/ui/logica-pannelli.ts`:

```ts
/**
 * I due pannelli: sugli schermi larghi (tablet in verticale, iPhone Duo
 * aperto) le categorie e le ricette stanno a sinistra, la ricetta aperta a
 * destra. Sul telefono tutto come prima.
 *
 * Qui ci sono le decisioni, fuori dai .tsx come le altre logica-*: la soglia,
 * le larghezze, le colonne, quale ricetta mostrare all'apertura, e cosa fare
 * quando la larghezza cambia modo sotto le dita (il Duo che si piega o si
 * apre). Progetto: docs/superpowers/specs/2026-09-24-due-pannelli-design.md.
 */

/** Da quanti punti di larghezza in su lo schermo si divide in due. */
export const SOGLIA_DUE_PANNELLI = 700;

/** L'ultima ricetta aperta, nella tabella `impostazioni`. */
export const CHIAVE_RICETTA_ULTIMA = 'ricetta.ultima';

const QUOTA_SFOGLIA = 0.4;
const SFOGLIA_MIN = 320;
const SFOGLIA_MAX = 420;
const LARGHEZZA_TESSERA = 180;
const COLONNE_MIN = 2;
const COLONNE_MAX = 4;

export function duePannelli(larghezza: number): boolean {
  return larghezza >= SOGLIA_DUE_PANNELLI;
}

/**
 * Quanto è largo il pannello delle categorie e delle ricette. Quanto un
 * telefono: sotto i 320 le tessere non ci stanno in due, sopra i 420 si
 * ruba spazio alla ricetta. Sul telefono è tutto lo schermo.
 */
export function larghezzaSfoglia(larghezza: number): number {
  if (!duePannelli(larghezza)) return larghezza;
  return Math.round(Math.min(SFOGLIA_MAX, Math.max(SFOGLIA_MIN, larghezza * QUOTA_SFOGLIA)));
}

/**
 * Quante tessere per riga in una griglia larga così. Una ogni 180 punti, ma
 * mai meno di due (è la griglia del telefono) e mai più di quattro (oltre,
 * una tessera diventa un francobollo).
 */
export function colonneGriglia(larghezza: number): number {
  return Math.min(COLONNE_MAX, Math.max(COLONNE_MIN, Math.floor(larghezza / LARGHEZZA_TESSERA)));
}

/**
 * La ricetta da mostrare a destra all'apertura: l'ultima aperta, se c'è
 * ancora. Se è stata cancellata, o non ce n'è mai stata una, nessuna: e
 * allora il pannello mostra l'invito.
 */
export function ricettaIniziale(salvata: string | null, esiste: boolean): string | null {
  return salvata !== null && salvata !== '' && esiste ? salvata : null;
}

/** Una pagina della pila principale, come la descrive React Navigation. */
export interface Rotta {
  name: string;
  params?: { ricettaId?: string } & Record<string, unknown>;
  key?: string;
}

/** La pila principale più la ricetta del pannello di destra. */
export interface Disposizione {
  rotte: Rotta[];
  selezionata: string | null;
}

/**
 * Cosa diventa la disposizione quando la larghezza passa da un modo
 * all'altro. È la regola di Apple per il Duo: aprendo o chiudendo, l'utente
 * ritrova quello che stava facendo.
 *
 * - Da uno a due pannelli: la ricetta in primo piano (l'ultima pagina
 *   Dosatore della pila) passa nel pannello, e la sua pagina sparisce.
 *   Quello che le stava sopra (una modifica, le impostazioni) resta sopra.
 * - Da due a uno: la ricetta del pannello diventa una pagina, subito sopra
 *   Principale, così la freccia indietro riporta all'elenco da cui si
 *   veniva; quello che era aperto sopra resta sopra.
 *
 * La prima rotta è sempre Principale.
 */
export function dopoCambioLarghezza(prima: boolean, dopo: boolean, d: Disposizione): Disposizione {
  if (prima === dopo) return d;

  if (dopo) {
    const ricette = d.rotte.filter((r) => r.name === 'Dosatore');
    const inPrimoPiano = ricette.at(-1)?.params?.ricettaId;
    if (inPrimoPiano === undefined) return d;
    return { rotte: d.rotte.filter((r) => r.name !== 'Dosatore'), selezionata: inPrimoPiano };
  }

  if (d.selezionata === null) return d;
  const [principale, ...sopra] = d.rotte;
  return {
    rotte: [principale, { name: 'Dosatore', params: { ricettaId: d.selezionata } }, ...sopra],
    selezionata: d.selezionata,
  };
}

/**
 * Dove si era arrivati scorrendo ogni ricetta, per tornarci quando la scheda
 * si ridisegna dopo una piega. Vive in memoria e basta: al riavvio dell'app
 * si riparte dall'alto, come sempre.
 */
export function creaMemoriaScorrimento() {
  const posizioni = new Map<string, number>();
  return {
    ricorda(id: string, y: number) {
      posizioni.set(id, y);
    },
    posizione(id: string): number {
      return posizioni.get(id) ?? 0;
    },
    dimentica(id: string) {
      posizioni.delete(id);
    },
  };
}
```

In `src/ui/logica-viste.ts` la funzione `colonne` diventa (e in cima si importa `colonneGriglia`):

```ts
import { colonneGriglia } from './logica-pannelli.ts';

/**
 * Quante colonne disegnare in una lista larga così. Le righe una; la griglia
 * quante tessere ci stanno, e lo decide colonneGriglia: due sul telefono, di
 * più dove l'elenco ha spazio.
 */
export function colonne(vista: Vista, larghezza: number): number {
  return vista === 'griglia' ? colonneGriglia(larghezza) : 1;
}
```

In `Categorie.tsx` ed `Elenco.tsx`, per ora, la larghezza è quella della finestra (il Task 3 la sostituisce con quella del pannello):

```ts
import { useWindowDimensions } from 'react-native'; // aggiungere all'import esistente di react-native
// dentro il componente, accanto agli altri hook:
const { width: larghezza } = useWindowDimensions();
// e la FlatList:
key={`${vista}-${colonne(vista, larghezza)}`}
numColumns={colonne(vista, larghezza)}
```

La `key` include il numero di colonne perché `numColumns` non si può cambiare su una FlatList già montata (il commento sopra la `key` lo spiega già: aggiungere «, né il numero di colonne»).

- [ ] **Step 4: test verdi**

Run: `npm test 2>&1 | tail -8 && npx tsc --noEmit && echo tsc-ok`
Expected: tutti pass, `tsc-ok`.

- [ ] **Step 5: commit**

```bash
git add src/ui/logica-pannelli.ts src/ui/logica-pannelli.test.ts src/ui/logica-viste.ts src/ui/logica-viste.test.ts src/ui/schermate/Categorie.tsx src/ui/schermate/Elenco.tsx
git commit -m "Le decisioni dei due pannelli, e le colonne della griglia secondo la larghezza"
```

---

### Task 2: il Dosatore diventa una scheda riusabile

Nessun cambiamento visibile: la pagina del telefono fa esattamente quello che faceva. Si separa il contenuto (`SchedaRicetta`) dalla pagina (`Dosatore`), e la scheda impara a tornare al punto di scorrimento.

**Files:**
- Create: `src/ui/componenti/SchedaRicetta.tsx`
- Modify: `src/ui/schermate/Dosatore.tsx` (diventa una pagina di poche righe)

**Interfaces:**
- Consumes: `creaMemoriaScorrimento` (Task 1).
- Produces:
  - `export interface PropsScheda { ricettaId: string; onIndietro: (() => void) | null; onSparita: () => void }`
  - `export default function SchedaRicetta(props: PropsScheda)`
  - `export const memoriaScorrimento` (un'istanza sola per tutta l'app, esportata da `SchedaRicetta.tsx`)

- [ ] **Step 1: spostare il contenuto**

Creare `src/ui/componenti/SchedaRicetta.tsx` spostandoci **tutto** il contenuto attuale di `Dosatore.tsx`: il commento d'intestazione (aggiungendo in fondo il paragrafo qui sotto), gli import, `ALTEZZA_COPERTINA`, il componente e `creaStili`. Poi, dentro, cambiano solo queste cose:

1. La firma:

```ts
export interface PropsScheda {
  ricettaId: string;
  /** La freccia in alto a sinistra. null nel pannello: lì non c'è un indietro. */
  onIndietro: (() => void) | null;
  /** La ricetta non c'è più (cancellata altrove): chi mostra la scheda decide dove andare. */
  onSparita: () => void;
}

/** Il punto di ogni ricetta a cui si era arrivati scorrendo: vedi creaMemoriaScorrimento. */
export const memoriaScorrimento = creaMemoriaScorrimento();

export default function SchedaRicetta({ ricettaId, onIndietro, onSparita }: PropsScheda) {
  const navigation = useNavigation<NativeStackNavigationProp<ParametriNav>>();
```

   (via `const { ricettaId } = route.params;`; import aggiunti: `useNavigation` da `@react-navigation/native`, `type NativeStackNavigationProp` da `@react-navigation/native-stack`, `type ParametriNav` da `../navigazione.ts`, `creaMemoriaScorrimento` da `../logica-pannelli.ts`; via l'import di `PropsSchermata`; gli import relativi `../` diventano `../` o `./` secondo la nuova cartella: `'../contesto.ts'`, `'../../domain/...'`, `'../../data/...'`, `'../../io/...'`, `'../scambio.ts'`, `'../logica-dosatore.ts'`, `'../scritturaUnica.ts'`, `'./CampoQuantita.tsx'`, `'./Segno.tsx'`, `'../segni.ts'`, `'./Barra.tsx'`, `'./FasciaRiscalo.tsx'`, `'../tema.ts'`, e `require('../../../assets/velo.png')` resta uguale perché la profondità è la stessa).

2. Nel `useFocusEffect`, il ramo della ricetta sparita chiama `sparita.current()` al posto di `navigation.goBack()`, e le dipendenze diventano `[db, ricettaId, mostra]`. `onSparita` passa da un ref e **non** va fra le dipendenze: il pannello la crea nuova a ogni disegno (`() => scegli(null)`), e da dipendenza farebbe rileggere la ricetta a ogni disegno del genitore. In cima al componente:

```ts
  // In un ref: chi usa la scheda può passarla nuova a ogni disegno.
  const sparita = useRef(onSparita);
  sparita.current = onSparita;
```

3. La barra: `onIndietro={onIndietro ?? undefined}` (la Barra non disegna la freccia quando `onIndietro` è `undefined`).

4. Lo scorrimento si ricorda e si ritrova. In cima al componente:

```ts
  const scorrimento = useRef<ScrollView>(null);
  // Si torna al punto una volta sola, quando il contenuto è abbastanza alto da
  // arrivarci: prima di allora scrollTo si fermerebbe in cima.
  const ritrovato = useRef(false);
```

   e sulla `ScrollView`:

```tsx
      <ScrollView
      ref={scorrimento}
      ...
      onContentSizeChange={(_, altezza) => {
        if (ritrovato.current) return;
        const y = memoriaScorrimento.posizione(ricettaId);
        if (y === 0 || altezza > y) {
          scorrimento.current?.scrollTo({ y, animated: false });
          ritrovato.current = true;
        }
      }}
      onScroll={(e) => {
        const y = e.nativeEvent.contentOffset.y;
        memoriaScorrimento.ricorda(ricettaId, y);
        const sopra = titoloInIntestazione(y, foto === null ? 0 : ALTEZZA_COPERTINA);
        if (sopra !== titoloSopra) setTitoloSopra(sopra);
      }}
```

   Quando `ricettaId` cambia (nel pannello si tocca un'altra ricetta) la scheda si rimonta, perché chi la usa le dà `key={ricettaId}`: `ritrovato` riparte da false senza codice in più.

Il paragrafo da aggiungere al commento d'intestazione:

```
 * Questa è la scheda, non la pagina: la usano la pagina Dosatore del
 * telefono (a tutto schermo, con la freccia indietro) e il pannello di destra
 * sugli schermi larghi (senza freccia). Chi la usa le dice cosa fare se la
 * ricetta sparisce, e le dà `key={ricettaId}`, così cambiando ricetta si
 * rimonta da capo. Il punto a cui si era scorsi si ricorda per ricetta
 * (memoriaScorrimento), e la scheda ci torna quando si ridisegna: è quello
 * che serve quando l'iPhone Duo si piega o si apre.
```

- [ ] **Step 2: la pagina del telefono**

`src/ui/schermate/Dosatore.tsx` diventa:

```tsx
/**
 * La pagina della ricetta sul telefono: la scheda a tutto schermo, con la
 * freccia indietro. Tutto il resto (dosi, riscalo, procedimento, condividi)
 * sta in SchedaRicetta, che usa anche il pannello di destra degli schermi
 * larghi.
 *
 * Tornando indietro si dimentica il punto di scorrimento: la prossima volta
 * la ricetta si riapre dall'alto, come si è sempre fatto. Lo si tiene solo
 * quando la pagina si ridisegna senza che l'utente l'abbia lasciata.
 */
import { useCallback } from 'react';

import type { PropsSchermata } from '../navigazione.ts';
import SchedaRicetta, { memoriaScorrimento } from '../componenti/SchedaRicetta.tsx';

export default function Dosatore({ route, navigation }: PropsSchermata<'Dosatore'>) {
  const { ricettaId } = route.params;
  const indietro = useCallback(() => {
    memoriaScorrimento.dimentica(ricettaId);
    navigation.goBack();
  }, [navigation, ricettaId]);
  return (
    <SchedaRicetta
      key={ricettaId}
      ricettaId={ricettaId}
      onIndietro={indietro}
      onSparita={navigation.goBack}
    />
  );
}
```

   Il gesto di trascinare dal bordo (iOS) e il tasto indietro di Android non passano da `indietro`: in quei casi il punto resta ricordato. È accettabile (riaprendo quella ricetta si torna dove si era) e non serve altro codice.

- [ ] **Step 3: verificare**

Run: `npm test 2>&1 | tail -5 && npx tsc --noEmit && echo tsc-ok`
Expected: pass, `tsc-ok`.

Poi a schermo, sull'emulatore del telefono (`QuantoBasta_Pixel`, procedura in `docs/BUILD.md`, APK di debug con Metro o release): aprire una ricetta, riscalare, scorrere, tornare indietro, riaprire. Deve comportarsi come prima (riapre dall'alto, dosi riscalate conservate).

- [ ] **Step 4: commit**

```bash
git add src/ui/componenti/SchedaRicetta.tsx src/ui/schermate/Dosatore.tsx
git commit -m "Il contenuto del dosatore diventa una scheda, riusabile dal pannello; ricorda il punto di scorrimento"
```

---

### Task 3: la pila «sfoglia» e l'apertura di una ricetta da un punto solo

Ancora nessun cambiamento visibile: si prepara la struttura. Categorie ed Elenco passano in una pila annidata dentro la pagina Principale, e aprono le ricette con `apriRicetta`.

**Files:**
- Modify: `src/ui/navigazione.ts`
- Create: `src/ui/pannelli.ts` (il contesto)
- Create: `src/ui/schermate/Principale.tsx`
- Modify: `src/ui/App.tsx`
- Modify: `src/ui/schermate/Categorie.tsx`, `src/ui/schermate/Elenco.tsx`

**Interfaces:**
- Consumes: `larghezzaSfoglia`, `duePannelli` (Task 1).
- Produces:
  - in `navigazione.ts`: `ParametriSfoglia = { Categorie: undefined; Elenco: { filtro: FiltroElenco } }`; `ParametriNav` perde `Categorie` ed `Elenco` e guadagna `Principale: undefined`; `PropsSfoglia<N extends keyof ParametriSfoglia>`.
  - in `pannelli.ts`: `interface StatoPannelli { due: boolean; larghezzaSfoglia: number; selezionata: string | null; apriRicetta(id: string): void; scegli(id: string | null): void }`, `ContestoPannelli`, `usePannelli()`.

- [ ] **Step 1: i tipi delle rotte**

In `src/ui/navigazione.ts`, al posto delle due voci `Categorie` ed `Elenco` in `ParametriNav`:

```ts
import type { CompositeScreenProps } from '@react-navigation/native';

/**
 * La pila dello sfoglia: le categorie e le ricette di una categoria. Sul
 * telefono occupa tutto lo schermo; sugli schermi larghi sta a sinistra,
 * accanto alla ricetta. Vive dentro la pagina Principale, e ci resta in
 * tutti e due i modi: quando l'iPhone Duo si piega o si apre, l'elenco non
 * si smonta e resta dov'era.
 */
export type ParametriSfoglia = {
  Categorie: undefined;
  Elenco: { filtro: FiltroElenco };
};

export type ParametriNav = {
  /** La pagina di partenza: la pila dello sfoglia, e sugli schermi larghi la ricetta accanto. */
  Principale: undefined;
  Dosatore: { ricettaId: string };
  // ... Modifica, GestioneCategorie, Impostazioni, Anteprima come oggi
};

/**
 * Le pagine dello sfoglia navigano anche verso la pila principale (Modifica,
 * Impostazioni...): React Navigation passa la richiesta al genitore da sé,
 * e questo tipo lo sa.
 */
export type PropsSfoglia<N extends keyof ParametriSfoglia> = CompositeScreenProps<
  NativeStackScreenProps<ParametriSfoglia, N>,
  NativeStackScreenProps<ParametriNav>
>;
```

Aggiornare anche il commento in cima al file: la schermata iniziale è Principale, che contiene lo sfoglia.

- [ ] **Step 2: il contesto dei pannelli**

`src/ui/pannelli.ts`:

```ts
/**
 * Quello che le pagine dello sfoglia e la scheda della ricetta devono sapere
 * dei due pannelli: se ci sono, quanto è largo lo sfoglia (per le colonne
 * delle griglie), quale ricetta è aperta a destra, e come si apre una
 * ricetta. Categorie ed Elenco chiamano apriRicetta e non sanno in che modo
 * sono: sugli schermi larghi la ricetta va nel pannello, sul telefono si
 * apre la pagina Dosatore come sempre.
 *
 * Lo stato vero sta in App.tsx; qui c'è solo il contesto che lo distribuisce.
 */
import { createContext, useContext } from 'react';

export interface StatoPannelli {
  /** Se lo schermo è diviso in due. */
  due: boolean;
  /** La larghezza della pila dello sfoglia: tutto lo schermo sul telefono. */
  larghezzaSfoglia: number;
  /** La ricetta del pannello di destra, o null (l'invito). Sul telefono è l'ultima aperta. */
  selezionata: string | null;
  apriRicetta: (id: string) => void;
  /** Cambia la ricetta del pannello senza aprire pagine: null torna all'invito. */
  scegli: (id: string | null) => void;
}

export const ContestoPannelli = createContext<StatoPannelli | null>(null);

export function usePannelli(): StatoPannelli {
  const stato = useContext(ContestoPannelli);
  if (stato === null) {
    throw new Error('usePannelli() chiamata fuori da ContestoPannelli.Provider');
  }
  return stato;
}
```

- [ ] **Step 3: la pagina Principale (per ora solo lo sfoglia)**

`src/ui/schermate/Principale.tsx`:

```tsx
/**
 * La pagina di partenza. Contiene la pila dello sfoglia (Categorie, Elenco);
 * sugli schermi larghi, accanto, il pannello della ricetta (Task 4).
 */
import { View, StyleSheet } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

import type { ParametriSfoglia } from '../navigazione.ts';
import { useApp } from '../contesto.ts';
import { t } from '../../i18n/index.ts';
import { useColori } from '../tema.ts';
import Categorie from './Categorie.tsx';
import Elenco from './Elenco.tsx';

const Sfoglia = createNativeStackNavigator<ParametriSfoglia>();

export default function Principale() {
  const { lingua } = useApp();
  const colori = useColori();
  return (
    <View style={stili.riga}>
      <View style={stili.sfoglia}>
        <Sfoglia.Navigator
          initialRouteName="Categorie"
          screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colori.fondo } }}
        >
          <Sfoglia.Screen name="Categorie" component={Categorie} options={{ title: t('app.nome', lingua) }} />
          <Sfoglia.Screen name="Elenco" component={Elenco} />
        </Sfoglia.Navigator>
      </View>
    </View>
  );
}

const stili = StyleSheet.create({
  riga: { flex: 1, flexDirection: 'row' },
  sfoglia: { flex: 1 },
});
```

- [ ] **Step 4: App.tsx**

1. Nella pila principale: via `Categorie` ed `Elenco`, `initialRouteName="Principale"`, e la prima schermata diventa
   `<Stack.Screen name="Principale" component={Principale} />` (import di `Principale`, via quelli di `Categorie` ed `Elenco`).
2. Lo stato dei pannelli, per ora senza la ricetta iniziale (arriva nel Task 4):

```tsx
import { useWindowDimensions } from 'react-native'; // nell'import esistente
import { ContestoPannelli } from './pannelli.ts';
import type { StatoPannelli } from './pannelli.ts';
import { duePannelli, larghezzaSfoglia } from './logica-pannelli.ts';

// dentro App, accanto agli altri stati:
const { width: larghezza } = useWindowDimensions();
const due = duePannelli(larghezza);
const [selezionata, setSelezionata] = useState<string | null>(null);

const pannelli = useMemo<StatoPannelli>(
  () => ({
    due,
    larghezzaSfoglia: larghezzaSfoglia(larghezza),
    selezionata,
    scegli: setSelezionata,
    apriRicetta: (id) => {
      setSelezionata(id);
      if (!due && navigazione.isReady()) navigazione.navigate('Dosatore', { ricettaId: id });
    },
  }),
  [due, larghezza, selezionata],
);
```

   e dentro `<ContestoApp.Provider>` si avvolge il `NavigationContainer` in `<ContestoPannelli.Provider value={pannelli}>`.

- [ ] **Step 5: Categorie ed Elenco**

In tutti e due:
- `PropsSchermata<'Categorie'>` diventa `PropsSfoglia<'Categorie'>`, e `PropsSchermata<'Elenco'>` diventa `PropsSfoglia<'Elenco'>` (import da `../navigazione.ts`);
- `const { apriRicetta, larghezzaSfoglia } = usePannelli();` (import da `../pannelli.ts`);
- `navigation.navigate('Dosatore', { ricettaId: item.id })` diventa `apriRicetta(item.id)` (Categorie riga 382, Elenco riga 318);
- la larghezza delle colonne del Task 1 diventa quella dello sfoglia: via `useWindowDimensions`, e `colonne(vista, larghezzaSfoglia)` nella `key` e in `numColumns`.

- [ ] **Step 6: verificare**

Run: `npm test 2>&1 | tail -5 && npx tsc --noEmit && echo tsc-ok`
Expected: pass, `tsc-ok`.

A schermo, sul telefono: partenza sulle categorie, categoria → elenco → ricetta → indietro → indietro; ricerca dalla home → ricetta; + → modifica → salva; ingranaggio → impostazioni; un file `.quantobasta` aperto dall'esterno apre l'anteprima. Tutto come prima.

- [ ] **Step 7: commit**

```bash
git add src/ui/navigazione.ts src/ui/pannelli.ts src/ui/schermate/Principale.tsx src/ui/App.tsx src/ui/schermate/Categorie.tsx src/ui/schermate/Elenco.tsx
git commit -m "Lo sfoglia in una pila sua dentro Principale, e le ricette si aprono da apriRicetta"
```

---

### Task 4: i due pannelli

**Files:**
- Create: `src/ui/componenti/Invito.tsx`
- Modify: `src/ui/schermate/Principale.tsx`
- Modify: `src/ui/App.tsx` (ricetta iniziale, ultima ricetta sul disco, cambio di larghezza)
- Modify: `src/ui/schermate/Elenco.tsx`, `src/ui/componenti/TesseraRicetta.tsx` (evidenziare la ricetta aperta)
- Modify: `src/ui/schermate/Modifica.tsx` (la ricetta nuova si apre a destra)
- Modify: `src/i18n/it.ts`, `src/i18n/en.ts` (la chiave `pannelli.invito`)

**Interfaces:**
- Consumes: tutto dei Task 1-3.
- Produces: `export default function Invito()`.

- [ ] **Step 1: la chiave dell'invito**

In `src/i18n/it.ts`, accanto a `'indietro'`: `'pannelli.invito': 'Scegli una ricetta',`
In `src/i18n/en.ts`, accanto a `'indietro'`: `'pannelli.invito': 'Pick a recipe',`

Run: `npm test 2>&1 | tail -3` → pass (il test delle lingue controlla che le chiavi siano le stesse).

- [ ] **Step 2: l'invito**

`src/ui/componenti/Invito.tsx`:

```tsx
/**
 * Il pannello di destra quando non c'è una ricetta da mostrare: la prima
 * volta, o dopo aver cancellato quella aperta. Il segno «q.b.» della
 * schermata d'avvio, tenue, e sotto una riga sola. Niente pulsanti: le
 * ricette sono lì a sinistra.
 */
import { Image, StyleSheet, Text, View } from 'react-native';

import { useApp } from '../contesto.ts';
import { SPAZIO, TESTO, useColori } from '../tema.ts';

export default function Invito() {
  const { testo } = useApp();
  const colori = useColori();
  return (
    <View style={[stili.centro, { backgroundColor: colori.fondo }]}>
      <Image
        source={require('../../../assets/splash-icon.png')}
        style={[stili.segno, { tintColor: colori.bordo }]}
        resizeMode="contain"
        accessibilityIgnoresInvertColors
      />
      <Text style={[TESTO.corpo, { color: colori.testoTenue }]}>{testo('pannelli.invito')}</Text>
    </View>
  );
}

const stili = StyleSheet.create({
  centro: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: SPAZIO.xxl },
  segno: { width: 160, height: 160, marginBottom: SPAZIO.l },
});
```

- [ ] **Step 3: Principale con il pannello**

In `src/ui/schermate/Principale.tsx` (aggiornare anche il commento in cima: «sugli schermi larghi, accanto, il pannello della ricetta»):

```tsx
import { usePannelli } from '../pannelli.ts';
import SchedaRicetta from '../componenti/SchedaRicetta.tsx';
import Invito from '../componenti/Invito.tsx';

export default function Principale() {
  const { lingua } = useApp();
  const colori = useColori();
  const { due, larghezzaSfoglia, selezionata, scegli } = usePannelli();
  return (
    <View style={stili.riga}>
      <View style={due ? [stili.sfogliaAccanto, { width: larghezzaSfoglia, borderRightColor: colori.bordo }] : stili.sfoglia}>
        {/* ... il Sfoglia.Navigator del Task 3, invariato ... */}
      </View>
      {due && (
        <View style={stili.ricetta}>
          {selezionata === null ? (
            <Invito />
          ) : (
            <SchedaRicetta
              key={selezionata}
              ricettaId={selezionata}
              onIndietro={null}
              onSparita={() => scegli(null)}
            />
          )}
        </View>
      )}
    </View>
  );
}

const stili = StyleSheet.create({
  riga: { flex: 1, flexDirection: 'row' },
  sfoglia: { flex: 1 },
  sfogliaAccanto: { borderRightWidth: StyleSheet.hairlineWidth },
  ricetta: { flex: 1 },
});
```

- [ ] **Step 4: App.tsx, la ricetta iniziale e l'ultima sul disco**

1. Nella lettura delle preferenze all'avvio si legge anche l'ultima ricetta, e si controlla che esista:

```ts
import { leggiRicetta } from '../data/ricette.ts';
import { ricettaVisibile } from './logica-dosatore.ts';
import { CHIAVE_RICETTA_ULTIMA, dopoCambioLarghezza, ricettaIniziale } from './logica-pannelli.ts';

// nel .then di apriDb, dopo il Promise.all di tema e lingua:
const ultima = await leggiImpostazione(db, CHIAVE_RICETTA_ULTIMA);
const esiste = ultima !== null && ricettaVisibile(await leggiRicetta(db, ultima));
if (!vivo) return;
setSelezionata(ricettaIniziale(ultima, esiste));
```

2. `scegli` e `apriRicetta` salvano l'ultima ricetta (null la cancella scrivendo la stringa vuota, che `ricettaIniziale` tratta come «nessuna»):

```ts
const scegli = useCallback(
  (id: string | null) => {
    setSelezionata(id);
    if (fase.nome === 'pronto') void salvaImpostazione(fase.db, CHIAVE_RICETTA_ULTIMA, id ?? '');
  },
  [fase],
);
// nel useMemo dei pannelli: scegli, e apriRicetta chiama scegli(id) al posto di setSelezionata(id).
// Dipendenze del useMemo: [due, larghezza, selezionata, scegli].
```

   Scegliere un'altra ricetta nel pannello dimentica il punto di scorrimento della precedente: in `scegli`, prima di `setSelezionata`, `if (selezionata !== null && selezionata !== id) memoriaScorrimento.dimentica(selezionata);` (import da `./componenti/SchedaRicetta.tsx`; aggiungere `selezionata` alle dipendenze di `useCallback`).

3. Il cambio di larghezza. Un ref ricorda il modo precedente; quando cambia, si calcola la disposizione nuova e la si applica alla pila:

```ts
import { CommonActions } from '@react-navigation/native';

const duePrima = useRef(due);
useEffect(() => {
  const prima = duePrima.current;
  duePrima.current = due;
  if (prima === due || !navigazione.isReady()) return;
  const stato = navigazione.getRootState();
  const dopo = dopoCambioLarghezza(prima, due, {
    rotte: stato.routes.map((r) => ({ name: r.name, params: r.params as Rotta['params'], key: r.key })),
    selezionata,
  });
  if (dopo.selezionata !== selezionata) setSelezionata(dopo.selezionata);
  if (dopo.rotte.length !== stato.routes.length) {
    navigazione.dispatch(
      CommonActions.reset({
        ...stato,
        routes: dopo.rotte as typeof stato.routes,
        index: dopo.rotte.length - 1,
      }),
    );
  }
}, [due, selezionata]);
```

   (`import type { Rotta } from './logica-pannelli.ts';`). Le rotte senza `key` (il Dosatore aggiunto chiudendo il Duo) ricevono una chiave nuova da React Navigation.

- [ ] **Step 5: la ricetta aperta si vede nell'elenco**

In `src/ui/componenti/TesseraRicetta.tsx`, una prop in più:

```ts
  /** La ricetta aperta nel pannello di destra: un bordo nell'accento. */
  evidenziata?: boolean;
```

e sul `Pressable`: `style={({ pressed }) => [stili.tessera, evidenziata && { borderWidth: 2, borderColor: colori.accento }, pressed && stili.premuta]}` (la tessera riceve già `colori`).

In `src/ui/schermate/Elenco.tsx`: `const { apriRicetta, larghezzaSfoglia, due, selezionata } = usePannelli();`, poi `const aperta = due && item.id === selezionata;`; alla tessera `evidenziata={aperta}`; alla riga `style={({ pressed }) => [stili.scheda, aperta && stili.schedaAperta, pressed && stili.schedaPremuta]}` con lo stile nuovo `schedaAperta: { borderColor: colori.accento, borderWidth: 2 }` accanto a `schedaPremuta`. Anche `accessibilityState={{ selected: aperta }}` sulla riga e sulla tessera.

- [ ] **Step 6: la ricetta nuova si apre a destra**

In `src/ui/schermate/Modifica.tsx`, in `salva`, dopo `salvaRicetta` e prima di `navigation.goBack()`:

```ts
        // Una ricetta nuova, sugli schermi larghi, si ritrova aperta a destra:
        // è quella a cui si stava pensando. Sul telefono si torna all'elenco
        // come sempre.
        if (route.params.ricettaId === null && due) scegli(nuova.id);
```

con `const nuova = ricettaDaBozza(esito.bozza, lingua, new Date().toISOString());` calcolata una volta e passata a `salvaRicetta(db, nuova)`, e `const { due, scegli } = usePannelli();` in cima al componente.

- [ ] **Step 7: verificare**

Run: `npm test 2>&1 | tail -5 && npx tsc --noEmit && echo tsc-ok`
Expected: pass, `tsc-ok`.

A schermo, sull'emulatore `QuantoBasta_Tablet` in verticale (1600×2560, `adb shell settings put system user_rotation 1`), con il ricettario demo importato:
- prima apertura: invito a destra; toccare una ricetta: si apre a destra ed è evidenziata a sinistra;
- riscalare e scorrere; toccarne un'altra; tornare alla prima: dall'alto, riscalo conservato;
- matita → modifica a tutto schermo → salva → di nuovo i due pannelli sulla stessa ricetta;
- + → ricetta nuova → salva → la nuova aperta a destra;
- modifica → elimina la ricetta aperta → invito;
- chiudere e riaprire l'app: si riapre sull'ultima ricetta;
- ruotare l'emulatore in orizzontale: resta verticale con le bande (blocco del plugin), niente si rompe.

Sul telefono (`QuantoBasta_Pixel`) e sul simulatore iPhone: niente di diverso da prima.

- [ ] **Step 8: commit**

```bash
git add src/ui/componenti/Invito.tsx src/ui/schermate/Principale.tsx src/ui/App.tsx src/ui/schermate/Elenco.tsx src/ui/componenti/TesseraRicetta.tsx src/ui/schermate/Modifica.tsx src/i18n/it.ts src/i18n/en.ts
git commit -m "I due pannelli sugli schermi larghi: sfoglia a sinistra, ricetta a destra"
```

---

### Task 5: collaudo del cambio di larghezza e documenti

Il simulatore dell'iPhone Duo arriva con Xcode 27.1. Fino ad allora il cambio di larghezza si prova sull'emulatore Android con un display ridimensionabile.

- [ ] **Step 1: provare il cambio di larghezza su Android**

L'emulatore tablet, portato sotto e sopra i 700 punti con `adb shell wm size 1080x2400` (telefono) e `adb shell wm size reset` (tablet), con `adb shell wm density 420` / `reset` per tenere le proporzioni del telefono:
- ricetta aperta a destra, riscalata e scorsa a metà → passare a telefono: la ricetta a tutto schermo, stesse dosi, stesso punto; indietro → l'elenco da cui si veniva;
- dal telefono con una ricetta aperta → passare a tablet: i due pannelli con quella ricetta a destra, stesse dosi e stesso punto;
- con Impostazioni aperte sopra, nei due versi: Impostazioni resta sopra.

Annotare quello che non torna e correggerlo nel task giusto prima di chiudere.

- [ ] **Step 2: documenti**

- `docs/STATO.md`: una riga sui due pannelli (da 700 punti, tablet verticale e iPhone Duo aperto), rimando al progetto.
- `docs/store/app-store.md` e memoria `quantobasta-dove-siamo.md`: da fare col simulatore del Duo quando esce Xcode 27.1 (aperto, chiuso, Split View a metà).
- Le schermate del tablet per Google Play vanno rifatte con i due pannelli (`docs/store/schermate/tablet/`, slide in `carosello/` con `p=tablet`).

- [ ] **Step 3: commit**

```bash
git add docs/STATO.md docs/store/app-store.md
git commit -m "Annota i due pannelli e il collaudo che resta da fare sul Duo"
```
