# Piano implementazione: contenuti taggati + pannello editor

> **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:** Ogni testo e immagine delle pagine vetrina riceve un tag stabile, i contenuti vivono in SQLite modificabili da un pannello `/admin/content` con ruoli admin/editor, stili tramite preset chiusi e overlay annotate sul sito.

**Architecture:** Tabella `content_blocks` nel DB SQLite esistente, seed versionato che importa i data file attuali, lib `content.ts` con cache in memoria invalidata al salvataggio, componenti Astro `<T>`/`<TImg>`, pagine vetrina flip a SSR, API REST sotto `/api/admin/content`, pannello SSR con JS vanilla.

**Tech Stack:** Astro 7 (adapter node standalone, `output: 'server'`), better-sqlite3, bcryptjs, sanitize-html, vitest. Zero librerie frontend nuove (vanilla JS, coerente col progetto).

**Spec:** `docs/superpowers/specs/2026-07-05-contenuti-taggati-pannello-editor-design.md`

## Global Constraints

- Rispondere a tutti i vincoli della spec; UI e messaggi in italiano.
- Tag: formato `pagina.sezione.elemento`, minuscolo, separatore `.`, segmenti kebab-case. Regex di validazione: `/^[a-z0-9-]+(\.[a-z0-9-]+)+$/`.
- Nessun CSS arbitrario dal pannello: solo chiavi preset whitelisted.
- Stile codice: come esistente (2 spazi, singoli apici, commenti italiani solo per vincoli non ovvi).
- Test: `npm test` (vitest, file in `tests/*.test.ts`, DB `:memory:` via `createDb(':memory:')`).
- Commit frequenti, messaggi brevi in italiano stile repo (`feat:`, `fix:`, `docs:`, `test:`).
- Il sito renderizzato NON deve cambiare visivamente dopo l'estrazione (stessi testi, stesse classi, stesse immagini).

## Deviazioni consapevoli dalla spec

1. **Celle agenda escluse dal tagging**: le 28 celle di `agenda.ts` sono dati placeholder del template Prowess (trainer fittizi); il cliente consegnerà l'orario reale. Taggare 84 valori destinati a essere buttati è spreco. Si taggano titolo sezione, discipline, giorni e slot. Le celle si affronteranno quando arriva l'orario reale.
2. **Aria-label e attributi tecnici esclusi** (`aria-label="Apri menu"`, title iframe): non sono contenuto editoriale.
3. **Alt text**: restano prop nel codice (derivati da name/title taggati dove data-driven); non diventano tag separati. Eccezione: nessuna — gli alt hardcoded seguono il testo del tag corrispondente dove esiste.

## Inventario tag

L'inventario completo (tag → valore attuale → file sorgente) è nel report allegato in coda a questo piano (sezione "Appendice: inventario"). I task di estrazione vi fanno riferimento.

---

### Task 1: Tabella content_blocks + colonna role

**Files:**
- Modify: `src/lib/db.ts` (SCHEMA + migrazione additiva)
- Test: `tests/content-db.test.ts`

**Interfaces:**
- Produces: tabella `content_blocks(tag PK, type, value, styles, updated_at, updated_by)`; colonna `users.role` DEFAULT `'admin'`.

- [ ] **Step 1: test fallente**

```ts
// tests/content-db.test.ts
import { describe, it, expect } from 'vitest';
import { createDb } from '../src/lib/db';

describe('schema content_blocks e ruoli', () => {
  it('crea la tabella content_blocks', () => {
    const db = createDb(':memory:');
    const cols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
    expect(cols.map((c) => c.name)).toEqual(
      expect.arrayContaining(['tag', 'type', 'value', 'styles', 'updated_at', 'updated_by'])
    );
  });

  it('rifiuta type non valido', () => {
    const db = createDb(':memory:');
    expect(() =>
      db.prepare(`INSERT INTO content_blocks (tag, type, value) VALUES ('x.y', 'video', '')`).run()
    ).toThrow();
  });

  it('users ha colonna role con default admin', () => {
    const db = createDb(':memory:');
    db.prepare(`INSERT INTO users (username, password_hash) VALUES ('a', 'h')`).run();
    const row = db.prepare(`SELECT role FROM users WHERE username = 'a'`).get() as { role: string };
    expect(row.role).toBe('admin');
  });

  it('migra un DB esistente senza colonna role', () => {
    // simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile,
    // quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia)
    const db = createDb(':memory:');
    expect(() => createDb(':memory:')).not.toThrow();
    expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4);
  });
});
```

- [ ] **Step 2: eseguire** `npx vitest run tests/content-db.test.ts` — atteso: FAIL (tabella/colonna assenti).

- [ ] **Step 3: implementazione** — in `src/lib/db.ts` aggiungere in coda alla costante `SCHEMA`:

```sql
CREATE TABLE IF NOT EXISTS content_blocks (
  tag        TEXT PRIMARY KEY,
  type       TEXT NOT NULL CHECK (type IN ('text','html','image')),
  value      TEXT NOT NULL DEFAULT '',
  styles     TEXT NOT NULL DEFAULT '{}',
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
  updated_by TEXT
);
```

e in `createDb`, dopo `db.exec(SCHEMA)`, la migrazione additiva:

```ts
  // Migrazione additiva: DB creati prima dell'introduzione dei ruoli non hanno users.role.
  const userCols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[];
  if (!userCols.some((c) => c.name === 'role')) {
    db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
  }
```

- [ ] **Step 4: eseguire** `npx vitest run tests/content-db.test.ts` — atteso: PASS. Poi `npm test` completo — atteso: tutto PASS.

- [ ] **Step 5: commit** `git add src/lib/db.ts tests/content-db.test.ts && git commit -m "feat: tabella content_blocks e colonna role utenti"`

---

### Task 2: Ruoli in auth + policy percorsi admin

**Files:**
- Modify: `src/lib/auth.ts`
- Modify: `src/env.d.ts`
- Modify: `scripts/create-user.mjs`
- Test: `tests/roles.test.ts`

**Interfaces:**
- Consumes: colonna `users.role` (Task 1).
- Produces: `createUser(db, username, password, role?: 'admin'|'editor'): number`; `getSessionUser(db, token): { id: number; username: string; role: string } | null`; `canAccessAdminPath(role: string, pathname: string): boolean`.

- [ ] **Step 1: test fallente**

