--- type: modulo parent: "Dropbox/adrian (sandbox)" --- # CLAUDE.md — PowerBricks (sandbox prototipo) **Versione:** v14 **Ultimo aggiornamento:** 2026-08-07 Eredita la Costituzione principale (`Dropbox/adrian/CLAUDE.md`). Solo contesto locale qui sotto. ## Cos'è Prototipo del query builder visuale PowerBricks — passo 1 (solo Anagrafica) validato con convinzione, ora in **passo 2** con l'aggiunta di Emesso (join + aggregazione), strategia completa in `archivio/Mauro/lavoro/lavoro-python-ecosistema/PowerBricks.md`. **Direzione strategica emersa 05/08/2026**: PowerBricks è di fatto un generatore di SQL parametrico — l'output naturale non è solo una tabella HTML ma può alimentare **una griglia in stile Linker** (MyICR_Suite), oggi in sola lettura. Vedi sezione "Griglia risultati PyQt6" sotto per l'implementazione. Vive fuori da `PYTHON/` deliberatamente: `PYTHON/` è scrivibile solo dall'istanza Ufficio, questa sandbox invece va scritta anche da Nave. Sincronizzata via Dropbox+git come il resto di `adrian/`. ## Stack Stesso pattern di `PYTHON/MyICR_Suite/app/modules/mediatrack/` (modello di riferimento dichiarato in MyICR_Suite/CLAUDE.md): **Flask + pywebview**, non browser puro — l'app si apre come finestra desktop nativa, Flask gira in thread interno su `127.0.0.1`. - **Flask** — unico backend, serve sia le pagine che l'API `/api/*` - **pywebview** — finestra nativa che incapsula il frontend Flask - **DuckDB** — query in-memory direttamente sul parquet Anagrafica, nessuna copia dati - **Blockly** (via CDN nel frontend, vendorizzare se serve lavorare offline) — blocchi `pb_query` (contenitore Anagrafica), `pb_gruppo`/`pb_filtro` (filtri Anagrafica), `pb_emesso` (contenitore Emesso, indipendente), `pb_gruppo_emesso`/`pb_filtro_emesso` (filtri Emesso), `pb_join` (ponte facoltativo, forza il tipo di join — vedi sezione dedicata sotto) Operatori supportati: `equals`, `not_equals`, `contains` (solo stringhe), `gt`/`lt`/`gte`/`lte` (solo numerici), `between` (numerici, valore `"v1,v2"`), `is_null`, `is_not_null`. Validazione lato backend per tipo di campo (`_build_filtro` in `backend/server.py`). **Selezione campi in output — modale su doppio click (05/08/2026)**: dopo tre tentativi scartati per tenere la selezione sempre visibile (checkbox, doppio elenco fisso, blocco Blockly nativo), la soluzione finale tiene il canvas Blockly pulito (solo filtri) e sposta la scelta dei campi in un **modale HTML** che si apre con doppio click sul blocco `pb_query` ("Tabella Anagrafica") — un vero pannello dedicato che compare solo quando serve, coerente con come Access/Power BI trattano la selezione campi (vedi `PowerBricks.md`). Meccanica: estensione Blockly `pb_query_output_modal` (`Blockly.Extensions.register`, in `app.js`) aggancia un listener DOM `dblclick` su `block.getSvgRoot()`; il modale riusa il doppio elenco "disponibili ↔ selezionati" con frecce sposta/riordina; la selezione si salva su `block.data` (stringa JSON, serializzata automaticamente da Blockly col resto del workspace — nessuna persistenza custom necessaria). ## Passo 2 — Emesso (05/08/2026) **Dati**: `emesso.parquet` (stesso `_PARQUET_DIR` di `prodotti.parquet`), 8,8M righe — molto più grande di Anagrafica (226k), conferma la relazione **1:molti** anticipata nella strategia (`PowerBricks.md`): un prodotto/edizione può avere decine/centinaia di righe, una per trasmissione. Chiave di join: `prodotto`+`edizione`, stessa di Anagrafica. Campi esposti in output (`EMESSO_FIELDS` in `backend/server.py`): `rete`, `data_emissione`, `ora_inizio`, `ora_fine`, `durata_netta`, `prima_visione`, `episodio`, `audience`, `share`, `durata_lorda`, `tipologia`, `fascia` — esclusi `prodotto`/`edizione` (chiavi di join, ridondanti in output) e `ncparpgm` (codice interno, non utile). **Emesso ora flessibile quanto Anagrafica (WHERE + SELECT), 05/08/2026**: dopo il primo giro solo-aggregazione, richiesta esplicita di Mauro di rendere Emesso pari ad Anagrafica — filtrabile con blocchi propri, non solo selezionabile in output. Aggiunti `pb_filtro_emesso` e `pb_gruppo_emesso` (stessa logica di `pb_filtro`/`pb_gruppo`, stesso set operatori `OPS`, campi presi da `ALL_EMESSO_FIELD_NAMES` invece di `ALL_FIELD_NAMES`), agganciati in un nuovo slot `FILTRI` dentro `pb_emesso` stesso. **Isolamento tra domini via `check` Blockly**: gli statement input e i blocchi dichiarano un tipo di connessione (`anagrafica_filtro` per `pb_query`/`pb_filtro`/`pb_gruppo`, `emesso_filtro` per `pb_emesso`/`pb_filtro_emesso`/`pb_gruppo_emesso`) — Blockly impedisce a livello di incastro di mescolare un filtro Anagrafica dentro lo slot filtri di Emesso o viceversa, non serve validarlo lato codice. **`pb_query`/`pb_emesso` sganciati, blocchi indipendenti sulla lavagna (05/08/2026)**: prima `pb_emesso` si agganciava in uno slot `EMESSO` dentro `pb_query` (nidificato). Corretto su richiesta di Mauro: sono due blocchi container separati, entrambi senza `previousStatement`/`nextStatement` (come `pb_query` già era) — viaggiano indipendenti sul workspace, non uno dentro l'altro. Se `pb_emesso` è presente da qualche parte sulla lavagna, si unisce **implicitamente** su `prodotto`+`edizione` (join fissa, nessuna scelta esplicita) — Mauro ha segnalato esplicitamente che in futuro potrebbe arrivare un blocco "ponte" per scegliere il tipo di join, ma non ora. Lato codice: `getEmessoConfig(workspace)` cerca il blocco `pb_emesso` con `workspace.getBlocksByType("pb_emesso", false)` invece di leggerlo da uno slot annidato di `pb_query` — il backend (join implicita su chiave fissa) non cambia, solo la sorgente della configurazione lato frontend. **Backend generico per tabella**: `_build_filtro`/`_build_node`/`_build_where` ora prendono `fields_dict` e `table_alias` come parametri invece di essere hardcoded su `FIELDS`/`"a"` — usati con `(FIELDS, "a")` per Anagrafica e `(EMESSO_FIELDS, None)` per Emesso (nessun alias necessario dentro la subquery Emesso, un'unica tabella in scope lì). Aggiunto anche il tipo `"float"` (per `share`) trattato come bucket numerico (`OPS_INT`) con cast `float()` invece di `int()`. **I filtri Emesso si applicano DENTRO la subquery**, prima di scegliere prima/ultima — "prima emissione su rete X" deve restringere alle emissioni su X e poi prendere la più vecchia tra quelle, non il contrario. Attenzione all'**ordine dei parametri SQL**: il JOIN (con gli eventuali placeholder dei filtri Emesso) compare nel testo SQL prima della WHERE Anagrafica — `_execute_query` passa `join_params + anagrafica_params` in quest'ordine a `con.execute()`. Verificato su dati reali da Nave: filtro Emesso semplice, gruppo OR, campo `float` con `between` — tutti sotto il secondo, nessuna regressione sulle query solo-Anagrafica. **Blocco `pb_emesso`** ("Tabella Emesso"): contenitore indipendente sulla lavagna (vedi sopra), opzionale — se assente, query solo su Anagrafica come nel passo 1. **Aggregazione rimandata a uno step successivo (05/08/2026)**: inizialmente il blocco aveva un dropdown `AGG` (prima/ultima/tutte le emissioni) integrato. Corretto su richiesta di Mauro: "Tabella Emesso" deve restare un blocco che dichiara solo la tabella (nome coerente: non più "Query Emesso — aggregazione..." ma "Tabella Emesso" secco) — l'aggregazione è un concetto separato, un blocco a sé quando verrà implementato. **Oggi nessuna aggregazione nel frontend**: il backend riceve `emesso` senza il campo `aggregation` e usa il default `"tutte"` (una riga per emissione) — la logica prima/ultima via window function resta nel backend (`_build_emesso_join`, invariata), pronta per quando un futuro blocco aggregazione la richiamerà esplicitamente passando `"aggregation": "prima"|"ultima"`. **LEFT JOIN di default, INNER se ci sono filtri Emesso (05/08/2026)**: gotcha reale trovato da Mauro testando il prototipo — con LEFT JOIN sempre attiva, un filtro su Emesso (es. `rete=I1`) non restringeva affatto i prodotti Anagrafica: i prodotti senza corrispondenza uscivano comunque, solo con le colonne Emesso a `NULL`, perché LEFT JOIN mantiene le righe di sinistra a prescindere dal match a destra. Un filtro sulla tabella joinata deve poter significare "solo chi corrisponde", non "arricchisci se puoi". Fix in `_build_emesso_join`: `join_type = "INNER JOIN" if emesso_clauses else "LEFT JOIN"` — con filtri Emesso presenti, INNER (solo prodotti con un'emissione che soddisfa il criterio); senza filtri, resta LEFT (Emesso arricchisce l'output senza restringere le righe Anagrafica, un prodotto senza emissioni resta comunque visibile con colonne Emesso a `NULL`). **Verificato su dati reali** (filtro `tipologia=FILM`): senza filtri Emesso, 471.494 righe (52.215 con `emesso_rete` `NULL` — prodotti senza emissioni, LEFT corretto); con filtro `rete=I1`, 30.690 righe, zero `NULL`, tutte effettivamente `I1` (INNER corretto). **Compromesso risolto con un blocco "ponte" esplicito (05/08/2026)**: il caso "mostrami tutti i prodotti, e se disponibile mostrami solo l'emissione su rete X" (LEFT JOIN + filtro solo per la visualizzazione, non per restringere) non era esprimibile con la sola regola automatica. Aggiunto `pb_join` — blocco indipendente, facoltativo, con una tendina (`TYPE`): **Automatico** (la regola sopra: INNER se ci sono filtri Emesso, LEFT altrimenti), **LEFT forzato** ("Tieni tutti gli Anagrafica"), **INNER forzato** ("Solo corrispondenze") — sovrascrive la scelta automatica a prescindere dalla presenza di filtri. Colore neutro grigio (`#757575`), coerente con la sua natura "ponte" tra domini, non appartiene a nessuna tabella specifica. Lato frontend: `getJoinType(workspace)` cerca `pb_join` con `getBlocksByType`, default `"auto"` se assente — stesso pattern di `getEmessoConfig`. Lato backend: `_parse_query_body` valida `join_type` (`auto`/`left`/`inner`, 400 se altro), `_build_emesso_join` lo usa per bypassare la regola automatica quando non è `"auto"`. Verificato su dati reali tutti e tre i casi (`auto`+filtro → INNER 30.690 righe 0 NULL; `left` forzato+filtro → LEFT 106.654 righe 75.964 NULL; `inner` forzato senza filtri → 419.279 righe 0 NULL) — i conteggi tornano esatti tra loro (es. `left`-`auto` = quanti prodotti hanno emissioni ma non su quella rete). Output Emesso selezionabile con lo stesso modale a doppio click di `pb_query` (generalizzato: `openCampiModal(block, allNames)` ora prende l'elenco campi come parametro, non più hardcoded su `ALL_FIELD_NAMES` — due estensioni Blockly separate, `pb_query_output_modal` e `pb_emesso_output_modal`, stessa UI). Le colonne Emesso in output sono prefissate `emesso_` (es. `emesso_rete`) per evitare collisioni con nomi di colonna già presenti in Anagrafica (es. `tipologia` esiste in entrambe le tabelle). **Gotcha backend risolto**: con la JOIN attiva, un nome di campo come `tipologia` esiste in entrambe le tabelle — DuckDB solleva un errore di colonna ambigua se non prefissato. Tutti i filtri Anagrafica (`_build_filtro`) ora usano il prefisso `a."campo"` esplicito (prima erano senza prefisso, andava bene perché la tabella era una sola). Verificato da Nave su dati reali: query con filtro Anagrafica + `prima`/`ultima`/`tutte` — tutte sotto il secondo (window function su 8,8M righe non è un problema di performance con DuckDB). Validazione errori confermata (aggregazione non valida → 400, campo Emesso non valido → 400). **Non ancora testato visivamente** su un display reale (Ufficio). ## Griglia risultati — migrata da PyQt6 a datagrid web (07/08/2026) `[CONFERMATO]` — sostituisce interamente la sezione "Griglia risultati PyQt6" sotto (lasciata per lo storico tecnico dei bug risolti, ma **non più il flusso attivo**). Nata da una riflessione più ampia su tutta la griglia risultati stile Linker (visualizzazione, il gestionale/data-entry resta fuori scope) — dettaglio completo della valutazione PyQt6 vs web e la scelta di Tabulator vs AG Grid in `archivio/Mauro/lavoro/lavoro-python-ecosistema/ PowerBricks.md`. **Componente**: [[datagrid]] (`sandbox/datagrid/`) — libreria riusabile standalone, non specifica di PowerBricks. Motivo esplicito di Mauro: la griglia è un candidato a diventare il componente condiviso per tutto MyICR (dove oggi vive la griglia Linker storica), non uno strumento usa-e-getta di questo prototipo. **Meccanica**: il vincolo "pywebview e PyQt6 non condividono lo stesso loop eventi" (motivo per cui la vecchia griglia girava come `subprocess.Popen` separato) **non si applica più** — il componente è puro JS, quindi i risultati si aprono in una **seconda finestra pywebview** (`webview.create_window`, stesso processo, nessun conflitto), non un processo a sé. Flusso: "Esegui query" → POST `/api/open_grid` (invariato lato frontend `app.js`) → il backend esegue la query, salva `{columns, rows}` in `_RESULTS_STORE` (dict in memoria, cap 20 con eviction FIFO — nessuna persistenza, un token vale finché il processo Flask resta vivo) sotto un token `uuid4`, poi chiama `webview.create_window(titolo, "http://127.0.0.1:5050/results.html?token=...", width=1100, height=700)`. `results.html` (nuovo, `frontend/`) legge il token dalla query string, fa `fetch('/api/results/')`, converte `{columns, rows}` (liste, stesso formato di `/api/query`) in oggetti riga per Tabulator, e inferisce il tipo colonna (`number`/`text`) guardando il primo valore non nullo di ogni colonna — euristica semplice, non lo schema tipato vero (`FIELDS`/`EMESSO_FIELDS`/...): sufficiente per sort/filtro di base, potrà affinarsi se servirà (es. quando arriverà il concetto di "cluster"/preset di campi, vedi `PowerBricks.md`). **Il componente datagrid non è copiato**: servito via una seconda static route Flask (`/vendor/datagrid/` → `sandbox/datagrid/src/`) — un solo sorgente di verità, PowerBricks lo consuma via URL come un consumer esterno qualunque. **Rimosso in questo giro**: `results_grid.py` (script PyQt6 standalone), dipendenza `PyQt6==6.7.0` da `requirements.txt`, `RESULTS_GRID_SCRIPT`/`subprocess`/`tempfile` da `backend/server.py`. `HOST`/`PORT` spostate da `run.py` a `backend/server.py` (servono anche lì per costruire l'URL della finestra risultati) — `run.py` ora le importa invece di duplicarle. **Griglia deliberatamente read-only per ora**: stessa scelta della vecchia PyQt6 (coerente con Linker in sola lettura) — il componente `datagrid` supporta editing di cella nativamente, ma `editable` non è passato in `results.html`. Riflette lo scope esplicito di Mauro (07/08/2026): solo visualizzazione, il gestionale/data-entry resta un capitolo a sé, volutamente rimandato. Verificato da Nave: `test_client()` per `/api/open_grid` (con `webview.create_window` mockato — chiamato con l'URL/token/dimensioni attesi), `/api/results/` (200 con dati, 404 su token inesistente), `/vendor/datagrid/datagrid.js` (200). `results.html` verificato con Playwright headless contro un server Flask reale con risultati precaricati: rendering righe, gestione token invalido (messaggio esplicito, nessun crash). **Non ancora verificata l'apertura reale della seconda finestra pywebview** (serve un display reale, headless non esercita `webview.start()`/`create_window` davvero) — prossimo test da fare su Ufficio. ## Campi derivati Anagrafica (IMDB/regista/attori) + label leggibili (07/08/2026) `[CONFERMATO]` — nato dalla replica del cluster Anagrafica di Linker (14 campi a intestazione arancione in `column_defs.py`, confermati da Mauro con screenshot reale). 11 dei 14 erano già in `FIELDS`; mancavano `IMDB`, `REGISTA`, `ATTORI`. **Decisione presa con Mauro**: niente nuova "Tabella Cast" standalone — `cast.parquet` ha una relazione 1:molti pesante con `prodotto` (fino a 1.369 righe, tutti i ruoli), esporlo come dominio a sé avrebbe richiesto la stessa complessità di aggregazione di Emesso/Diritti senza un bisogno reale: a Linker non serve la lista cast grezza, solo un riassunto già a 1 riga per prodotto. Aggiunti invece **tre campi derivati dentro Anagrafica stessa** (`imdb_codice`/`regista`/`attori`), stessa logica di `v_custom_anagr` in Linker (`PYTHON/MyICR_Suite/app/modules/linker/src/database/parquet_db.py:40-108`), riletta 1:1 non reinventata: `imdb_codice` da un LEFT JOIN univoco su `imdb.parquet.codice` (verificato: 110.773 righe = 110.773, nessun fan-out); `regista` dal primo `cast.parquet` con `ruolo='FRE'` ordinato per `progr_cast` (`QUALIFY ROW_NUMBER() ... = 1`); `attori` dai primi 3 `ruolo='C001'` concatenati (`STRING_AGG` ordinato per `progr_cast`). Implementato in `_anagrafica_base_sql()` (`backend/server.py`), sostituisce il semplice `read_parquet(prodotti)` come FROM base di `_execute_query` — nessuna deduplicazione per "prodotto" a differenza di Linker (PowerBricks tiene il grano prodotto+edizione intero, i tre derivati si ripetono identici su tutte le edizioni dello stesso prodotto, nessun problema perché sono già aggregati a monte). **Idea per il futuro, annotata non implementata**: REGISTA/ATTORI sono di fatto "cluster calcolati" (riassunto di più righe cast) — un domani potrebbero fare drill verso il dettaglio cast grezzo filtrato per ruolo. Non affrontato ora, solo segnato perché non vada perso. **Label leggibili stile Linker** (stesso giro, richiesta di Mauro: "decodificare i nomi campo come in Linker, nelle tendine e nella griglia"): `ANAGRAFICA_LABELS`/`EMESSO_LABELS`/ `BOXOFFICE_LABELS`/`DIRITTI_LABELS` in `backend/server.py`, mappano il nome campo grezzo alla label `column_defs.py` di Linker **solo dove la corrispondenza è certa** — es. `titolo_italiano` → `TI`, `ragsoc_distr` → `DISTRIBUTORE`, `data_debutto` → `DEBUTTO`. **Gemma esclusa deliberatamente** (`GEMMA_LABELS = {}`): le valutazioni `VAL_*` di Linker non hanno una corrispondenza inequivocabile con i campi `v_*`/`a_*` esposti qui senza verificare il codice che le costruisce — meglio nessuna label che una indovinata male. Ogni `/api/fields*` ora restituisce anche `"label"` (fallback al nome se assente dal dizionario). Nuovo endpoint `/api/labels` (dizionario piatto, chiavi = nomi colonna **in output**, quindi con prefisso dominio per i satelliti — es. `emesso_rete` → `RETE`) usato da `results.html` per le intestazioni di colonna della griglia. Frontend (`app.js`): `FIELD_LABELS` (dizionario globale nome→label, popolato da `/api/fields*` in `main()`) usato sia nelle tendine `FIELD` dei blocchi filtro sia nel modale di selezione output — fallback al nome grezzo se non mappato. Verificato su dati reali da Nave: query diretta su un prodotto dello screenshot condiviso da Mauro (3096645, "LIFE OF THE PARTY") — output esatto `imdb_codice='tt5619332'`, `regista='BEN FALCONE'`, `attori='MELISSA MCCARTHY, MATT WALSH, MOLLY GORDON'`, identico a Linker. `test_client()` per `/api/fields`/`/api/labels`. Playwright headless: tendina `FIELD` mostra `RIFER=prodotto`/`TI=titolo_italiano` (label mappate) e `edizione=edizione` (fallback per campi non mappati); intestazioni griglia risultati `RIFER, TI, IMDB, REGISTA, ATTORI` per una query con quei campi in output. **Non ancora verificato visivamente** su Ufficio. ## Default output Anagrafica = cluster Linker (07/08/2026) `[CONFERMATO]` — richiesta di Mauro: un blocco "Tabella Anagrafica" mai toccato nel modale selezione campi deve avere come default il cluster Linker (14 campi, stesso ordine), non "tutti i campi" come prima. `ANAGRAFICA_DEFAULT_FIELDS` in `app.js` (stesso ordine di `column_defs.py`). `getBlockOutputFields(block, allNames, defaultFields)` e `openCampiModal` accettano ora un terzo parametro opzionale `defaultFields` — se assente (Emesso/Boxoffice/ Diritti/Gemma, nessun cluster deciso per loro ancora) il comportamento resta invariato ("tutti i campi"). Il default si applica sia al modale (campi già preselezionati in "selezionati") sia a `getQueryPayload` (i campi effettivamente mandati al backend se il blocco non è mai stato aperto). Verificato con Playwright: workspace con solo il blocco `pb_query` di partenza (mai toccato) → `getQueryPayload(...).fields` produce esattamente i 14 campi nell'ordine atteso. ## Persistenza workspace Blockly (07/08/2026) `[CONFERMATO]` — richiesta di Mauro: non dover ricostruire la query manualmente ad ogni test. Autosave debounced (500ms, `workspace.addChangeListener`, stesso pattern del pannello pseudo-lingua) verso `POST /api/workspace`, che scrive `Blockly.serialization.workspaces.save(workspace)` su file locale (`.workspace_state.json`, root della sandbox, **mai in git** — dato di sessione, non sorgente, aggiunto a `.gitignore`). All'avvio, `GET /api/workspace` prova a ripristinare l'ultimo stato salvato (`Blockly.serialization.workspaces.load`); se assente (primo avvio, o file mai scritto), fallback al solito blocco "Tabella Anagrafica" di partenza. **Scelta deliberata: autosave ad ogni modifica, non salvataggio-su-chiusura**: Mauro ha chiesto "quando esco dall'applicativo mi salvi i blocchi" — implementato però come autosave continuo (debounced) invece di agganciarsi all'evento di chiusura pywebview: più semplice (nessun hook sul ciclo di vita della finestra) e più robusto (nessun rischio di perdere lo stato se l'app si chiude in modo brusco, crash o kill del processo — un salvataggio-su-chiusura per definizione non protegge da quel caso). Verificato con Playwright: file assente → `GET /api/workspace` restituisce `null` → workspace parte vuoto (blocco di partenza standard); aggiunto un blocco `pb_boxoffice` via API Blockly, atteso il debounce, contati i blocchi (2) → reload pagina → blocchi ancora 2, `pb_boxoffice` presente — stato ripristinato correttamente. File JSON ispezionato, contiene le posizioni e i tipi di blocco attesi. ## Griglia risultati — replica stile Linker (07/08/2026) `[CONFERMATO]` — richiesta esplicita di Mauro: "replicare quanto più possibile il layout del foglio Linker, stessi caratteri, stessi colori intestazioni". Il tema di base (font, riga compatta, cella selezionata Excel-style) vive nel componente condiviso `sandbox/datagrid/` (vedi `sandbox/datagrid/CLAUDE.md`, non duplicato qui). Quello che è specifico di PowerBricks è solo la **mappatura colore-header per dominio**, in `frontend/results.html` (`headerColorFor(name)`), stessa palette `HC` di `PYTHON/MyICR_Suite/app/modules/linker/src/gui/column_defs.py`: | Dominio (prefisso colonna output) | Colore | Hex | |---|---|---| | Anagrafica (nessun prefisso) | arancione | `#FFB347` | | `imdb_codice` (caso speciale) | ciano | `#00BFFF` | | `emesso_*` | arancione (Linker: "generaliste") | `#FFB347` | | `boxoffice_*` | giallo | `#FFFF00` | | `diritti_*` | verde | `#90EE90` | | `gemma_*` | giallo pastello (Linker: "Valutazioni") | `#FFF59D` | A differenza di Linker, ogni dominio qui copre l'intera tabella (nessuna colonna "non classificata" — Linker mostra solo un sottoinsieme curato per dominio, PowerBricks espone tutti i campi disponibili quindi tutti condividono il colore del loro dominio). Nessuna distinzione "Emesso tematiche" (viola in Linker): PowerBricks ha un solo dominio Emesso, non lo split generaliste/tematiche. Verificato con Playwright: colori header confrontati byte-per-byte coi valori RGB attesi su una query con 4 domini contemporanei (Anagrafica+IMDB, Boxoffice, Diritti), font Calibri applicato, altezza filtri header allineata alle righe dati (corretto dopo un primo giro troppo alto). **Non ancora verificato visivamente** su Ufficio. **Bug reale trovato e corretto (07/08/2026) — scrollbar verticale spuria**: presente anche con poche righe che non riempivano la pagina, segnalato da Mauro. Causa: non la griglia stessa (Tabulator gestisce correttamente il proprio scroll interno), ma la **pagina** attorno — `calc(100vh - 40px)` più i margini di `#status`/`#grid` superava di pochi pixel l'altezza del viewport (misurato: 705px di contenuto contro 700px disponibili), abbastanza per far comparire lo scroll del documento. Fix: `html, body { overflow: hidden }` — la griglia resta l'unica area scrollabile (verificato: con 100 righe lo scroll interno funziona regolarmente, 2244px di contenuto contro 612px visibili). **Intestazioni colonna centrate** (07/08/2026, richiesta di Mauro): `text-align: center` su `.tabulator-col-title`, nel tema di base del componente `datagrid` (non specifico di PowerBricks — beneficia chiunque altro lo consumi). **Colonne a larghezza fissa, stesso valore esatto di Linker (07/08/2026)**: di default il componente `datagrid` dimensiona le colonne sul contenuto (`fitDataFill`, prima `fitDataStretch` — cambiato perché allungava comunque l'ultima colonna, contraddicendo l'idea di "fisse") — Mauro ha chiesto di renderle fisse. Stesso principio delle label: nuovi dizionari `ANAGRAFICA_WIDTHS`/`EMESSO_WIDTHS`/`BOXOFFICE_WIDTHS`/`DIRITTI_WIDTHS` in `backend/server.py` (px esatti da `column_defs.py`, es. `titolo_italiano` → 200, `prodotto` → 61), `GEMMA_WIDTHS = {}` (stesso motivo di `GEMMA_LABELS`: nessuna corrispondenza certa). Nuovo endpoint `/api/widths` (stesso pattern prefisso-dominio di `/api/labels`), usato da `results.html` con fallback a 100px per i campi senza un valore Linker noto. Verificato con Playwright: 14 colonne del cluster Anagrafica, larghezze in pixel confrontate una per una coi valori attesi — corrispondenza esatta. ## Griglia risultati PyQt6 (05/08/2026) **Perché un processo separato**: `pywebview` (finestra Blockly) e PyQt6 sono due toolkit GUI diversi, ognuno col proprio loop eventi — non possono coesistere nello stesso processo senza conflitti. Soluzione: `results_grid.py` (root della sandbox, sibling di `backend/`/`frontend/`) è uno script PyQt6 standalone, lanciato come **sottoprocesso** (`subprocess.Popen`) dal backend Flask. **Flusso (semplificato 05/08/2026)**: il pulsante è uno solo, **"Esegui query"** → POST `/api/open_grid` direttamente (niente tabella HTML intermedia, niente pulsante separato "Apri in griglia" — rimossi su richiesta esplicita: il risultato vive solo nella griglia PyQt6, non nella finestra del query builder) → il backend esegue la query, scrive `{columns, rows}` in un file JSON temporaneo (`tempfile.NamedTemporaryFile`), lancia `subprocess.Popen([sys.executable, results_grid.py, tmp_path])` e risponde subito (non aspetta la chiusura della finestra). `results_grid.py` legge il JSON, lo cancella (`unlink()`), poi apre una `QTableWidget` read-only (`NoEditTriggers`) — editing eventuale solo in futuro, oggi è deliberatamente sola lettura come Linker. `/api/query` resta nel backend (limite 1000, usato dai test rapidi da terminale/`test_client`) ma non è più chiamato dal frontend — solo `/api/open_grid` (limite 5000) è nel percorso reale. **Refactor backend**: `_parse_query_body()`/`_execute_query()` estratte da `/api/query` e condivise con `/api/open_grid` — stessa validazione, stesso motore DuckDB, limite massimo diverso per endpoint. Dipendenza `PyQt6==6.7.0` (pin standard di casa) in `requirements.txt`. Verificato da Nave con `QT_QPA_PLATFORM=offscreen` (headless, nessun display) — costruzione finestra/tabella corretta, file temporaneo pulito correttamente prima di entrare nel loop eventi Qt. **Non ancora verificato visivamente** (serve un display reale, Ufficio). **`results_grid.py` resta volutamente semplice, nessuno stile custom**: un primo tentativo aveva applicato la palette MediaTrack (QSS + `app.setStyle("Fusion")`, necessario perché lo stile nativo Windows ignora parzialmente i QSS su `QHeaderView`) proprio a questo foglio — poi corretto: la richiesta era vestire il **query builder**, non il foglio risultati, che va lasciato semplice. Non applicare styling qui senza indicazione esplicita. ## Toolbox a categorie per dominio (06/08/2026) `[CONFERMATO]` — Mauro ha notato guardando lo screenshot col pattern "due domini attivi" che il toolbox flat (tutti i blocchi di tutti i domini sempre visibili in un unico elenco) non avrebbe retto con Diritti/Boxoffice aggiunti (4 domini × ~3 blocchi = affollamento). Discusse insieme alcune opzioni (categorie collassabili, due pannelli custom sincronizzati tabella↔blocchi) prima di convergere su una soluzione nativa: il **category toolbox** di Blockly (lo stesso pattern di "Logic/Loops/Math" nei playground ufficiali) è già di fatto un sistema a due pannelli — elenco categorie stretto a sinistra, flyout a comparsa coi blocchi della categoria selezionata — senza bisogno di codice custom per sincronizzare stato tra due pannelli. **Implementato**: `frontend/index.html`, toolbox XML convertito da `` flat a `` per dominio — "Anagrafica" (`#C62828`), "Emesso" (`#F9A825`), "Strumenti" (`#757575`, oggi solo `pb_join`) — stessi colori già in uso per i blocchi. Quando arriveranno Diritti/Boxoffice, bastano due categorie in più con lo stesso pattern. **Bottoni nel flyout invece di solo blocchi trascinabili**: Blockly supporta elementi `