```ts
// tests/roles.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { createUser, login, getSessionUser, canAccessAdminPath } from '../src/lib/auth';

let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });

describe('ruoli', () => {
  it('createUser accetta ruolo editor e getSessionUser lo ritorna', () => {
    createUser(db, 'cliente', 'segreta123', 'editor');
    const token = login(db, 'cliente', 'segreta123')!;
    expect(getSessionUser(db, token)).toMatchObject({ username: 'cliente', role: 'editor' });
  });

  it('createUser senza ruolo crea admin', () => {
    createUser(db, 'adriano', 'segreta123');
    const token = login(db, 'adriano', 'segreta123')!;
    expect(getSessionUser(db, token)).toMatchObject({ role: 'admin' });
  });

  it('admin accede a tutto, editor solo a contenuti e logout', () => {
    expect(canAccessAdminPath('admin', '/admin')).toBe(true);
    expect(canAccessAdminPath('admin', '/api/admin/posts')).toBe(true);
    expect(canAccessAdminPath('editor', '/admin/content')).toBe(true);
    expect(canAccessAdminPath('editor', '/api/admin/content/home.hero.title')).toBe(true);
    expect(canAccessAdminPath('editor', '/admin/logout')).toBe(true);
    expect(canAccessAdminPath('editor', '/admin')).toBe(false);
    expect(canAccessAdminPath('editor', '/admin/new')).toBe(false);
    expect(canAccessAdminPath('editor', '/api/admin/posts')).toBe(false);
    expect(canAccessAdminPath('editor', '/api/admin/upload')).toBe(false);
  });
});
```

- [ ] **Step 2: eseguire** `npx vitest run tests/roles.test.ts` — atteso: FAIL.

- [ ] **Step 3: implementazione** in `src/lib/auth.ts`:

```ts
export type Role = 'admin' | 'editor';

export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number {
  const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
    .run(username, hashPassword(password), role);
  return Number(res.lastInsertRowid);
}

// Percorsi admin consentiti anche al ruolo editor.
const EDITOR_ALLOWED = [/^\/admin\/content(\/|$)/, /^\/api\/admin\/content(\/|$)/, /^\/admin\/logout$/];

export function canAccessAdminPath(role: string, pathname: string): boolean {
  if (role === 'admin') return true;
  return EDITOR_ALLOWED.some((re) => re.test(pathname));
}
```

`getSessionUser`: aggiungere `u.role` alla SELECT e al tipo di ritorno `{ id, username, role }`.

`src/env.d.ts`: `user?: { id: number; username: string; role: string }`.

`scripts/create-user.mjs`: terzo argomento opzionale `role` (default `admin`, valori ammessi `admin|editor`, errore altrimenti); la CREATE TABLE inline nello script aggiunge `role TEXT NOT NULL DEFAULT 'admin'` e l'INSERT include role con `ON CONFLICT ... SET role = excluded.role`. Uso: `npm run create-user -- <username> <password> [role]`.

- [ ] **Step 4: eseguire** `npm test` — atteso: PASS (incluso `auth.test.ts` esistente, invariato).

- [ ] **Step 5: commit** `git add -u tests/roles.test.ts && git commit -m "feat: ruoli admin/editor in auth e create-user"`

---

### Task 3: Middleware con enforcement ruoli

**Files:**
- Modify: `src/middleware.ts`

**Interfaces:**
- Consumes: `canAccessAdminPath`, `getSessionUser` con role (Task 2).

- [ ] **Step 1: implementazione** — in `src/middleware.ts`, dopo aver ottenuto `user` valido, aggiungere:

```ts
  if (!canAccessAdminPath(user.role, pathname)) {
    if (pathname.startsWith('/api/')) {
      return new Response(JSON.stringify({ error: 'Permessi insufficienti' }), {
        status: 403, headers: { 'Content-Type': 'application/json' },
      });
    }
    return context.redirect('/admin/content');
  }
```

(import di `canAccessAdminPath` da `./lib/auth`). La logica di policy è già coperta dai test del Task 2; il middleware resta un guscio sottile.

- [ ] **Step 2: verifica manuale** — `npm run dev` in background, poi:

```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:4321/api/admin/posts   # atteso 401 (anonimo)
```

Login admin da browser o curl con cookie: comportamento invariato. (La verifica editor completa avviene al Task 13 quando esiste `/admin/content`.)

- [ ] **Step 3: commit** `git add src/middleware.ts && git commit -m "feat: middleware limita editor ai percorsi contenuti"`

---

### Task 4: Preset stili

**Files:**
- Create: `src/lib/style-presets.ts`
- Modify: `src/styles/global.css` (classi `.cs-*` in coda)
- Test: `tests/style-presets.test.ts`

**Interfaces:**
- Produces: `STYLE_GROUPS: Record<string, readonly string[]>`; `type StyleGroup = 'size'|'color'|'weight'|'style'|'align'`; `validateStyles(input: unknown): { ok: true; styles: Record<string, string> } | { ok: false; error: string }`; `stylesToClasses(styles: Record<string, string>): string`.

- [ ] **Step 1: test fallente**

```ts
// tests/style-presets.test.ts
import { describe, it, expect } from 'vitest';
import { validateStyles, stylesToClasses, STYLE_GROUPS } from '../src/lib/style-presets';

describe('style presets', () => {
  it('accetta chiavi e valori in whitelist', () => {
    const r = validateStyles({ size: 'l', color: 'accent', align: 'center' });
    expect(r).toEqual({ ok: true, styles: { size: 'l', color: 'accent', align: 'center' } });
  });

  it('rifiuta gruppo sconosciuto e valore fuori lista', () => {
    expect(validateStyles({ font: 'comic' }).ok).toBe(false);
    expect(validateStyles({ size: 'xxl' }).ok).toBe(false);
    expect(validateStyles('non-oggetto').ok).toBe(false);
  });

  it('mappa stili in classi cs-*', () => {
    expect(stylesToClasses({ size: 's', weight: 'bold' })).toBe('cs-size-s cs-weight-bold');
    expect(stylesToClasses({})).toBe('');
  });

  it('espone i gruppi attesi', () => {
    expect(Object.keys(STYLE_GROUPS)).toEqual(['size', 'color', 'weight', 'style', 'align']);
  });
});
```

- [ ] **Step 2: eseguire** `npx vitest run tests/style-presets.test.ts` — atteso: FAIL.

- [ ] **Step 3: implementazione**

```ts
// src/lib/style-presets.ts
export const STYLE_GROUPS = {
  size: ['s', 'm', 'l'],
  color: ['accent', 'accent-dark', 'dark', 'heading', 'text', 'text-light', 'white'],
  weight: ['normal', 'bold'],
  style: ['normal', 'italic'],
  align: ['left', 'center', 'right'],
} as const;

export type StyleGroup = keyof typeof STYLE_GROUPS;

export function validateStyles(input: unknown):
  | { ok: true; styles: Record<string, string> }
  | { ok: false; error: string } {
  if (typeof input !== 'object' || input === null || Array.isArray(input)) {
    return { ok: false, error: 'Stili non validi.' };
  }
  const out: Record<string, string> = {};
  for (const [k, v] of Object.entries(input)) {
    const allowed = (STYLE_GROUPS as Record<string, readonly string[]>)[k];
    if (!allowed) return { ok: false, error: `Gruppo stile sconosciuto: ${k}` };
    if (typeof v !== 'string' || !allowed.includes(v)) {
      return { ok: false, error: `Valore non ammesso per ${k}: ${String(v)}` };
    }
    out[k] = v;
  }
  return { ok: true, styles: out };
}

export function stylesToClasses(styles: Record<string, string>): string {
  return Object.entries(styles).map(([k, v]) => `cs-${k}-${v}`).join(' ');
}
```

In `src/styles/global.css`, in coda:

```css
/* === Stili contenuto (preset pannello /admin/content) === */
.cs-size-s { font-size: .85em; }
.cs-size-m { font-size: 1em; }
.cs-size-l { font-size: 1.25em; }
.cs-color-accent { color: var(--c-accent); }
.cs-color-accent-dark { color: var(--c-accent-dark); }
.cs-color-dark { color: var(--c-dark); }
.cs-color-heading { color: var(--c-heading); }
.cs-color-text { color: var(--c-text); }
.cs-color-text-light { color: var(--c-text-light); }
.cs-color-white { color: #fff; }
.cs-weight-normal { font-weight: 400; }
.cs-weight-bold { font-weight: 700; }
.cs-style-normal { font-style: normal; }
.cs-style-italic { font-style: italic; }
.cs-align-left { text-align: left; }
.cs-align-center { text-align: center; }
.cs-align-right { text-align: right; }
```

- [ ] **Step 4: eseguire** `npm test` — atteso: PASS.

- [ ] **Step 5: commit** `git add src/lib/style-presets.ts src/styles/global.css tests/style-presets.test.ts && git commit -m "feat: preset stili contenuto con whitelist"`

---

### Task 5: Seed contenuti + content lib con cache

**Files:**
- Create: `src/data/content-seed.ts`
- Create: `src/lib/content.ts`
- Modify: `src/lib/db.ts` (sync seed in `createDb`)
- Modify: `src/lib/sanitize.ts` (aggiunta `sanitizeInline`)
- Test: `tests/content.test.ts`

**Interfaces:**
- Consumes: `content_blocks` (Task 1), `stylesToClasses` (Task 4).
- Produces:
  - Seed: `type ContentType = 'text'|'html'|'image'`; `interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }`; `const contentSeed: SeedEntry[]`; `const seedByTag: Map<string, SeedEntry>`.
  - Lib: `resolveContent(tag: string): { value: string; type: ContentType; classes: string }`; `t(tag: string): string` (solo il valore); `getImageOverride(tag: string): string | null` (URL `/uploads/...` o null); `contentImageUrl(tag: string, fallback: string): string`; `invalidateContentCache(): void`; `syncSeed(db: Database): void`.
  - Sanitize: `sanitizeInline(html: string): string` (solo `strong, em, u, s, br, a`).

- [ ] **Step 1: test fallente**

```ts
// tests/content.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { contentSeed } from '../src/data/content-seed';
import { makeContentStore, syncSeed } from '../src/lib/content';

let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });

describe('seed', () => {
  it('tag univoci, validi e valori non vuoti', () => {
    const tags = contentSeed.map((e) => e.tag);
    expect(new Set(tags).size).toBe(tags.length);
    for (const e of contentSeed) {
      expect(e.tag).toMatch(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/);
      if (e.type !== 'image') expect(e.value.length).toBeGreaterThan(0);
    }
  });

  it('createDb sincronizza il seed senza sovrascrivere modifiche', () => {
    const n = (db.prepare('SELECT COUNT(*) AS n FROM content_blocks').get() as { n: number }).n;
    expect(n).toBe(contentSeed.length);
    db.prepare(`UPDATE content_blocks SET value = 'MODIFICATO' WHERE tag = ?`).run(contentSeed[0].tag);
    // ri-sync (come a un nuovo avvio) non deve toccare la modifica
    syncSeed(db);
    const v = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(contentSeed[0].tag) as { value: string };
    expect(v.value).toBe('MODIFICATO');
  });
});

describe('content store', () => {
  it('legge dal DB con classi preset e cache invalidabile', () => {
    const store = makeContentStore(() => db);
    const tag = contentSeed[0].tag;
    db.prepare(`UPDATE content_blocks SET value = 'Nuovo', styles = '{"size":"l"}' WHERE tag = ?`).run(tag);
    store.invalidate();
    const r = store.resolve(tag);
    expect(r.value).toBe('Nuovo');
    expect(r.classes).toBe('cs-size-l');
    // senza invalidate la cache resta
    db.prepare(`UPDATE content_blocks SET value = 'Altro' WHERE tag = ?`).run(tag);
    expect(store.resolve(tag).value).toBe('Nuovo');
    store.invalidate();
    expect(store.resolve(tag).value).toBe('Altro');
  });

  it('fallback: tag assente nel DB usa il seed, sconosciuto stringa vuota', () => {
    const store = makeContentStore(() => db);
    db.prepare('DELETE FROM content_blocks WHERE tag = ?').run(contentSeed[0].tag);
    store.invalidate();
    expect(store.resolve(contentSeed[0].tag).value).toBe(contentSeed[0].value);
    expect(store.resolve('tag.inesistente').value).toBe('');
  });

  it('styles corrotto nel DB viene ignorato', () => {
    const store = makeContentStore(() => db);
    db.prepare(`UPDATE content_blocks SET styles = 'non-json' WHERE tag = ?`).run(contentSeed[0].tag);
    store.invalidate();
    expect(store.resolve(contentSeed[0].tag).classes).toBe('');
  });
});
```

- [ ] **Step 2: eseguire** `npx vitest run tests/content.test.ts` — atteso: FAIL.

- [ ] **Step 3: implementazione seed (nucleo iniziale)** — `src/data/content-seed.ts` con tipi, helper e le sole entry GLOBALI per partire (i task di estrazione lo ampliano):

```ts
import type { StyleGroup } from '../lib/style-presets';
import { site } from './site';

export type ContentType = 'text' | 'html' | 'image';
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }

const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style']): SeedEntry =>
  ({ tag, type: 'text', value, styleOptions });
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color']): SeedEntry =>
  ({ tag, type: 'html', value, styleOptions });
const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' });

export const contentSeed: SeedEntry[] = [
  // --- Globali (site.ts) ---
  T('global.contact.phone', site.phone, []),
  T('global.contact.email', site.email, []),
  T('global.contact.address', site.address, []),
  T('global.contact.city-line', site.cityLine, []),
  T('global.hours.1', site.hours[0], []),
  T('global.hours.2', site.hours[1], []),
  // I task di estrazione aggiungono qui le entry di header/footer/pagine/data file.
];

export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
```

- [ ] **Step 4: implementazione lib** — `src/lib/content.ts`:

```ts
import type Database from 'better-sqlite3';
import { getDb } from './db';
import { contentSeed, seedByTag, type ContentType } from '../data/content-seed';
import { stylesToClasses } from './style-presets';

interface Entry { value: string; type: ContentType; styles: Record<string, string> }

export function syncSeed(db: Database.Database): void {
  const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
  const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); });
  tx();
}

export function makeContentStore(dbGetter: () => Database.Database) {
  let cache: Map<string, Entry> | null = null;

  const load = (): Map<string, Entry> => {
    if (cache) return cache;
    const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as
      { tag: string; type: ContentType; value: string; styles: string }[];
    cache = new Map(rows.map((r) => {
      let styles: Record<string, string> = {};
      try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
      if (typeof styles !== 'object' || styles === null) styles = {};
      return [r.tag, { value: r.value, type: r.type, styles }];
    }));
    return cache;
  };

  return {
    resolve(tag: string): { value: string; type: ContentType; classes: string } {
      const hit = load().get(tag);
      if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles) };
      const seed = seedByTag.get(tag);
      if (seed) return { value: seed.value, type: seed.type, classes: '' };
      console.warn(`[content] tag sconosciuto: ${tag}`);
      return { value: '', type: 'text', classes: '' };
    },
    invalidate(): void { cache = null; },
  };
}

const store = makeContentStore(getDb);

export const resolveContent = (tag: string) => store.resolve(tag);
export const t = (tag: string): string => store.resolve(tag).value;
export const invalidateContentCache = (): void => store.invalidate();

export function getImageOverride(tag: string): string | null {
  const r = store.resolve(tag);
  return r.type === 'image' && r.value ? `/uploads/${r.value}` : null;
}

export const contentImageUrl = (tag: string, fallback: string): string =>
  getImageOverride(tag) ?? fallback;
```

In `src/lib/db.ts`, `createDb` chiama il sync dopo schema e migrazioni. Per evitare import circolare (content.ts importa db.ts), il sync si fa con import dinamico differito NON praticabile in sync — soluzione: `syncSeed` vive in content.ts ma non importa db.ts a livello di modulo per la funzione; siccome `content.ts` importa `getDb` solo per lo store singleton, spostare `syncSeed` in un modulo dedicato è più pulito. **Scelta: crearlo in content.ts ma importare in db.ts SOLO il seed**, e replicare l'insert lì:

```ts
// in src/lib/db.ts (import in testa: nessun ciclo — content-seed non importa db)
import { contentSeed } from '../data/content-seed';
// ...in createDb, dopo migrazione role:
  const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
  const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); });
  syncTx();
```

e in content.ts `syncSeed` resta esportata per i test (stessa logica). In `src/lib/sanitize.ts` aggiungere:

```ts
export function sanitizeInline(html: string): string {
  return sanitize(html, {
    allowedTags: ['strong', 'em', 'u', 's', 'br', 'a'],
    allowedAttributes: { a: ['href', 'rel', 'target'] },
    allowedSchemes: ['https', 'http', 'mailto'],
    allowProtocolRelative: false,
  });
}
```

- [ ] **Step 5: eseguire** `npm test` — atteso: PASS.

- [ ] **Step 6: commit** `git add src/data/content-seed.ts src/lib/content.ts src/lib/db.ts src/lib/sanitize.ts tests/content.test.ts && git commit -m "feat: content store con seed, cache e fallback"`

---

### Task 6: Componenti `<T>` e `<TImg>`

**Files:**
- Create: `src/components/content/T.astro`
- Create: `src/components/content/TImg.astro`

**Interfaces:**
- Consumes: `resolveContent`, `getImageOverride` (Task 5).
- Produces: `<T tag as? class? />` (default `as="span"`; emette `data-tag`, classi preset accodate); `<TImg tag src alt class? loading? widths? sizes? />` (`src: ImageMetadata`; override → `<img src="/uploads/...">`).

- [ ] **Step 1: implementazione T.astro**

```astro
---
import { resolveContent } from '../../lib/content';
interface Props { tag: string; as?: string; class?: string }
const { tag, as = 'span', class: cls } = Astro.props;
const Tag = as;
const c = resolveContent(tag);
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
---
{c.type === 'html'
  ? <Tag data-tag={tag} class={classes} set:html={c.value} />
  : <Tag data-tag={tag} class={classes}>{c.value}</Tag>}
```

- [ ] **Step 2: implementazione TImg.astro**

```astro
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import { getImageOverride } from '../../lib/content';
interface Props {
  tag: string; src: ImageMetadata; alt: string;
  class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string;
}
const { tag, src, alt, class: cls, ...rest } = Astro.props;
const override = getImageOverride(tag);
---
{override
  ? <img src={override} alt={alt} class={cls} data-tag={tag} loading={rest.loading} />
  : <Image src={src} alt={alt} class={cls} data-tag={tag} {...rest} />}
```

- [ ] **Step 3: verifica sintassi** — `npx astro check` (accettare solo errori preesistenti, nessun nuovo errore nei due componenti).

- [ ] **Step 4: commit** `git add src/components/content && git commit -m "feat: componenti T e TImg per contenuti taggati"`

---

### Task 7: Estrazione — Header, Footer, ContactForm, globali

**Files:**
- Modify: `src/data/content-seed.ts` (entry global/header/footer/form)
- Modify: `src/components/Header.astro`, `src/components/Footer.astro`, `src/components/ContactForm.astro`

**Interfaces:**
- Consumes: `<T>`, `t()` (Task 5-6).
- Produces: tag `global.nav.<i>.label` (1-5), `header.logo.alt`, `footer.tagline`, `footer.about`, `footer.col.training-title`, `footer.col.programs-title`, `footer.col.contact-title`, `footer.copyright`, `footer.social-label`, `footer.training.<i>.label` (1-4), `footer.programs.<i>.label` (1-6), `form.field.first-name`, `form.field.last-name`, `form.field.phone`, `form.field.email`, `form.field.message`, `form.submit`, `form.msg.success`, `form.msg.error-generic`, `form.msg.error-network`.

Pattern di estrazione (vale per tutti i task 7-12):

1. Aggiungere le entry al seed copiando ESATTAMENTE il testo dal componente (helper `T()`/`H()`/`I()`).
2. Nel template sostituire il testo con `<T tag="..." as="<tag html attuale>" class="<classi attuali>" />` quando il testo occupa un intero elemento; con `{t('...')}` quando è un frammento (placeholder, attributo, interno di elemento con altro markup).
3. `type: 'html'` SOLO dove il valore contiene markup (`<br>`, `&nbsp;`, `<strong>`, link) — renderizzato con `set:html` via `<T>`.
4. Testi nei `<script>` client (messaggi form): passarli come attributi `data-*` sull'elemento form dal frontmatter (`data-msg-success={t('form.msg.success')}`) e leggerli nel JS con `form.dataset.msgSuccess`.
5. Liste da data file (nav, footer link): il loop resta sul data file per gli `href`; la label diventa `{t(`global.nav.${i + 1}.label`)}` con indice 1-based.

Casi specifici di questo task:
- `footer.tagline`: `H('footer.tagline', 'PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.')` — copiare il valore esatto dal file.
- `footer.copyright`: contiene `<strong>` e anno dinamico → tenere l'anno fuori dal tag: `© {year} <T tag="footer.copyright" as="span" />` con valore "Insanitylab, All Rights Reserved" (adattare al markup reale del file).
- Contatti footer (`site.phone` ecc.): sostituire con `{t('global.contact.phone')}` ecc. — le entry globali esistono dal Task 5.
- `footer.hours.*`: usare `{t('global.hours.1')}` / `{t('global.hours.2')}` al posto del map su `site.hours`.

- [ ] **Step 1**: aggiungere entry al seed (tutte quelle in Produces, valori copiati dai file).
- [ ] **Step 2**: sostituire nei tre componenti secondo il pattern.
- [ ] **Step 3: verifica** — `npm run dev` in background; poi:

```bash
curl -s http://localhost:4321/ | grep -o 'PERFORMANCE.*LONGEVITY' | head -1   # tagline presente
curl -s http://localhost:4321/ | grep -c 'data-tag="footer.'                  # > 0
curl -s http://localhost:4321/contact | grep -c 'Invia messaggio'             # 1+
npm test   # seed: unicità e formato tag
```

Confronto visivo: aprire la home, header/footer identici a prima.
- [ ] **Step 4: commit** `git add -u && git commit -m "feat: tag contenuti header, footer e form contatti"`

---

### Task 8: Estrazione — Home parte A (hero, info, feature, quote)

**Files:**
- Modify: `src/data/content-seed.ts`
- Modify: `src/pages/index.astro`, `src/components/home/HeroSlider.astro`, `src/components/home/InfoColumns.astro`, `src/components/home/FeatureBlocks.astro`, `src/components/home/QuoteBanner.astro`

**Interfaces:**
- Produces (tutte con valori copiati dai file):
  - `home.meta.title`, `home.training.title`, `home.training.subtitle` (index.astro)
  - `home.hero.title-line-1/-2/-3`, `home.hero.cta`, `home.hero.image` (HeroSlider)
  - `home.info.col1.title`, `home.info.col2.title`, `home.info.col3.title`, `home.info.col1.image`, `home.info.col2.image`, `home.info.col3.image` (InfoColumns)
  - `home.feature.performance.title/.body/.cta`, `home.feature.balance.title/.body/.cta`, `home.feature.longevity.title/.body/.cta`, `home.feature.performance.image`, `home.feature.balance.image`, `home.feature.longevity.image` (FeatureBlocks)
  - `home.quote.text` (type `html`, col markup `<strong>` GIÀ applicato nel valore — congelare l'output della regex runtime nel seed e rimuovere la regex), `home.quote.cite`, `home.quote.image` (QuoteBanner)

Casi specifici:
- Meta: `<Base title={t('home.meta.title')}>`.
- Immagini `<img>`/`<Image>` → `<TImg tag="..." src={importAttuale} alt="..." />` mantenendo prop attuali.
- Background (InfoColumns via `getImage`, QuoteBanner): nel frontmatter `const bgUrl = contentImageUrl('home.info.col1.image', ottimizzata.src)` e usare `bgUrl` nello style inline; aggiungere `data-tag` sull'elemento col background.
- QuoteBanner marquee: il testo è renderizzato 2× — usare `{t('home.quote.text')}` con `set:html` in entrambe le copie (la seconda resta `aria-hidden`).

- [ ] **Step 1**: seed + sostituzioni nei 5 file.
- [ ] **Step 2: verifica**

```bash
curl -s http://localhost:4321/ | grep -c 'data-tag="home.'    # >= 20
curl -s http://localhost:4321/ | grep -c 'PERFORMANCE'        # invariato rispetto a prima della modifica
npm test && npx astro check
```

Aprire la home nel browser: hero, colonne, feature, citazione identici (incluse animazioni).
- [ ] **Step 3: commit** `git add -u && git commit -m "feat: tag contenuti home hero, info, feature, quote"`

---

### Task 9: Estrazione — Home parte B (training cards, programmi, team, metodo, partner, agenda)

**Files:**
- Modify: `src/data/content-seed.ts`
- Modify: `src/components/home/TrainingCards.astro`, `ProgramsGrid.astro`, `TeamSection.astro`, `MethodSteps.astro`, `PartnersStrip.astro`, `src/components/Agenda.astro`, `src/pages/training.astro` (solo dove riusa gli stessi tag trainings)

**Interfaces:**
- Produces:
  - Da `trainings.ts` (seed generato con `.map` sul data file): `training.<id>.title/.caption/.description/.cta-label` + `training.<id>.image` per id `one-to-one, small-groups, performance-class, fit-remote`; più `home.training-cards.link-label`.
  - Da `team.ts`: `team.<slug>.name/.role/.bio` + `team.<slug>.image` e `team.<slug>.image-about` per slug `donata, nicola, eva, alessandro, eleonora, antonello, alice, davide, giulia`; più `home.team.title` (html, `<br>`), `home.team.cta`.
  - Da `method.ts`: `method.intro` (html, con `<strong>` congelato), `method.extra-note`, `method.step.<n>.title/.text` (n=1..4), `method.step.<n>.image`; più `home.method.title` (html).
  - Da `partners.ts`: `partner.<i>.name` + `partner.<i>.image` (i=1..4).
  - Agenda: `agenda.title`, `agenda.discipline.<i>` (1-7), `agenda.day.<i>` (1-7), `agenda.slot.<i>` (1-5). CELLE ESCLUSE (vedi deviazioni).
  - `home.programs.title`, `home.programs.body`.

Nel seed le entry data-driven si generano importando i data file:

```ts
import { trainings } from './trainings';
// ...
...trainings.flatMap((tr) => [
  T(`training.${tr.id}.title`, tr.title),
  T(`training.${tr.id}.caption`, tr.caption),
  T(`training.${tr.id}.description`, tr.description),
  T(`training.${tr.id}.cta-label`, tr.cta.label, []),
  I(`training.${tr.id}.image`),
]),
```

(stesso pattern per team, method, partners; per team lo slug è `m.shortName.toLowerCase()`).

Nei componenti il loop resta sul data file per struttura/immagini/href; i testi diventano `<T tag={`training.${t.id}.title`} .../>` o `{t(...)}`. Le immagini risolte via glob: `<TImg tag={`training.${tr.id}.image`} src={imgOf(tr.image)} alt={t(`training.${tr.id}.title`)} />`.

- [ ] **Step 1**: seed generato + sostituzioni nei 6 componenti (+ training.astro per i blocchi che usano `t.title`/`t.description`).
- [ ] **Step 2: verifica**

```bash
curl -s http://localhost:4321/ | grep -c 'data-tag="team.'      # >= 4
curl -s http://localhost:4321/ | grep -c 'data-tag="training.'  # >= 4
curl -s http://localhost:4321/training | grep -c 'One to One'   # >= 1
npm test && npx astro check
```

Browser: home completa identica, pagina training identica.
- [ ] **Step 3: commit** `git add -u && git commit -m "feat: tag contenuti home cards, team, metodo, partner, agenda"`

---

### Task 10: Estrazione — About + Timeline

**Files:**
- Modify: `src/data/content-seed.ts`
- Modify: `src/pages/about.astro`, `src/components/about/Timeline.astro`

**Interfaces:**
- Produces: tutti i tag `about.*` dell'appendice: meta (2), hero title, mission (2), direzione (3), approccio (3), about (2), convenzioni (5: title, body, item1-3 — la riga finale con email/telefono si compone come testo taggato `about.convenzioni.cta-text` + `{t('global.contact.email')}` + `{t('global.contact.phone')}`), team (3), metodo (3), studios (4), vision (3), storylab (2), timeline (8: label, open-text, stage1-3 label+text) + immagini `about.mission.image`, `about.about.image`, `about.convenzioni.image`, `about.metodo.image`, `about.studios.image`, `about.vision.image`.
- Le foto team della griglia about usano i tag `team.<slug>.image-about` (Task 9).

- [ ] **Step 1**: seed + sostituzioni. PageHero riceve `title={t('about.hero.title')}`.
- [ ] **Step 2: verifica**

```bash
curl -s http://localhost:4321/about | grep -c 'data-tag="about.'   # >= 25
curl -s http://localhost:4321/about | grep -c 'Mission'            # invariato
npm test && npx astro check
```

Browser: /about identica (griglia team, timeline con details).
- [ ] **Step 3: commit** `git add -u && git commit -m "feat: tag contenuti pagina about e timeline"`

---

### Task 11: Estrazione — Programs (lista + dettaglio) + pricing

**Files:**
- Modify: `src/data/content-seed.ts`
- Modify: `src/pages/programs/index.astro`, `src/pages/programs/[slug].astro`

**Interfaces:**
- Produces:
  - Da `programs.ts` (seed generato): `program.<slug>.title/.subtitle/.excerpt/.long-description`, `program.<slug>.feature.<i>`, `program.<slug>.faq.<i>.q/.a` + `program.<slug>.image` per i 6 slug.
  - Da `pricing.ts`: `pricing.heading`, `pricing.intro`, `pricing.group.<i>.label` (1-3), `pricing.group.1.plan.<i>.note/.installments/.best` (1-3). I valori numerici `monthly`/`entries` restano nel data file (strutturali, guidano singolare/plurale e layout).
  - Template: `programs.meta.title`, `programs.meta.description`, `programs.hero.title`, `programs.intro.title`, `programs.intro.body`, `programs.card.more-label`, `program.side.categories-label`, `program.side.category.<i>` (1-4), `program.side.others-label`, `program.pricing.empty-note` (html, con link), `program.pricing.per`, `program.pricing.entries-singular`, `program.pricing.entries-plural`, `program.pricing.buy-cta`.

Nota: il dettaglio programma usa `program.title.toUpperCase()` per l'hero → `t(`program.${slug}.title`).toUpperCase()`. Meta del dettaglio: `t(`program.${slug}.title`)` / `t(`program.${slug}.excerpt`)`.

- [ ] **Step 1**: seed generato + sostituzioni nelle 2 pagine.
- [ ] **Step 2: verifica**

```bash
curl -s http://localhost:4321/programs | grep -c 'data-tag='                      # > 10
for s in personal-training performance-class coaching pilates-flow rehab nutrition; do
  curl -s -o /dev/null -w "%{http_code} $s\n" http://localhost:4321/programs/$s   # tutti 200
done
curl -s http://localhost:4321/programs/performance-class | grep -c 'Acquista'     # >= 1
npm test && npx astro check
```

Browser: /programs e almeno 2 dettagli identici (FAQ details, pricing card colorate).
- [ ] **Step 3: commit** `git add -u && git commit -m "feat: tag contenuti programmi e pricing"`

---

### Task 12: Estrazione — Training, Contact, 404

**Files:**
- Modify: `src/data/content-seed.ts`
- Modify: `src/pages/training.astro`, `src/pages/contact.astro`, `src/pages/404.astro`

**Interfaces:**
- Produces:
  - `training.meta.title`, `training.meta.description`, `training.hero.title`, `training.intro.title`, `training.intro.subtitle`, `training.trial.title`, `training.trial.body`, `training.trial.cta`, `training.trial.image` (i blocchi per-training usano i tag `training.<id>.*` del Task 9).
  - `contact.meta.title`, `contact.meta.description`, `contact.hero.title`, `contact.hero.image`, `contact.map.button`, `contact.map.note`, `contact.info.address-label`, `contact.info.phone-label`, `contact.info.email-label`, `contact.invite` (valori contatto → tag `global.contact.*`).
  - `notfound.meta.title`, `notfound.eyebrow`, `notfound.title`, `notfound.body`, `notfound.cta`.

Caso specifico: `contact.hero.image` è background di PageHero → `bgImage={contentImageUrl('contact.hero.image', contactHero.src)}` (adattare a come PageHero consuma la prop; se riceve ImageMetadata, convertire PageHero ad accettare anche una URL stringa).

- [ ] **Step 1**: seed + sostituzioni nelle 3 pagine.
- [ ] **Step 2: verifica**

```bash
for p in training contact; do curl -s -o /dev/null -w "%{http_code} $p\n" http://localhost:4321/$p; done
curl -s http://localhost:4321/pagina-inesistente | grep -c 'Pagina non trovata'   # >= 1
npm test && npx astro check
```

- [ ] **Step 3: commit** `git add -u && git commit -m "feat: tag contenuti training, contact e 404"`

---

### Task 13: Flip a SSR + sitemap statica

**Files:**
- Modify: tutte le pagine vetrina (`src/pages/index.astro`, `about.astro`, `contact.astro`, `training.astro`, `404.astro`, `programs/index.astro`, `programs/[slug].astro`): `export const prerender = false;`
- Create: `src/pages/sitemap.xml.ts`
- Modify: `astro.config.mjs` (rimozione integrazione `@astrojs/sitemap`), `src/layouts/Base.astro` (link sitemap), `public/robots.txt` se referenzia la sitemap.

**Interfaces:**
- Consumes: `getDb` e tabella posts per le URL del blog.
- Produces: endpoint `GET /sitemap.xml` con le route vetrina + blog pubblicati.

- [ ] **Step 1**: cambiare `prerender` nelle 7 pagine. In `programs/[slug].astro` sostituire `getStaticPaths` con lettura runtime dello slug (`Astro.params.slug` + `getProgram(slug)`, 404 con `Astro.redirect` o `new Response(null, { status: 404 })` se sconosciuto — seguire il pattern di `blog/[slug].astro` esistente).
- [ ] **Step 2**: creare l'endpoint:

```ts
// src/pages/sitemap.xml.ts
import type { APIRoute } from 'astro';
import { getDb } from '../lib/db';
import { programs } from '../data/programs';
export const prerender = false;

const STATIC = ['/', '/about', '/contact', '/training', '/programs', '/blog'];

export const GET: APIRoute = ({ site }) => {
  const base = (site ?? new URL('https://insanitylab.tielogic.xyz')).toString().replace(/\/$/, '');
  const posts = getDb().prepare(
    `SELECT slug FROM posts WHERE draft = 0 AND published_at <= datetime('now')`
  ).all() as { slug: string }[];
  const urls = [
    ...STATIC,
    ...programs.map((p) => `/programs/${p.slug}`),
    ...posts.map((p) => `/blog/${p.slug}`),
  ];
  const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `  <url><loc>${base}${u}</loc></url>`).join('\n')}\n</urlset>`;
  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } });
};
```

- [ ] **Step 3**: `astro.config.mjs`: togliere `sitemap(...)` da `integrations` e l'import. `Base.astro`: `<link rel="sitemap" href="/sitemap.xml" />`. Controllare `grep -ri sitemap public/` e aggiornare robots.txt se serve.
- [ ] **Step 4: verifica**

```bash
npm run build                                          # build pulita
curl -s http://localhost:4321/sitemap.xml | grep -c '<loc>'   # >= 12 (6 statiche + 6 programmi + blog)
curl -s -o /dev/null -w '%{http_code}' http://localhost:4321/programs/inesistente  # 404
npm test
```

- [ ] **Step 5: commit** `git add -u src/pages/sitemap.xml.ts && git commit -m "feat: pagine vetrina in SSR e sitemap runtime"`

---

### Task 14: API contenuti

**Files:**
- Create: `src/pages/api/admin/content/index.ts` (GET elenco)
- Create: `src/pages/api/admin/content/[tag].ts` (PUT)
- Create: `src/pages/api/admin/content/[tag]/image.ts` (POST upload, DELETE ripristino)
- Test: `tests/content-api.test.ts` (validazione payload estratta in lib)
- Create: `src/lib/content-update.ts` (logica pura testabile)

**Interfaces:**
- Consumes: `validateStyles` (Task 4), `sanitizeInline` (Task 5), `invalidateContentCache` (Task 5), `seedByTag` (Task 5).
- Produces: `applyContentUpdate(db, tag, body, username): { ok: true } | { ok: false; status: number; error: string }` in content-update.ts; route HTTP:
  - `GET /api/admin/content` → `{ items: [{ tag, type, value, styles, styleOptions, updatedAt, updatedBy }] }`
  - `PUT /api/admin/content/[tag]` body `{ value?: string, styles?: object }` → 200 `{ ok: true }` | 400/404
  - `POST /api/admin/content/[tag]/image` (multipart `file`) → `{ url }`; `DELETE` → `{ ok: true }` (value = '')

- [ ] **Step 1: test fallente**

```ts
// tests/content-api.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { contentSeed } from '../src/data/content-seed';
import { applyContentUpdate } from '../src/lib/content-update';

let db: Database.Database;
const textTag = contentSeed.find((e) => e.type === 'text')!.tag;
const htmlTag = contentSeed.find((e) => e.type === 'html')?.tag;
beforeEach(() => { db = createDb(':memory:'); });

describe('applyContentUpdate', () => {
  it('aggiorna value e styles validi', () => {
    const r = applyContentUpdate(db, textTag, { value: 'Nuovo testo', styles: { size: 'l' } }, 'admin');
    expect(r).toEqual({ ok: true });
    const row = db.prepare('SELECT value, styles, updated_by FROM content_blocks WHERE tag = ?').get(textTag) as any;
    expect(row.value).toBe('Nuovo testo');
    expect(JSON.parse(row.styles)).toEqual({ size: 'l' });
    expect(row.updated_by).toBe('admin');
  });

  it('404 su tag inesistente, 400 su styles fuori whitelist', () => {
    expect(applyContentUpdate(db, 'no.tag', { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 404 });
    expect(applyContentUpdate(db, textTag, { styles: { size: 'xxl' } }, 'a')).toMatchObject({ ok: false, status: 400 });
  });

  it('sanifica i tag html e rifiuta value su tag image', () => {
    if (htmlTag) {
      applyContentUpdate(db, htmlTag, { value: 'ciao <script>alert(1)</script><strong>ok</strong>' }, 'a');
      const row = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(htmlTag) as any;
      expect(row.value).not.toContain('<script>');
      expect(row.value).toContain('<strong>ok</strong>');
    }
    const imgTag = contentSeed.find((e) => e.type === 'image')!.tag;
    expect(applyContentUpdate(db, imgTag, { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 400 });
  });
});
```

- [ ] **Step 2: eseguire** — FAIL.
- [ ] **Step 3: implementazione**

```ts
// src/lib/content-update.ts
import type Database from 'better-sqlite3';
import { validateStyles } from './style-presets';
import { sanitizeInline } from './sanitize';

interface Body { value?: unknown; styles?: unknown }

export function applyContentUpdate(db: Database.Database, tag: string, body: Body, username: string):
  { ok: true } | { ok: false; status: number; error: string } {
  const row = db.prepare('SELECT type FROM content_blocks WHERE tag = ?').get(tag) as { type: string } | undefined;
  if (!row) return { ok: false, status: 404, error: 'Tag inesistente.' };

  const sets: string[] = [];
  const args: unknown[] = [];
  if (body.value !== undefined) {
    if (typeof body.value !== 'string') return { ok: false, status: 400, error: 'Valore non valido.' };
    if (row.type === 'image') return { ok: false, status: 400, error: 'Le immagini si aggiornano via upload.' };
    sets.push('value = ?');
    args.push(row.type === 'html' ? sanitizeInline(body.value) : body.value);
  }
  if (body.styles !== undefined) {
    const v = validateStyles(body.styles);
    if (!v.ok) return { ok: false, status: 400, error: v.error };
    sets.push('styles = ?');
    args.push(JSON.stringify(v.styles));
  }
  if (!sets.length) return { ok: false, status: 400, error: 'Nessuna modifica.' };
  sets.push(`updated_at = datetime('now')`, 'updated_by = ?');
  args.push(username, tag);
  db.prepare(`UPDATE content_blocks SET ${sets.join(', ')} WHERE tag = ?`).run(...args);
  return { ok: true };
}
```

Route `[tag].ts` (PUT): parse JSON, `applyContentUpdate(getDb(), params.tag, data, locals.user!.username)`, su ok `invalidateContentCache()` e 200; altrimenti status/error. Route `index.ts` (GET): join DB + `styleOptions` da `seedByTag`, ordinato per tag. Route `[tag]/image.ts`: POST — verifica che il tag esista con `type='image'` (404/400), validazione file identica a `api/admin/upload.ts` (stesse costanti ALLOWED/MAX_BYTES: estrarle in `src/lib/upload-rules.ts` ed importarle in entrambi), salvataggio in `join(getEnv('UPLOADS_DIR','uploads'), 'content')` con nome `<tag con . → ->-<timestamp>.<ext>`, `UPDATE content_blocks SET value = 'content/<nome>'`, `invalidateContentCache()`, risposta `{ url: '/uploads/content/<nome>' }`. DELETE — `UPDATE ... SET value = ''` + invalidate (file orfano tollerato, come da spec).

- [ ] **Step 4: eseguire** `npm test` — PASS. Verifica HTTP con dev server + cookie sessione admin:

```bash
# login e salvataggio cookie
curl -s -c /tmp/cj -X POST http://localhost:4321/admin/login -d 'username=admin&password=prova1234' -o /dev/null
curl -s -b /tmp/cj -X PUT http://localhost:4321/api/admin/content/notfound.title \
  -H 'Content-Type: application/json' -d '{"value":"Pagina non trovata!"}'        # {"ok":true}
curl -s http://localhost:4321/pagina-x | grep -c 'Pagina non trovata!'            # 1 → cache invalidata
curl -s -b /tmp/cj -X PUT http://localhost:4321/api/admin/content/notfound.title \
  -H 'Content-Type: application/json' -d '{"value":"Pagina non trovata."}' -o /dev/null  # ripristino
```

(Adattare il POST di login al formato reale di `admin/login.astro`.)
- [ ] **Step 5: commit** `git add src/pages/api/admin/content src/lib/content-update.ts src/lib/upload-rules.ts tests/content-api.test.ts -u && git commit -m "feat: API contenuti taggati"`

---

### Task 15: Pannello /admin/content

**Files:**
- Create: `src/pages/admin/content.astro`
- Modify: `src/layouts/Admin.astro` (nav condizionale per ruolo + link Contenuti)

**Interfaces:**
- Consumes: DB + `seedByTag` (styleOptions), `STYLE_GROUPS`, API Task 14.

Requisiti funzionali (dalla spec §7):
- Gruppi collassabili per prefisso pagina: Home (`home.*`), About (`about.*` + `team.*` + `method.*`), Programmi (`program.*`, `programs.*`, `pricing.*`), Training (`training.*`), Contatti (`contact.*`, `form.*`), Globali (`global.*`, `header.*`, `footer.*`, `agenda.*`, `partner.*`, `notfound.*`). Mappa prefisso→gruppo esplicita nel frontmatter; tag senza gruppo finiscono in "Altro".
- Ricerca client-side (input che filtra le righe per tag o contenuto, `hidden` sulle non corrispondenti).
- Riga per tag: `<details id="<tag>">` con summary = tag + inizio valore; dentro: textarea (se `value.length > 80` o contiene `\n`) o input; select per ogni gruppo in `styleOptions` (opzione vuota = default); per `type='image'`: anteprima corrente (override o testo "originale"), `<input type=file>`, bottoni Carica / Ripristina originale.
- Salva per riga → `fetch` PUT; esito inline (`.form-msg--ok/--err` riusando classi admin).
- Ancora: `location.hash` → il `<details>` corrispondente si apre ed evidenzia (`scrollIntoView`), listener `hashchange`.
- Header pagina: link "Vedi sito con tag ↗" → `/?annotate=1` target blank.
- `Admin.astro`: se `user.role === 'editor'` la nav mostra solo "Contenuti" e "Esci"; per admin aggiunge link "Contenuti" accanto ad "Articoli".

- [ ] **Step 1**: implementare la pagina (SSR legge `content_blocks` + seed, raggruppa, renderizza; JS vanilla inline per fetch/upload/ricerca/ancore). Niente framework.
- [ ] **Step 2: verifica manuale con dev server**

```bash
curl -s -b /tmp/cj http://localhost:4321/admin/content | grep -c '<details'   # ~ numero tag
```

Browser (admin): modificare `notfound.body` → salvare → aprire `/pagina-x` → testo aggiornato. Cambiare uno stile (size l) → verificare classe `cs-size-l` sul sito. Upload immagine su `home.hero.image` → home mostra l'override; Ripristina → torna l'originale.
- [ ] **Step 3: commit** `git add src/pages/admin/content.astro src/layouts/Admin.astro && git commit -m "feat: pannello admin contenuti"`

---

### Task 16: Overlay annotate

**Files:**
- Modify: `src/layouts/Base.astro`
- Create: `src/styles/annotate.css` (importato condizionalmente) oppure blocco `<style>` condizionale in Base

**Interfaces:**
- Consumes: `SESSION_COOKIE`, `getSessionUser`, `getDb`.

- [ ] **Step 1**: in `Base.astro` frontmatter:

```ts
import { getDb } from '../lib/db';
import { getSessionUser, SESSION_COOKIE } from '../lib/auth';

let annotate = false;
if (Astro.url.searchParams.get('annotate') === '1') {
  const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
  annotate = Boolean(tok && getSessionUser(getDb(), tok));
}
```

Nel body, solo se `annotate`, iniettare style + script:

```html
{annotate && (
  <style is:global>
    [data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
    .tag-badge { position: absolute; z-index: 9999; background: #3a2929; color: #fff;
      font: 600 10px/1.6 monospace; padding: 0 6px; text-decoration: none; opacity: .85; }
    .tag-badge:hover { opacity: 1; background: #9c8b70; }
  </style>
  <script is:inline>
    document.querySelectorAll('[data-tag]').forEach((el) => {
      const a = document.createElement('a');
      a.className = 'tag-badge';
      a.textContent = el.dataset.tag;
      a.href = '/admin/content#' + el.dataset.tag;
      a.target = '_blank';
      document.body.appendChild(a);
      const place = () => {
        const r = el.getBoundingClientRect();
        a.style.top = window.scrollY + r.top - 14 + 'px';
        a.style.left = window.scrollX + r.left + 'px';
      };
      place();
      addEventListener('scroll', place, { passive: true });
      addEventListener('resize', place);
    });
  </script>
)}
```

- [ ] **Step 2: verifica** — browser: `/?annotate=1` da loggato → badge visibili, click badge apre il form giusto; stessa URL in finestra anonima → nessun badge, nessun outline; pagina normale senza parametro → invariata (curl diff: `curl -s localhost:4321/ | grep -c tag-badge` → 0).
- [ ] **Step 3: commit** `git add -u && git commit -m "feat: overlay annotate per utenti loggati"`

---

### Task 17: Utente editor, typecheck, smoke completo

**Files:**
- Modify: `scripts/smoke.sh` (aggiunte route/asserzioni contenuti)

- [ ] **Step 1**: creare utente editor locale: `npm run create-user -- editor prova1234 editor`. Verifica browser: login editor → atterra/limitato a `/admin/content`; `/admin` → redirect a `/admin/content`; PUT contenuto ok; `curl -b <cookie editor> -X POST /api/admin/posts` → 403.
- [ ] **Step 2**: estendere `scripts/smoke.sh`: check `GET /sitemap.xml` 200 + `<loc>`, `GET /` contiene `data-tag=`, `GET /?annotate=1` anonimo NON contiene `tag-badge`, tutte le route vetrina 200.
- [ ] **Step 3**: `npx astro check && npm test && npm run build && ./scripts/smoke.sh` (contro build di produzione via `npm run start`). Tutto verde.
- [ ] **Step 4: commit** `git add -u && git commit -m "test: smoke contenuti taggati e utente editor"`

---

### Task 18: Deploy produzione e verifica

- [ ] **Step 1**: push al remote Gitea (`git push`).
- [ ] **Step 2**: sul VPS: `cd /opt/docker/insanitylab/src && git pull && docker compose up -d --build` (pattern consolidato del progetto).
- [ ] **Step 3**: creare utente editor in produzione dentro il container (`docker compose exec <servizio> npm run create-user -- <nome> <password-generata> editor`); consegnare credenziali all'operatore (Vaultwarden).
- [ ] **Step 4: verifica produzione**: route 200 (home, about, programs, training, contact, sitemap.xml); login admin → `/admin/content` funziona; modifica di prova su un tag → visibile sul sito → ripristino; `/?annotate=1` da loggato mostra badge, da anonimo no; blog invariato.
- [ ] **Step 5**: aggiornare memoria progetto (`insanitylab-stato-progetto.md`) con stato nuovo sistema.

---

## Appendice: inventario

Riferimento completo tag → valore → file: `docs/superpowers/plans/2026-07-05-inventario-contenuti.md`. I valori esatti si copiano SEMPRE dal file sorgente al momento dell'estrazione, non da quel documento. Conteggi: ~150 tag testo hardcoded nei template, ~200 stringhe da data file, ~60 slot immagine. Esclusioni motivate in "Deviazioni consapevoli".
