@llmnative/react 1.48.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt CHANGED
@@ -1,1487 +1,1526 @@
1
- # @llmnative/react — Riferimento completo
2
-
3
- Framework React AI-first per generazione deterministica di UI. Data-driven di default, schema-driven opzionale.
4
- Versione 1.0.0 — Apache-2.0
5
-
6
- ```bash
7
- npm install @llmnative/react
8
- import '@llmnative/react/dist/index.css'
9
- ```
10
-
11
- ---
12
-
13
- ## Regole fondamentali
14
-
15
- - Tutti gli import da `'@llmnative/react'` — mai da sottodirectory
16
- - I provider sono iniettati via `<App providers={...}>` — mai importati direttamente nei componenti
17
- - Usare `useDataProvider()`, `useAuthProvider()`, ecc. nei componenti, non istanze dirette
18
- - TypeScript strict: no `any` — usare `unknown`, `Record<string, unknown>`, tipi specifici
19
- - Naming props: `view` (presentazione contenuto), `appearance` (guscio visuale), `layout` (disposizione spaziale), `mode` (comportamento operativo), `variant` (tono semantico), `position` (ancoraggio), `size` (scala)
20
-
21
- ---
22
-
23
- ## App — Entry point
24
-
25
- ```tsx
26
- <App
27
- providers={{
28
- firebase: { // FirebaseConfig — tutte le chiavi flat
29
- apiKey, authDomain, databaseURL, projectId,
30
- storageBucket, messagingSenderId, appId,
31
- },
32
- supabase: { url, anonKey },
33
- google: { clientId, scope }, // flat — NON { oAuth2: { clientId } }
34
- dropbox: { clientId }, // DropboxConfig
35
- mock: { data: mockData },
36
- ai: { // AIConfig — NON una prop separata di <App>
37
- openaiApiKey, geminiApiKey, anthropicApiKey,
38
- deepSeekApiKey, mistralApiKey, openRouterApiKey,
39
- groqApiKey,
40
- cloudflare: { apiToken, accountId }, // Cloudflare Workers AI
41
- },
42
- proxy: { enable: true, route: '/api/proxy' },
43
- custom: { data: { myAdapter: new MyDataProvider() } },
44
- services: {
45
- data: 'dbRealtime', // DataDriverName
46
- storage: 'firestorage', // StorageDriverName | null
47
- auth: 'googleAuth', // AuthDriverName | null
48
- email: 'gmail', // EmailDriverName | null
49
- ai: 'openai', // AIDriverName | null
50
- credentials: 'googleServiceAccount',
51
- },
52
- }}
53
- iconProvider="lucide" // 'lucide' | 'phosphor' | AppIconProviderConfig
54
- themeProvider="default" // 'default' | 'flat' | 'cyber' | AppThemeProviderConfig
55
- menuConfig={menuConfig}
56
- importPage={(path) => import(path)}
57
- LayoutDefault={MyLayout} // layout wrapper custom (opzionale)
58
- appName="MyApp" // nome app (opzionale)
59
- i18n={{ locale: 'it', translations: { ... } }}
60
- contextProviders={MyContextProvider}
61
- errorReportUrl="/api/errors"
62
- debug={import.meta.env.DEV}
63
- />
64
- ```
65
-
66
- ### Testi dei componenti (i18n)
67
-
68
- I testi fissi dei componenti vengono da `I18nDict` e seguono `i18n.locale` (en, it, de, ru, ar, zh
69
- inclusi): un testo si cambia con `i18n.translations`, non nel codice. Namespace dei componenti:
70
- `notifications` (`markAllRead`, `empty`, `emptyHint`), `errorBoundary` (schermata d'errore; fuori da
71
- `<I18nProvider>`, come l'`ErrorBoundary` radice di `<App>`, resta in inglese), `themeSwitcher`,
72
- `imageField` (anche i dialoghi immagine di `RichText`), `richText`, `tabDynamic` (`addTab`),
73
- `breadcrumbs` (`label`, l'`aria-label` del `nav`), `grid` (`tableView`, `galleryView`, `columns`,
74
- `fields`), `prompt` (anche `Chatbot`: `role`, `language`, `voice`, `style`, `temperature`, `stop`),
75
- `chat` (`ChatReasoning` e `ChatQuestions`: `reasoning`, `reasoningFor`, `other`, `review`, `submit`, …;
76
- il codice di `ChatMarkdown` usa `code`).
77
-
78
- ```tsx
79
- <App i18n={{ locale: 'it', translations: { it: { notifications: { emptyHint: 'Niente di nuovo' } } } }} />
80
- ```
81
-
82
- ### AppThemeProviderConfig
83
- ```ts
84
- {
85
- mode?: 'light' | 'dark' | 'system';
86
- theme: string;
87
- themes?: Record<string, {
88
- preset?: { colors?: ColorScale; radius?: number; font?: string };
89
- motion?: MotionRegistry;
90
- components?: Record<string, Partial<ThemeComponent>>;
91
- }>;
92
- themeOverride?: Record<string, Partial<ThemeComponent>>;
93
- }
94
- ```
95
-
96
- Built-in themes: `default`, `flat`, `cyber`, `vscode-dark-modern`, `vscode-light-modern`, `vscode-dark-plus`, `vscode-light-plus`, `tokyo-night`, `tokyo-night-light`.
97
- The VS Code and Tokyo Night themes are compiled from the original VS Code theme JSON (`scripts/themes/vscode`), filling the keys a theme does not list with VS Code's color registry defaults; each one has both modes (`vscode-dark-*` starts dark, `vscode-light-*` light). Status colors (destructive/success/warning/info) stay the framework's.
98
-
99
- `ColorScale` tokens: `background`, `foreground`, `canvas` (page floor behind cards and panels, `bg-canvas`; falls back to `background`), `card`, `popover`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `success`, `warning`, `info` (each with its `*Foreground`), `border`, `input` (border of form fields), `ring`. A consumer bridging the tokens in its own `@theme inline` adds `--color-canvas: hsl(var(--rf-canvas, var(--rf-background)));`.
100
-
101
- ---
102
-
103
- ## Provider System
104
-
105
- ### Matrix completa
106
-
107
- | Servizio | Interfaccia | Driver | Config key | Obbligatorio |
108
- |---|---|---|---|---|
109
- | data | `DataProviderAdapter` | `mock`, `dbRealtime`, `firestoreDb`, `supabaseDb` | `providers.mock/firebase/supabase` | sì (fallback: mock) |
110
- | storage | `StorageProviderAdapter` | `firestorage`, `supabaseStorage` | `providers.firebase/supabase` | no |
111
- | auth | `AuthProviderAdapter` | `googleAuth`, `firebaseAuth`, `supabaseAuth`, `dropboxAuth`, `githubAuth` | `providers.google/firebase/supabase/dropbox/github` | sì (fallback: googleAuth) |
112
- | email | `EmailProviderAdapter` | `gmail` | `providers.google` | no |
113
- | ai | `AIProviderAdapter` | `openai`, `gemini`, `anthropic`, `deepseek`, `mistral`, `openrouter`, `opencode`, `opencode-go`, `cloudflare`, `groq` | `Config.tsx AIConfig` | no |
114
- | credentials | `CredentialsAdapter` | `googleServiceAccount` | `providers.google.serviceAccount` | no |
115
-
116
- ### Hooks
117
-
118
- ```ts
119
- const data = useDataProvider() // DataProviderAdapter | null
120
- const storage = useStorageProvider() // StorageProviderAdapter | null
121
- const auth = useAuthProvider() // AuthProviderAdapter | null
122
- const email = useEmailProvider() // EmailProviderAdapter | null
123
- const ai = useAIProvider() // AIProviderAdapter | null
124
- const creds = useCredentialsProvider() // CredentialsAdapter | null
125
-
126
- // Named provider (multi-provider setup)
127
- const supabase = useDataProvider('supabase')
128
- ```
129
-
130
- ### GitHub (`githubAuth`, sessione sul server dell'app)
131
-
132
- GitHub richiede il client secret per scambiare il codice: scambio, token e sessione stanno sul server
133
- dell'app, il browser vede solo il profilo. Config `providers.github = { sessionUrl: '/api/auth/github' }`,
134
- driver `auth: 'githubAuth'`; funziona con `AuthButton` (pulsante e `aspect="avatar"`) e
135
- `useAuthProvider('githubAuth')`. Il server espone sotto `sessionUrl`:
136
-
137
- | Rotta | Risposta |
138
- |---|---|
139
- | `GET /login?returnTo=<path>[&scope=…]` | redirect a GitHub, poi di nuovo a `<path>` |
140
- | `GET /me` | `{ login, name, avatarUrl, email }`, 401 senza sessione |
141
- | `POST /logout` | chiude la sessione |
142
-
143
- Lato server, da `@llmnative/react/server`:
144
-
145
- ```ts
146
- const oauth = createGitHubOAuth({ clientId, clientSecret, scopes: ['read:org'] })
147
- oauth.authorizeUrl({ state, redirectUri, codeChallenge }) // PKCE S256
148
- const { accessToken } = await oauth.exchangeCode({ code, redirectUri, codeVerifier }) // GitHubOAuthError se rifiutato
149
- await oauth.getUser(accessToken) // { login, name, avatarUrl, email }
150
- await oauth.getOrganizations(accessToken) // [{ login, avatarUrl, role: 'admin' | 'member' }]
151
- ```
152
-
153
- ### Interfacce principali
154
-
155
- ```ts
156
- // DataProviderAdapter
157
- read(path, options?) / set(path, data) / update(path, data) / remove(path)
158
- subscribe(path, callback: (records: RecordArray) => void): () => void
159
- count?(path, where?): Promise<number>
160
-
161
- // StorageProviderAdapter
162
- upload(file, path): Promise<string|undefined>
163
- getURL(path) / download(path): Promise<Blob|undefined> / delete(path): Promise<boolean>
164
-
165
- // AuthProviderAdapter
166
- signIn(intent?, options?) / signOut() / getUser(): UserProfile|null
167
- onAuthChange(callback) / getAccessToken(): Promise<string|null>
168
- ```
169
-
170
- ### Auth sign-in methods
171
-
172
- | method | Firebase | Supabase | Google |
173
- |---|---|---|---|
174
- | `password` | ✅ | ✅ | — |
175
- | `anonymous` | ✅ | ✅ | — |
176
- | `magic_link` | — | ✅ | — |
177
- | `oauth` (github, apple…) | ✅ | ✅ | — |
178
- | GIS + scoped token | — | — | ✅ |
179
-
180
- ```ts
181
- await auth.signIn({ method: 'password', email: '...', password: '...' })
182
- await auth.signIn({ method: 'oauth', provider: 'github' })
183
- // Google: token per API scope
184
- const token = await auth.getAccessToken('https://www.googleapis.com/auth/gmail.send')
185
- ```
186
-
187
- ### CredentialsAdapter (Google Service Account)
188
- ```tsx
189
- // Chiama Google APIs lato browser senza Node.js
190
- <App providers={{ google: { serviceAccount: { clientEmail, privateKey } }, services: { credentials: 'googleServiceAccount' } }} />
191
-
192
- const creds = useCredentialsProvider()
193
- const token = await creds.getToken('https://www.googleapis.com/auth/solar')
194
- ```
195
-
196
- ### Provider custom
197
- ```ts
198
- import { DataProviderAdapter, RecordArray } from '@llmnative/react'
199
-
200
- export class RestDataProvider implements DataProviderAdapter {
201
- async read(path: string): Promise<unknown> { ... }
202
- async set(path: string, data: object): Promise<void> { ... }
203
- async update(path: string, data: object): Promise<void> { ... }
204
- async remove(path: string): Promise<void> { ... }
205
- subscribe(path: string | undefined, callback: (records: RecordArray) => void): () => void {
206
- return () => undefined
207
- }
208
- }
209
- ```
210
-
211
- ---
212
-
213
- ## Grid — CRUD table/gallery
214
-
215
- ```tsx
216
- <Grid
217
- path="/users" // DataProvider-backed — oppure records={array} per in-memory
218
- columns={[
219
- { key: 'name', label: 'Nome', sortable: true },
220
- { key: 'email', label: 'Email' },
221
- { key: 'role', label: 'Ruolo', render: ({ value }) => <Badge>{value}</Badge> },
222
- { key: 'date', label: 'Data', render: 'date' },
223
- ]}
224
- actions={['add', 'edit', 'delete']} // oppure Record<string, GridAction>
225
- view="table" // 'table' | 'gallery'
226
- pagination={{ limit: 20 }}
227
- groupBy="status"
228
- sortable={{ field: 'name', dir: 'asc' }}
229
- form={<FormFields />}
230
- onSave={async ({ record, action }) => record._key}
231
- onComplete={async ({ record, action }) => true}
232
- selection={{ mode: 'multiple', onChange: (sel) => {} }}
233
- reorderable
234
- onReorder={(records, meta) => {}}
235
- header={(ctx) => <div>{ctx.title}</div>}
236
- footer={(ctx) => <div>{ctx.records.length} records</div>}
237
- />
238
- ```
239
-
240
- | Prop | Tipo | Note |
241
- |---|---|---|---|
242
- | `path` | `string \| "fromUrl"` | Path provider-backed |
243
- | `records` | `RecordArray` | Dati in memoria (alternativa a path) |
244
- | `columns` | `GridColumn[]` | `{ key, label, sortable?, render?, className? }` |
245
- | `actions` | `Array<'add'\|'edit'\|'delete'> \| Record<string, GridAction>` | |
246
- | `view` | `'table' \| 'gallery'` | |
247
- | `groupBy` | `string \| string[]` | |
248
- | `pagination` | `PaginationParams` | `{ limit, page?, maxPageButtons?, align?, sticky? }` |
249
- | `sortable` | `boolean \| OrderConfig` | |
250
- | `selection` | `false \| 'single' \| 'multiple' \| GridSelectionConfig` | |
251
- | `form` | `ReactElement \| ((ctx) => ReactNode)` | Form condiviso per CRUD |
252
- | `onSave` | `GridMutationSaveHandler` | `async ({ record, action }) => string` |
253
- | `onDelete` | `GridMutationDeleteHandler` | `async ({ record }) => string` |
254
- | `onComplete` | `GridAfterActionHandler` | `async ({ record, action }) => boolean` |
255
- | `onRowClick` | `(record) => void` | |
256
- | `reorderable` | `boolean` | Abilita drag & drop righe |
257
- | `onReorder` | `GridReorderHandler` | `(records, meta) => void` |
258
- | `onLoad` | `(records) => TRecord[]` | Trasforma records dopo il fetch |
259
- | `editDeepLink` | `boolean` | Sincronizza modal edit con URL |
260
- | `audit` | `boolean` | Logga mutazioni |
261
- | `sticky` | `'top' \| 'bottom'` | Header/footer sticky |
262
- | `wrapperClassName` | `string` | CSS wrapper esterno |
263
- | `loading` | `boolean` | Stato loading override |
264
- | `title` | `ReactNode` | Titolo header grid |
265
- | `before` / `after` | `ReactNode` | Slot prima/dopo il contenuto |
266
- | `header` / `footer` | `ReactNode \| ((ctx) => ReactNode)` | Header/footer custom |
267
- | `searchable` | `boolean \| GridSearchConfig` | Search box nell'header default — `{ fields?, placeholder? }` |
268
- | `filters` | `GridFilterConfig[]` | Checkbox toggle nell'header default, applicati PRIMA di `searchable` — `{ key, label, defaultValue?, predicate(record, value) }` |
269
-
270
- **GridAction kind:** `'modal'` (default), `'route'`, `'external'`, `'inline'`, `'delete'`
271
-
272
- ---
273
-
274
- ## Form — form CRUD con lifecycle
275
-
276
- ```tsx
277
- <Form
278
- path="/products"
279
- appearance="card" // 'card' | 'empty'
280
- showBack
281
- defaultValues={{ status: 'draft' }}
282
- keyGenerator={() => `prod_${Date.now()}`}
283
- onLoad={(data) => { /* side-effect hook, non trasforma */ }}
284
- onSave={async ({ record, action }) => { /* return storagePath or undefined */ }}
285
- onDelete={async ({ record }) => { /* confirm */ }}
286
- onComplete={async ({ action }) => { if (action === 'save') navigate('/products') }}
287
- >
288
- <Input name="title" label="Titolo" required />
289
- <Input name="price" label="Prezzo" type="number" />
290
- <Input name="address.city" label="Città" /> {/* oggetto annidato */}
291
- <Repeat name="tags" defaultLength={1}>
292
- {(i) => <Input name={`tags.${i}`} label={`Tag ${i+1}`} />}
293
- </Repeat>
294
- </Form>
295
- ```
296
-
297
- | Prop | Tipo | Note |
298
- |---|---|---|---|
299
- | `path` | `string` | Path provider-backed |
300
- | `appearance` | `'card' \| 'empty'` | Guscio visuale |
301
- | `controller` | `FormController` | Controller condiviso per bottoni custom esterni |
302
- | `showBack` | `boolean` | Pulsante Back nel footer |
303
- | `defaultValues` | `RecordProps` | Valori iniziali per nuovo record |
304
- | `keyGenerator` | `(record) => string` | Generatore chiave primaria custom |
305
- | `draftBucket` | `string` | Bucket stabile che isola la bozza locale; omettilo per disabilitare la persistenza |
306
- | `noticeAnchorRef` | `React.RefObject<HTMLElement>` | Ancoraggio sticky per notifiche |
307
- | `showNotice` | `boolean` | Mostra notifiche save/delete (default `true`) |
308
- | `requireChanges` | `boolean` | Default `true`: senza un campo cambiato il salvataggio dice "No changes to save" ed è disabilitato. `false` = form d'azione (CR-117): i valori iniziali sono già un invio valido (es. avviare una release con la versione proposta), `save()` valida e chiama `onSave` anche senza modifiche; `isDirty` dice comunque se qualcosa è cambiato |
309
- | `children` | `ReactNode \| ((fields) => ReactNode) \| (({ record }) => ReactNode)` | Contenuto form |
310
- | `header` | `ReactNode` | Contenuto header custom |
311
- | `footer` | `ReactNode` | Contenuto footer custom |
312
- | `wrapperClassName` | `string` | CSS wrapper esterno |
313
- | `headerClassName` | `string` | CSS header |
314
- | `className` | `string` | CSS body form |
315
- | `footerClassName` | `string` | CSS footer |
316
- | `log` | `boolean` | Logga ogni modifica su console |
317
- | `onLoad` | `(record) => void` | Side-effect dopo il caricamento (non trasforma) |
318
- | `onRecordChange` | `(record) => void` | Cambio campo in tempo reale |
319
- | `onSave` | `FormSaveHandler` | `async ({ record, prevRecord?, storagePath?, action }) => string \| undefined` |
320
- | `onDelete` | `FormDeleteHandler` | `async ({ record }) => string \| undefined` |
321
- | `onComplete` | `FormFinallyHandler` | `async ({ record, action }) => boolean` |
322
-
323
- **Form controller** — stato e azioni native del form accessibili da qualsiasi punto della pagina.
324
-
325
- Due hook:
326
-
327
- | Hook | Dove usarlo | Cosa restituisce |
328
- |---|---|---|
329
- | `useFormController(controller?)` | Ovunque (anche fuori dal form) | Crea o si aggancia a un `FormController`. Senza argomenti ne crea uno locale stabile. Con un `controller` esistente ci si aggancia. |
330
- | `useFormActions()` | Solo dentro `<Form>` (legge dal context) | Restituisce lo stesso `FormController` del form genitore. Lancia errore se usato fuori. |
331
-
332
- Esempio — bottone custom fuori dal form:
333
- ```tsx
334
- const form = useFormController();
335
-
336
- <ActionButton disabled={form.saveDisabled} onClick={() => { void form.save(); }} />
337
- <Form controller={form} draftBucket="workspace/acme" onSave={...} />
338
- ```
339
-
340
- Esempio — form d'azione, i valori proposti si inviano anche senza modificarli (CR-117):
341
- ```tsx
342
- const form = useFormController();
343
-
344
- <LoadingButton label="Avvia la release" onClick={() => form.save()} />
345
- <Form controller={form} appearance="empty" requireChanges={false} defaultValues={{ version: '1.4.0', notes }} onSave={async ({ record }) => { await start(record); return undefined; }}>
346
- <Input name="version" label="Versione" required />
347
- </Form>
348
- ```
349
-
350
- Esempio — uso dentro il form (senza passare controller):
351
- ```tsx
352
- function MySaveButton() {
353
- const form = useFormActions();
354
- return <LoadingButton disabled={form.saveDisabled} loading={form.isSaving} onClick={form.save} />;
355
- }
356
- ```
357
-
358
- Stato esposto dal controller: `record`, `isNewRecord`, `isDirty`, `canSave`, `canDelete`, `isSaving`, `isDeleting`, `saveDisabled`, `deleteDisabled`, `hasDraft`, `draftStatus`, `errors`, `hasErrors`.
359
-
360
- Azioni: `save()`, `delete()`, `reset()`, `restoreDraft()`, `discardDraft()`.
361
-
362
- ---
363
-
364
- ## Campi form
365
-
366
- ### Input
367
- ```tsx
368
- <Input name="x" label="..." required /> // text (default)
369
- <Input name="price" type="number" min={0} max={100} step={0.01} />
370
- <Input name="email" type="email" />
371
- <Input name="pwd" type="password" />
372
- <Input name="date" type="date" />
373
- <Input name="color" type="color" />
374
- <TextArea name="bio" minHeight={96} />
375
- <Checkbox name="active" valueChecked="yes" />
376
- <Switch name="gate" label="Passa dalla CI" disabled title="L'agente non committa" /> // CR-116: disabled (non cambia né con il clic né da tastiera), title anche sull'etichetta: il motivo al passaggio del mouse
377
- ```
378
- `Input` props: `name`(req) `label` `type` `required` `disabled` `placeholder` `min` `max` `step` `validator:(v)=>string|undefined`
379
- `type`: `'text' | 'number' | 'email' | 'password' | 'color' | 'date' | 'time' | 'datetime-local' | 'week' | 'month' | 'range' | 'checkbox' | 'radio' | 'url'`
380
-
381
- **NOTA**: il tipo HTML si passa con `type`. `inputType` e i sotto-componenti `Input.Number`/`Input.Email`/`Input.Date` non esistono.
382
-
383
- **Label come nodo**: `label` di tutti i campi di inserimento (`Input`, `TextArea`, `Checkbox`, `Select`, `Autocomplete`, `Checklist`, `Upload*`, `ImageField`, `CodeEditor`, `RichText`, `AddressAutocomplete`, `UploadCSV`, `Prompt`) accetta una stringa o un nodo React (tipo `FieldLabel`), disegnato dentro la `<label>` prima dell'asterisco di obbligatorio. Dove serve un testo (messaggio "obbligatorio", `aria-label`) si usa il testo del nodo, ricavabile anche con `labelText(node)`.
384
- ```tsx
385
- <Select name="skill" required options={skills} label={<>Skill <ActionButton icon="info" ariaLabel="Apri la skill" variant="link" onClick={openSkill} /></>} />
386
- ```
387
-
388
- ### Select / Autocomplete / Checklist
389
- ```tsx
390
- <Select name="role" options={[{ label: 'Admin', value: 'admin' }]} />
391
- <Select name="categoryId" optionsSource={{ path: '/categories', labelField: 'name', valueField: '_key' }} />
392
- <Autocomplete name="userId" optionsSource={{ path: '/users', labelField: 'email' }} maxItems={1} />
393
- <Checklist name="tags" options={['a', 'b', 'c']} />
394
- ```
395
-
396
- | Prop | Tipo | Note |
397
- |---|---|---|
398
- | `options` | `Option[] \| string[] \| number[]` | |
399
- | `optionsSource` | `{ path, labelField, valueField?, where?, order?, fieldMap?, onLoad? }` | Carica da provider |
400
- | `order` | `{ field: 'label' \| 'value', dir: 'asc' \| 'desc' }` | |
401
- | `placeholderOption` | `Option` | Solo Select |
402
- | `placeholder` | `string` | Solo Autocomplete |
403
- | `creatable` | `boolean` | Solo Autocomplete — crea nuovi valori |
404
- | `onCreate` | `(value) => void \| boolean \| Promise<void \| boolean>` | Solo Autocomplete — restituire `false` per non selezionare il valore creato (es. aprire un modulo di creazione) |
405
- | `listClassName` / `optionClassName` | `string` | Solo Autocomplete — classi della lista e delle voci (anche da tema `Autocomplete.listClassName` / `optionClassName`) |
406
- | `minItems` / `maxItems` | `number` | Solo Autocomplete |
407
- | `itemClassName` | `string` | Solo Checklist |
408
- | `validator` | `(value) => string \| undefined` | Tutte le varianti |
409
- | `readOnlyAfterSet` | `boolean` | readonly dopo primo valore |
410
- | `disabled` | `boolean` | |
411
- | `title` | `string` | |
412
- | `feedback` | `string` | Messaggio di errore/suggerimento |
413
-
414
- `Autocomplete` è una combobox accessibile: la lista (in un portal, larga quanto il campo, stile popover del tema) mostra solo le label e filtra per label/value senza distinzione di maiuscole e accenti; i chip mostrano la label; tastiera: frecce, Invio, Esc (chiude solo la lista, non il `Modal`), Tab, Backspace su campo vuoto rimuove l'ultimo valore. Con `creatable` compare la voce "Crea «testo»". Il valore salvato è sempre l'array dei `value`. `Autocomplete` e `Checklist` sono export nominati: `Select.Autocomplete`/`Select.Checklist` non esistono.
415
-
416
- ### UploadImage / UploadDocument / UploadCSV
417
- ```tsx
418
- <UploadImage name="cover" label="Copertina" /> {/* con crop editor */}
419
- <UploadDocument name="pdf" label="PDF" />
420
- <UploadCSV name="data" label="Importa CSV" /> {/* con preview tabellare */}
421
- ```
422
-
423
- **UploadImageProps** (eredita FormFieldProps):
424
- | Prop | Tipo | Note |
425
- |---|---|---|
426
- | `name` | `string` (required) | |
427
- | `uploadPath` | `string` | Path storage per upload automatico |
428
- | `accept` | `string` | MIME types accettati |
429
- | `editable` | `boolean` | Abilita crop editor |
430
- | `multiple` | `boolean` | Multipla selezione |
431
- | `max` | `number` | Numero massimo file |
432
- | `feedback` | `string` | Testo placeholder drop zone |
433
- | `srcsetWidths` | `number[]` | Larghezze varianti responsive |
434
- | `previewHeight` / `previewWidth` | `number` | Dimensioni preview |
435
-
436
- **UploadDocumentProps** (eredita FormFieldProps): `name`, `uploadPath`, `accept`, `editable`, `multiple`, `max`
437
-
438
- ### UploadCSV
439
- ```tsx
440
- <UploadCSV name="data" label="Importa CSV" />
441
- ```
442
- | Prop | Tipo |
443
- |---|---|
444
- | `name` | `string` (required) |
445
- | `onDataLoaded` | `(data: UploadCSVData) => void` |
446
- | `onParseField` | `([key, value]) => [key, value] \| undefined` |
447
- | `normalizeKeys` | `boolean` |
448
- | `removeEmptyFields` | `boolean` |
449
- | `delimiter` | `string` |
450
- | `label` / `icon` | Opzionali |
451
-
452
- ### ImageField
453
- ```tsx
454
- <ImageField
455
- name="hero"
456
- label="Hero image"
457
- defaultValue={{ src: 'https://example.com/hero.jpg', alt: 'Hero shot' }}
458
- srcsetWidths={[400, 800]}
459
- uploadPath="/content/images"
460
- />
461
- ```
462
-
463
- Gestisce un oggetto `{ src, alt, srcset?, sizes? }`, preview inline, crop editor e generazione opzionale di varianti responsive.
464
-
465
- ### CropImage
466
- ```tsx
467
- <CropImage img={imageObj} title="Ritaglia" scales={{ '1:1': 1, '4:3': 1.33 }} />
468
- ```
469
- Componente di ritaglio indipendente. `img` ha tipo `{ fileName, variants }` dove ogni variante è `{ fileName, type, scale, top, left, width, height, base64? }`. Usato internamente da `UploadImage` e `ImageField`, ma esportato pubblicamente per uso standalone.
470
-
471
- ### CodeEditor
472
- ```tsx
473
- <CodeEditor
474
- name="code"
475
- label="Template"
476
- language="html"
477
- placeholder="Scrivi HTML..."
478
- minHeight={200}
479
- maxHeight={600}
480
- disabled={false}
481
- required
482
- validateSyntax
483
- commands={[{ name: 'wrap-section', handler: ({ value }) => `<section>\n${value}\n</section>` }]}
484
- commandsTrigger="/"
485
- />
486
- ```
487
- Editor basato su CodeMirror con syntax highlighting e validazione. Linguaggi: `liquid`, `html`, `json`, `js`, `ts`, `css`.
488
- Quando `commands` e presente, `CodeEditor` apre internamente `ContextMenu` sul trigger configurato.
489
-
490
- | Prop extra | Tipo |
491
- |---|---|
492
- | `language` | `CodeEditorLanguage` |
493
- | `placeholder` | `string` |
494
- | `minHeight` / `maxHeight` | `number` |
495
- | `validateSyntax` | `boolean` |
496
- | `validator` | `(value) => string \| undefined \| Promise<string \| undefined>` |
497
- | `extensions` | `unknown[]` |
498
-
499
- ### RichText
500
- ```tsx
501
- <RichText
502
- name="body"
503
- label="Article body"
504
- placeholder="Scrivi..."
505
- toolbar="fixed" // 'fixed' | 'floating' | false
506
- toolbarCommands={['bold', 'italic', 'heading1', '|', 'bulletList', 'link']}
507
- outputFormat="html" // 'html' | 'json' | 'text'
508
- statusBar={{ tagBreadcrumb: true, wordCount: true }}
509
- minHeight={200}
510
- maxHeight={800}
511
- imageUpload={{ path: '/content/images', srcsetWidths: [400, 800] }}
512
- commands={[{ name: 'summary', icon: 'sparkles' }]}
513
- commandsTrigger="/"
514
- />
515
- ```
516
-
517
- | Prop extra | Tipo |
518
- |---|---|
519
- | `placeholder` | `string` |
520
- | `disabled` | `boolean` |
521
- | `feedback` | `string` |
522
- | `toolbar` | `'fixed' \| 'floating' \| false` |
523
- | `toolbarCommands` | `ToolbarCommand[]` |
524
- | `outputFormat` | `'html' \| 'json' \| 'text'` |
525
- | `statusBar` | `boolean \| StatusBarConfig` |
526
- | `minHeight` / `maxHeight` | `number` |
527
- | `imageUpload` | `RichTextImageUploadConfig` |
528
- | `documentUpload` | `RichTextDocumentUploadConfig` |
529
- | `validator` | `(value) => string \| undefined` |
530
-
531
- ### Repeat — array dinamici
532
- ```tsx
533
- <Repeat name="items" defaultLength={3} minItems={1} maxItems={10} label="Elementi" layout="vertical" readOnly>
534
- {({ index, record, records, remove }) => (
535
- <>
536
- <Input name={`items.${index}.name`} label="Nome" />
537
- <UploadImage name={`items.${index}.photo`} label="Foto" />
538
- </>
539
- )}
540
- </Repeat>
541
- ```
542
-
543
- | Prop | Tipo | Note |
544
- |---|---|---|
545
- | `name` | `string` (required) | |
546
- | `children` | `ReactNode \| ((args) => ReactNode)` | Render prop con `{ index, record, records, remove }` |
547
- | `defaultLength` | `number` | Righe iniziali |
548
- | `minItems` / `maxItems` | `number` | Min/max righe |
549
- | `layout` | `'vertical' \| 'horizontal' \| 'inline'` | |
550
- | `label` | `string` | Etichetta gruppo |
551
- | `labelPosition` | `'top' \| 'bottom'` | |
552
- | `hideRemoveButton` | `boolean` | Nascondi rimozione |
553
- | `readOnly` | `boolean` | Sola lettura |
554
- | `onChange` | `FieldOnChange` | |
555
- | `onAdd` / `onRemove` | `(records) => void` | Callback aggiunta/rimozione |
556
-
557
- ### Prompt — campo AI-potenziato
558
- ```tsx
559
- <Prompt
560
- name="summary"
561
- mode="run" // 'edit' | 'run'
562
- onRunPrompt={async (prompt, options) => AI.fetch(prompt)}
563
- commands={[{ name: 'translate', icon: 'languages', handler: (v) => `Translate:\n${v}` }]}
564
- commandsTrigger="/" // default automatico quando commands e presente
565
- attachments
566
- statusItems={['tokensIn', 'tokensOut', 'contextPercent', 'duration']}
567
- />
568
- ```
569
-
570
- Quando `commands` e presente in `mode="run"`, `Prompt` monta internamente `ContextMenu` sulla textarea del risultato. Il trigger di default e `"/"`; `commandsTrigger` permette di cambiarlo. Il menu diventa searchable automaticamente sopra una soglia interna di comandi.
571
-
572
- `renderFallback`: quando l'AI non e configurata, `Prompt` monta un fallback testuale; passa `renderFallback` per personalizzarlo (riceve `{ name, label, required, onChange }`).
573
-
574
- ---
575
-
576
- ## UI Primitives
577
-
578
- ### Alert
579
- ```tsx
580
- <Alert variant="success">Salvato!</Alert>
581
- <Alert variant="warning" icon placement="fixed" timeout={3000}>Attenzione</Alert>
582
- ```
583
- `variant`: `'info' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'light' | 'dark'`
584
- `appearance`: `'default' | 'text'`
585
- `placement`: `'inline' | 'fixed'`
586
-
587
- ### Badge
588
- ```tsx
589
- <Badge variant="success">Attivo</Badge>
590
- ```
591
- `variant`: stessi valori di Alert
592
-
593
- ### Button variants
594
- ```tsx
595
- <ActionButton variant="primary" label="Salva" icon="save" onClick={handleSave} />
596
- <LoadingButton variant="primary" label="Invia" onClick={async (e, setMsg) => { await save(); setMsg('Salvato!') }} />
597
- <BackLink label="Indietro" />
598
- <GoSite href="https://..." label="Visita" />
599
- ```
600
-
601
- **IButton props:** `variant`, `label`, `icon`, `badge`, `disabled`, `ariaLabel`, `title`, `iconClassName`, `onClick`
602
- **LoadingButton aggiunge:** `loading`, `loadingLabel` — `onClick` riceve `(e, setMessage?) => Promise<unknown>`
603
-
604
- ### Card
605
- ```tsx
606
- <Card title="Titolo" header={<Actions />} footer={<Pagination />} loading={isLoading}>
607
- {children}
608
- </Card>
609
- ```
610
-
611
- ### Modal
612
- ```tsx
613
- <Modal title="Conferma" size="md" position="center" onClose={() => {}} onSave={handleSave} onDelete={handleDelete} motion="fade" allowFullscreen stackedBehind>
614
- {content}
615
- </Modal>
616
- <ModalYesNo title="Sicuro?" onClose={() => {}} onYes={async () => true}>Eliminare il record?</ModalYesNo>
617
- <ModalOk title="Completato!" onClose={() => {}}>Salvato.</ModalOk>
618
- ```
619
- `size`: `'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'fullscreen'`
620
- `position`: `'center' | 'top' | 'left' | 'right' | 'bottom'`
621
- `ModalYesNo` e `ModalOk` sono export separati: `Modal.YesNo` e `Modal.Ok` non esistono.
622
- Tastiera: Esc chiude il modal in cima alla pila (`closeOnEscape`, default `true`).
623
- Accessibilità: il dialog ha `role="dialog"`, `aria-modal="true"` (non con `stackedBehind`) e `aria-labelledby` verso `title` (o `header` stringa) — nei test si trova con `getByRole('dialog', { name })`.
624
-
625
- | Prop | Tipo | Note |
626
- |---|---|---|
627
- | `children` | `ReactNode` (required) | |
628
- | `title` | `ReactNode` | |
629
- | `header` / `footer` | `ReactNode \| false` | Custom header/footer |
630
- | `size` / `position` | Come sopra | |
631
- | `onClose` | `() => void` | |
632
- | `onSave` / `onDelete` | `(e) => Promise<boolean>` | |
633
- | `allowFullscreen` | `boolean` | Toggle fullscreen |
634
- | `showCancel` | `boolean` | Default `true` |
635
- | `stackedBehind` | `boolean` | Modale sotto un'altra (stile Google Tag Manager): un velo la scurisce, il suo backdrop resta com'è (quello della modale sopra entra sopra, così la pagina si scurisce di un gradino per modale) e non riceve il puntatore. I pannelli laterali aperti sullo stesso lato si impilano da soli: quello in cima prende la larghezza con cui si è aperto il primo, ognuno sotto è largo il 10% in più di quello sopra (100%, 110%, 121%…), così ne sporge una striscia. Solo la larghezza cambia, con la transizione dello slide, in entrata e in uscita |
636
- | `closeOnBackdrop` | `boolean` | Default `true` |
637
- | `closeOnEscape` | `boolean` | Default `true` — solo il modal in cima reagisce |
638
- | `zIndex` | `number` | |
639
- | `motion` | `MotionReference` | Preset animazione |
640
- | Classi CSS header/title/subtitle/body/footer | `string` | |
641
-
642
- ### Table
643
- ```tsx
644
- <Table
645
- columns={[{ key: 'name', label: 'Nome', sort: true }]}
646
- records={records}
647
- onRowClick={(record) => {}}
648
- selection="multiple"
649
- sortable={{ field: 'name', dir: 'asc' }}
650
- pagination={{ limit: 20 }}
651
- onReorder={(records, meta) => {}}
652
- onSelectionChange={(sel) => {}}
653
- groupBy="status"
654
- renderCell={(record, key, index) => <CustomCell />}
655
- />
656
- ```
657
-
658
- | Prop | Tipo |
659
- |---|---|
660
- | `columns` | `TableHeaderProp[]` |
661
- | `records` | `RecordArray` |
662
- | `sortable` | `boolean \| OrderConfig` |
663
- | `pagination` | `PaginationParams` |
664
- | `selection` | `'single' \| 'multiple'` |
665
- | `onRowClick` | `(record) => void` |
666
- | `onReorder` | `(records, meta) => void` |
667
- | `onSelectionChange` | `(selection) => void` |
668
- | `renderCell` | `(record, key, index) => ReactNode` |
669
- | `groupBy` | `string \| string[]` |
670
- | `activeKey` | `string \| null` |
671
- | `selectedKeys` | `string[]` |
672
- | `header` / `footer` | `ReactNode` |
673
- | `headerClassName` / `bodyClassName` / `footerClassName` / `scrollClassName` / `selectedClassName` / `heightClassName` | `string` |
674
-
675
- ### Gallery
676
- ```tsx
677
- <Gallery
678
- records={records}
679
- overlays={[{ position: 'topRight', badge: { variant: 'success', children: 'New' } }]}
680
- columns={3}
681
- gap={2}
682
- sortable={{ field: 'name', dir: 'asc' }}
683
- pagination={{ limit: 20 }}
684
- onRowClick={(record) => {}}
685
- onSelectionChange={(sel) => {}}
686
- header="Titolo"
687
- groupBy="status"
688
- />
689
- ```
690
-
691
- `position`: `'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'middleLeft' | 'middleRight'`
692
-
693
- | Prop | Tipo |
694
- |---|---|
695
- | `records` | `GalleryRecord[]` (`{ img?, thumbnail?, name?, width?, height? }`) |
696
- | `columns` | `1 \| 2 \| 3 \| 4 \| 6` |
697
- | `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` |
698
- | `overlays` | `GalleryOverlay[]` |
699
- | `sortable` | `boolean \| OrderConfig` |
700
- | `pagination` | `PaginationParams` |
701
- | `onRowClick` | `(record) => void` |
702
- | `onSelectionChange` | `(selection) => void` |
703
- | `groupBy` | `string \| string[]` |
704
- | `selectedKeys` | `string[]` |
705
- | `header` / `footer` | `ReactNode` |
706
- | Classi CSS: `headerClassName`, `bodyClassName`, `footerClassName`, `scrollClassName`, `selectedClassName` | `string` |
707
-
708
- ### Tab
709
- ```tsx
710
- import { Tab, TabItem } from '@llmnative/react'
711
-
712
- <Tab defaultIndex={0} layout="top">
713
- <TabItem label="Generale">{content1}</TabItem>
714
- <TabItem label="SEO">{content2}</TabItem>
715
- </Tab>
716
- ```
717
- `layout`: `'default' | 'top' | 'left' | 'right' | 'bottom'`
718
- Le schede sono `<TabItem>`, export separato: `Tab.Item` non esiste.
719
-
720
- ### Image
721
- ```tsx
722
- <Image src="..." fit="cover" position="center" width={400} height={300} />
723
- ```
724
- `fit`: `'cover' | 'contain' | 'fill' | 'scale-down' | 'none'`
725
-
726
- ### Pagination (standalone)
727
- ```tsx
728
- <Pagination records={items} limit={20}>
729
- {(pageRecords, offset) => pageRecords.map(r => <Row key={r._key} record={r} />)}
730
- </Pagination>
731
- ```
732
-
733
- ### Percentage
734
- ```tsx
735
- <Percentage value={75} appearance="bar" variant="success" showText />
736
- <Percentage value={0.75} appearance="circle" size={60} />
737
- ```
738
-
739
- | Prop | Tipo | Default |
740
- |---|---|---|
741
- | `value` | `number` | — |
742
- | `max` | `number` | `100` |
743
- | `min` | `number` | — |
744
- | `appearance` | `'bar' \| 'circle'` | `'bar'` |
745
- | `variant` | `ColorType` | — |
746
- | `trackVariant` | `ColorType` | — |
747
- | `thickness` | `number` | — (px) |
748
- | `showText` | `boolean` | — |
749
- | `size` | `number` | Diametro in modalità `'circle'` (px) |
750
- | `fontSize` | `number` | — |
751
- | `label` | `string` | — |
752
-
753
- ### DescriptionList
754
- Coppie etichetta → valore in sola lettura (profilo, dati di un record, riepiloghi), rese come `<dl>`/`<dt>`/`<dd>`. Da usare al posto di markup `<dl>` scritto a mano; per dati modificabili usare i campi di `<Form>`, per molti record `<Table>`/`<Grid>`.
755
- ```tsx
756
- <DescriptionList items={[{ label: 'Email', value: 'a@b.it' }, { label: 'Telefono', value: null }]} />
757
- <DescriptionList layout="stacked" columns={4} truncate items={[{ label: 'Stato', value: <Badge variant="success">Spedito</Badge> }]} />
758
- ```
759
-
760
- | Prop | Tipo | Default |
761
- |---|---|---|
762
- | `items` | `DescriptionListItem[]` — `{ key?, label, value?, title? }` | — |
763
- | `layout` | `'horizontal' \| 'stacked'` | `'horizontal'` (etichette a sinistra, impilate su mobile) |
764
- | `columns` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `1` (solo `stacked`, dal breakpoint `lg`) |
765
- | `labelWidth` | `string` (lunghezza CSS) | `'12rem'` (solo `horizontal`) |
766
- | `emptyValue` | `ReactNode` | `'—'` per `null` / `undefined` / `''` (lo `0` resta) |
767
- | `truncate` | `boolean` | `false`; un valore testuale diventa il suo `title` |
768
- | Classi | `wrapperClassName`, `className`, `itemClassName`, `labelClassName`, `valueClassName` | tema `DescriptionList` |
769
-
770
- ### Tree
771
- Albero accessibile di nodi (cartelle e file, gerarchie): pattern WAI-ARIA tree, un solo tab stop, frecce per muoversi, → / ← per aprire e chiudere, Invio o Spazio per selezionare. Le cartelle si aprono al click, le foglie si selezionano.
772
- ```tsx
773
- <Tree nodes={treeFromPaths(paths)} defaultExpanded selectedId={path} onSelect={(node) => setPath(node.id)} ariaLabel="File" />
774
- <Tree nodes={[{ id: 'a', label: 'Docs', children: [{ id: 'a/1', label: 'README.md', badge: <Badge>new</Badge> }] }]} />
775
- ```
776
- | Prop | Tipo | Note |
777
- |---|---|---|
778
- | `nodes` | `TreeNode[]` — `{ id, label, icon?, badge?, description?, children?, disabled? }` | `id` unico; con `children` è una cartella |
779
- | `selectedId` + `onSelect` / `defaultSelectedId` | `string \| null` | selezione controllata o no |
780
- | `expanded` + `onExpandedChange` / `defaultExpanded` | `string[]`; `defaultExpanded` anche `true` (tutto aperto) | apertura controllata o no |
781
- | `selectableBranches` | `boolean` (default `false`) | anche le cartelle si selezionano |
782
- | `ariaLabel` | `string` | nome accessibile |
783
- | Classi | `wrapperClassName`, `className`, `itemClassName`, `selectedClassName` | tema `Tree` |
784
-
785
- `treeFromPaths(paths, leaf?)` costruisce l'albero da percorsi `a/b/c`: cartelle prima dei file, ordinati; l'id di un file è il percorso, quello di una cartella `percorso/`; `leaf(path)` decora le foglie (label, icona, descrizione…).
786
-
787
- ### Dropdown
788
- ```tsx
789
- <Dropdown trigger={<Icon name="menu" />} position="end">
790
- <DropdownItem label="Modifica" icon="edit" onClick={() => {}} />
791
- <DropdownItem label="Elimina" icon="trash" variant="danger" />
792
- <DropdownDivider />
793
- <DropdownHeader label="Azioni rapide" />
794
- </Dropdown>
795
- ```
796
-
797
- Azioni secondarie di una voce (CR-107): `actions` mostra icone in fondo alla riga, al passaggio
798
- del mouse o al focus (sempre sui dispositivi touch). Non selezionano la voce e lasciano il menu aperto
799
- salvo `closeOnClick`.
800
-
801
- ```tsx
802
- <DropdownItem icon="messages-square" onClick={() => open(chat)}
803
- actions={[{ icon: 'archive', label: 'Archivia', onClick: () => archive(chat) }]}>
804
- {chat.title}
805
- </DropdownItem>
806
- ```
807
-
808
- Per elenchi caricati dinamicamente usare `AsyncDropdown`: gestisce ricerca con debounce,
809
- cancellazione delle richieste precedenti tramite `AbortSignal` e stati loading/empty/error.
810
-
811
- ```tsx
812
- <AsyncDropdown
813
- trigger={<ActionButton label="Select user" />}
814
- loadItems={(query, signal) => users.search(query, { signal })}
815
- getItemId={user => user.id}
816
- renderItem={user => user.name}
817
- onSelect={user => setUser(user)}
818
- selectedId={user?.id}
819
- />
820
- ```
821
-
822
- ### ContextMenu
823
- ```tsx
824
- <ContextMenu trigger="/" searchable>
825
- <ContextMenu.Heading>Comandi</ContextMenu.Heading>
826
- <ContextMenu.Item label="Titolo" value="# " icon="heading" />
827
- <ContextMenu.Separator />
828
- <ContextMenu.Item label="Bold" value="**bold**" icon="bold" />
829
- </ContextMenu>
830
- ```
831
- Funziona su qualsiasi `<textarea>` / `<input>` figlio. Trigger custom (`/`, `@`, `{`), filtro searchable, navigazione tastiera, floating positioning.
832
- `ContextMenu.Item` accetta `description` (opzionale): seconda riga attenuata (`text-muted-foreground`)
833
- sotto la label, troncata; il menu è largo al massimo 28rem e il testo completo resta nel `title`
834
- della voce. Il filtro cerca nel testo digitato dopo il trigger su label, value e description (CR-111).
835
-
836
- ```tsx
837
- <ContextMenu.Item label="/plan" value="plan" icon="list" description="Scrive il piano della release" />
838
- ```
839
-
840
- | Prop | Tipo | Note |
841
- |---|---|---|
842
- | `trigger` | `string` (required) | Es. `"/"`, `"@"`, `"{"` |
843
- | `searchable` | `boolean` | Filtro testuale |
844
- | `controlled` | `ContextMenuControlledState` | Stato controllato esterno |
845
- | `onSelect` | `(item, context) => void` | |
846
-
847
- **EditorCommand** (usato da `commands` prop su `Chatbot`, `Prompt`, `RichText`, `CodeEditor`):
848
- ```ts
849
- {
850
- name: string;
851
- description?: string;
852
- icon?: string;
853
- group?: string;
854
- consumeSuffix?: string;
855
- handler?: (context: TextCommandContext) => string | Promise<string>;
856
- }
857
- ```
858
-
859
- `description` compare sotto `/name` nel menu dei comandi di tutti e quattro i consumer e il filtro
860
- la usa (digitare `/release` trova un comando descritto come "…release…"). Un comando senza `handler`
861
- sostituisce il trigger con `/name ` (spazio finale) e il testo resta tale: in `Chatbot`/`Prompt` il
862
- messaggio inviato è letteralmente `"/name …"`, da interpretare a cura di chi lo riceve.
863
-
864
- `consumeSuffix` elimina una chiusura automatica adiacente durante la sostituzione del trigger,
865
- utile quando un comando cambia famiglia sintattica (per esempio da `{{ ... }}` a `{% ... %}`).
866
-
867
- ### Altre primitive
868
- ```tsx
869
- <Icon name="user" size={20} /> // usa provider icone configurato
870
- <ImageAvatar src="..." title="Mario" alt="Mario" width={40} height={40} />
871
- <Identicon value="acme/app" size={32} title="acme/app" /> // icona generata dal valore, stile GitHub
872
- <Loader show={isLoading}>{content}</Loader> // indicatore al posto dei figli finché show è true
873
- <Loader show>{null}</Loader> // solo indicatore (es. pagina in caricamento)
874
- <Code language="tsx">{snippet}</Code> // syntax highlight
875
- <GridSystem.Row><GridSystem.Col xs={12} md={6}>...</GridSystem.Col></GridSystem.Row>
876
- <LocaleSwitcher locales={[{ code: 'it', label: 'Italiano' }]} />
877
- <ThemeSwitcher />
878
- <ProviderSwitcher />
879
- <LayoutBuilder components={...} />
880
- ```
881
-
882
- ### Charts (CR-112)
883
- Grafici SVG senza dipendenze, dal modulo `charts`. Colori come token del tema: `color` è uno slot della
884
- palette categoriale (`1…8` → `--rf-chart-N`, definiti in `globals.css` con la variante scura e
885
- sovrascrivibili dal consumer) o un token (`'--rf-success'`); le serie senza colore prendono lo slot
886
- della loro posizione. Larghezza dal contenitore (ResizeObserver), animazioni spente con
887
- `prefers-reduced-motion`, tooltip che segue il puntatore e la tastiera (le aree prendono il focus
888
- finché sono al massimo 40), tabella dei dati `sr-only` con `ariaLabel` come didascalia, stato vuoto
889
- `charts.noData`. `format` è facoltativo: di default il numero nella locale dell'app.
890
-
891
- ```tsx
892
- <ColumnChart ariaLabel="CR chiuse al giorno" columns={[{ label: 'lun', value: 4 }]} markers={[{ position: 3.5, label: 'mediana', dashed: true }]} onColumnClick={(column) => {}} />
893
- <StackedColumnChart ariaLabel="Ore per agente" labels={days} labelEvery={2} format={(v) => `${v} h`}
894
- series={[{ id: 'dev', label: 'Developer', values: dev }, { id: 'qa', label: 'Tester', values: qa, color: 3 }]}
895
- comparison={previousTotals} comparisonLabel="periodo precedente" />
896
- <MeasureBarList ariaLabel="Agenti" // CR-115: barre raggruppate, ogni agente una volta, una barra per misura (colore in legenda), ognuna sulla sua scala
897
- columns={[{ id: 'time', label: 'Ore', format: (v) => `${v} h` }, { id: 'cost', label: 'Costo' },
898
- { id: 'tokens', label: 'Token', parts: [{ id: 'in', label: 'Input' }, { id: 'out', label: 'Output' }] }]} // parts: la barra divisa in proporzione
899
- rows={[{ id: 'dev', label: 'Developer', values: { time: 18, cost: 4.27, tokens: [2400000, 190000] }, trailing: { text: '18 back', tone: 'bad' } }]} />
900
- <SequenceColumnChart ariaLabel="Turni per CR" format={(v) => `${v} min`} // CR-114: ogni colonna nel suo ordine, una serie può tornare
901
- series={[{ id: 'dev', label: 'Developer' }, { id: 'rev', label: 'Reviewer' }]}
902
- columns={[{ label: '#34', title: 'CR #34', segments: [{ series: 'dev', value: 18 }, { series: 'rev', value: 6 }, { series: 'dev', value: 11 }] }]}
903
- onColumnClick={(column) => {}} />
904
- <LineChart ariaLabel="Lead time" labels={days} threshold={{ value: 6.5, label: 'obiettivo' }} baseline={0} domain={[0, 10]}
905
- series={[{ id: 'now', label: 'Ora', values: [3, null, 5], area: true, band: { min, max } }, { id: 'before', label: 'Prima', values: prev, dashed: true }]} />
906
- <AreaChart ariaLabel="Flusso cumulativo" labels={days} stacked series={[{ id: 'closed', label: 'Chiuse', values: closed }]} />
907
- <ScatterChart ariaLabel="Costo contro rework" xLabel="Costo" yLabel="Rework" sizeLabel="Turni"
908
- series={[{ id: 'sonnet', label: 'Sonnet' }]} points={[{ id: '1', label: 'Developer', x: 0.04, y: 0.3, size: 30, series: 'sonnet' }]} onPointClick={(point) => {}} />
909
- <DivergingBarChart ariaLabel="Valore e costo" upLabel="Valore" downLabel="Costo" bars={[{ label: 'lun', up: 120, down: null }]} />
910
- <BarList ariaLabel="Costo per agente" rows={[{ id: 'dev', label: 'Developer', value: 1.84, trailing: { text: '+12%', tone: 'bad' } }]} />
911
- <Heatmap ariaLabel="Turni per ora" rowLabels={agents} columnLabels={hours} columnLabelEvery={2} values={matrix} />
912
- <CalendarHeatmap ariaLabel="Turni per giorno" days={[{ date: '2026-11-02', value: 4 }, { date: '2026-11-03', value: null }]} />
913
- <Sparkline values={[1, 3, null, 2]} ariaLabel="Andamento" /> // senza ariaLabel è decorativa
914
- <AnimatedNumber value={3.85} format={(v) => `$${v.toFixed(2)}`} />
915
- ```
916
-
917
- Parti comuni per grafici propri: `useChartTooltip()` (`show`, `showAt`, `hide`, `element`), `ChartLegend`,
918
- `ChartDataTable`, `useChartWidth(fallback)`, `useAnimatedValues(values)`, `useReveal(key)`,
919
- `useChartFormat(format?)`, `chartToken(color)`, `chartFill(token, alpha?)`, `niceMax`, `ticks`.
920
-
921
- ---
922
-
923
- ## Blocks
924
-
925
- ```tsx
926
- <Breadcrumbs trail={[{ label: 'Home', href: '/' }, { label: 'Utenti' }]} separator="/" jsonLd />
927
- <Brand url="/" label="MyApp" logo="/logo.svg" width={120} />
928
- <Search onQueryChange={(e) => setQ(e.target.value)} />
929
- <Notifications items={[...]} seeAllUrl="/notifications" onMarkAllRead={() => {}} onItemClick={(item) => markRead(item.id)} />
930
- <Notifications items={[{ id: 'a', title: 'Fonte senza Action', description: 'crm · sources/notes/a.md', url: '/projects/crm', time: 'ora', icon: 'file', actions: [{ label: "Apri l'Action", icon: 'play', onClick: openAction, variant: 'primary' }] }]} />
931
- <Carousel images={[...]} />
932
- <Toolbar items={[...]}>
933
- {children}
934
- </Toolbar>
935
- <SideNav />
936
- <ListCard records={records} onRowClick={(r) => {}} />
937
- ```
938
-
939
- **Brand:**
940
- | Prop | Tipo |
941
- |---|---|
942
- | `url` | `string` |
943
- | `label` | `string` |
944
- | `logo` | `string` |
945
- | `width` / `height` | `number` |
946
- | `wrapperClassName` / `className` / `logoClassName` / `labelClassName` | `string` |
947
-
948
- **SideNav:**
949
- | Prop | Tipo |
950
- |---|---|
951
- | `items` | `SideNavItemDef[]` (in alternativa a `menuKey`) |
952
- | `menuKey` | `string` — voci dal menu registrato in `<App>` |
953
- | `bottomItems` | `SideNavItemDef[]` — voci fissate in fondo, sopra il footer e fuori dalla lista scorrevole (es. Impostazioni) |
954
- | `header` / `footer` | `ReactNode` |
955
- | `collapsed` + `onCollapsedChange` / `defaultCollapsed` | `boolean` — compressione controllata o no |
956
- | `showIcons` / `showCollapseButton` | `boolean` (default `true`) |
957
- | `embedded` | `boolean` — solo le voci, senza guscio (drawer, pannelli); `bottomItems` seguono le voci principali |
958
-
959
- `SideNavItemDef`: `{ path, title?, icon?, group?, end?, badge?, children? }`. La voce attiva ha
960
- `aria-current="page"`.
961
-
962
- ```tsx
963
- <SideNav
964
- items={[{ path: '/dashboard', title: 'Dashboard', icon: 'home' }]}
965
- bottomItems={[{ path: '/settings', title: 'Impostazioni', icon: 'settings' }]}
966
- />
967
- ```
968
-
969
- **ImageAvatar:**
970
- | Prop | Tipo |
971
- |---|---|
972
- | `src` | `string` (obbligatoria) |
973
- | `width` / `height` | `number` |
974
- | `title` / `alt` | `string` |
975
- | `fit` | `AvatarFit` |
976
- | `badge` | `BadgeDescriptor` |
977
- | `feedback` | `ReactNode` |
978
-
979
- **Identicon:** l'icona di un'entità senza immagine (repository, tenant, utente senza foto), come quelle di
980
- GitHub: griglia 5×5 simmetrica, celle e tinta da un hash di `value`. Lo stesso valore dà sempre la stessa
981
- icona; niente rete né storage. Default: `rounded-md bg-muted` (tema `Identicon.className`).
982
- `identiconPattern(value)` restituisce celle e tinta, per chi la disegna altrove.
983
- | Prop | Tipo |
984
- |---|---|
985
- | `value` | `string` (obbligatoria) |
986
- | `size` | `number` (px, default 40) |
987
- | `title` | `string`: nome accessibile (`role="img"`); senza, `aria-hidden` |
988
- | `before` / `after` / `className` / `wrapperClassName` | come le altre primitive |
989
-
990
- **Search:**
991
- | Prop | Tipo |
992
- |---|---|
993
- | `onQueryChange` | `(e: React.ChangeEvent<HTMLInputElement>) => void` |
994
-
995
- **Notifications:**
996
- | Prop | Tipo |
997
- |---|---|
998
- | `items` | `NotificationItem[]` |
999
- | `badge` | `BadgeProps` |
1000
- | `seeAllUrl` | `string` |
1001
- | `onMarkAllRead` | `() => void` |
1002
- | `onItemClick` | `(item, index) => void` — una voce aperta (es. per segnarla letta); il menu si chiude |
1003
- | `motion` | `MotionReference` |
1004
-
1005
- `NotificationItem`: `{ id?, title, description?, url?, external?, time, icon, read?, actions? }`. `description` è la
1006
- riga sotto il titolo; senza `url` la voce si mostra e basta; con `external` l'`url` è un indirizzo aperto
1007
- in una nuova scheda (`<a target="_blank">`), altrimenti una rotta (`Link`). `actions`
1008
- (`NotificationAction[]`: `{ label, icon?, onClick, variant?: 'primary' | 'default', closeOnClick? }`) sono
1009
- pulsanti sotto la voce: non la aprono, mostrano il caricamento finché la promessa di `onClick` è in corso
1010
- e lasciano il menu aperto salvo `closeOnClick`.
1011
-
1012
- **ListCard:**
1013
- | Prop | Tipo |
1014
- |---|---|
1015
- | `title` / `description` / `meta` | `ReactNode` |
1016
- | `badge` | `ReactNode` |
1017
- | `icon` | `string \| ReactNode` |
1018
- | `trailing` / `footer` / `children` | `ReactNode` |
1019
- | `href` | `string` |
1020
- | `external` / `active` / `disabled` / `dashed` / `compact` | `boolean` |
1021
- | `align` | `'start' \| 'center'` |
1022
- | `onClick` | `(e) => void` |
1023
- | Classi CSS: `titleClassName`, `descriptionClassName`, `metaClassName`, `badgeClassName`, `leadingClassName`, `bodyClassName`, `headerClassName`, `trailingClassName`, `footerClassName` | `string` |
1024
-
1025
- **SortableList (CR-120):** card compatte (ListCard) in un ordine che la persona sceglie: le trascina per la
1026
- maniglia, o con le frecce su/giù sulla maniglia; l'annuncio della nuova posizione va agli screen reader.
1027
- ```tsx
1028
- <SortableList
1029
- items={accounts}
1030
- getKey={(a) => a.id}
1031
- label="Account"
1032
- numbered
1033
- onReorder={(next, { fromIndex, toIndex, item }) => setAccounts(next)}
1034
- renderItem={(a) => ({ title: a.email, meta: a.added, badge: <Badge variant="success">Disponibile</Badge>, trailing: <ActionButton icon="trash-2" … /> })}
1035
- emptyState={<p>Nessun account</p>}
1036
- />
1037
- moveItem(['a', 'b', 'c'], 0, 2) // ['b', 'c', 'a']
1038
- ```
1039
- | Prop | Tipo |
1040
- |---|---|
1041
- | `items` / `getKey` | `TItem[]` / `(item) => string` |
1042
- | `renderItem` | `(item, index) => SortableListCard` (`title`, `description`, `meta`, `badge`, `icon`, `trailing`, `active`, `disabled` di ListCard) |
1043
- | `onReorder` | `(items, { fromIndex, toIndex, item }) => void`; senza, nessuna maniglia |
1044
- | `label` / `numbered` / `disabled` / `emptyState` | `string` / `boolean` / `boolean` / `ReactNode` |
1045
- | Classi CSS: `className`, `itemClassName`, `cardClassName` | `string` |
1046
-
1047
- **Dropdown:**
1048
- | Prop | Tipo |
1049
- |---|---|
1050
- | `trigger` | `string \| ReactNode \| DropdownTogglerProps` |
1051
- | `badge` | `BadgeProps` |
1052
- | `header` / `footer` | `ReactNode` |
1053
- | `defaultOpen` / `open` | `boolean` |
1054
- | `onOpenChange` | `(open) => void` |
1055
- | `position` | `'start' \| 'end'` |
1056
- | `placement` | `'auto' \| 'top' \| 'bottom'` |
1057
- | `strategy` | `'fixed' \| 'absolute'` |
1058
-
1059
- // Menu — renderizza una sezione del menuConfig passato ad <App>
1060
- <Menu menuKey="main" /> // 'main' o qualsiasi chiave definita in MenuConfig
1061
- <Menu menuKey="topbar" className="nav flex-row gap-2" />
1062
- // NON usare context= — la prop si chiama menuKey
1063
-
1064
- // AuthButton — login/logout con il provider auth configurato
1065
- <AuthButton />
1066
- ```
1067
-
1068
- ---
1069
-
1070
- ## Widgets
1071
-
1072
- ### MarkdownReader
1073
- ```tsx
1074
- <MarkdownReader
1075
- content={markdownString}
1076
- onInternalLinkClick={(href, e) => { e.preventDefault(); navigate(href) }}
1077
- wrapperClassName="prose"
1078
- />
1079
- ```
1080
-
1081
- ### Messaggi di chat: ChatMarkdown, ChatReasoning, ChatQuestions (CR-109), ChatActivity (CR-123)
1082
-
1083
- Parti di un messaggio di un modello, da comporre nel proprio thread (anche accanto a `Chatbot`).
1084
-
1085
- ```tsx
1086
- // Markdown GFM del messaggio: tabelle (scorrono dentro di sé in un pannello stretto), elenchi,
1087
- // titoli, citazioni, task list, codice con evidenziazione (Prism, lazy) e pulsante Copia, link a
1088
- // indirizzi in una nuova scheda (noopener). Sanitizzato: niente HTML grezzo, niente javascript:.
1089
- // streaming: chiude ciò che la fine del testo lascia aperto (fence, intestazione di tabella,
1090
- // **grassetto**, `codice`, link) così il layout non salta mentre arriva. Off per un messaggio finito.
1091
- <ChatMarkdown content={text} streaming={running} onLinkClick={(href) => navigate(href)} />
1092
- // tone="muted", highlight={false}, components (come react-markdown), className,
1093
- // classNames={{ root, paragraph, heading, list, blockquote, tableWrapper, table, th, td, codeBlock, codeHeader, code, inlineCode, link }}
1094
- // repairStreamingMarkdown(text) è la stessa riparazione, pura.
1095
-
1096
- // Ragionamento: chiuso dietro "Ragionamento" / "Ha ragionato per 12 s"; aperto mentre arriva
1097
- // (se l'utente non lo chiude) e richiuso alla fine. Contenuto con ChatMarkdown, tono attenuato.
1098
- <ChatReasoning content={reasoning} streaming={thinking} duration={12} />
1099
- // defaultOpen, open + onOpenChange (controllato), label, icon ('brain'), className, classNames={{ root, trigger, content }}
1100
-
1101
- // Domande interattive (formato dello strumento `question` di opencode / elicitation MCP):
1102
- <ChatQuestions
1103
- questions={[
1104
- { id: 'db', header: 'Database', question: 'Quale database?', recommended: 0,
1105
- options: [{ label: 'Postgres', description: 'Relazionale' }, { label: 'SQLite' }] },
1106
- { id: 'features', header: 'Funzioni', question: 'Quali funzioni?', multiple: true,
1107
- options: [{ label: 'Auth' }, { label: 'Ricerca' }] },
1108
- ]}
1109
- onSubmit={async (answers) => send(answers)} // [{ id, header, values: string[] }] in ordine
1110
- />
1111
- // Una scheda per domanda (header) + Riepilogo e un solo Invia; opzioni numerate 1-2-3 o A-B-C
1112
- // (numbering, altrimenti dedotto da etichette "A) …" / "1. …", che vengono tolte); consigliata
1113
- // (recommended: indice da 0 o etichetta; anche il suffisso "(Recommended)"); multiple; "Altro" con
1114
- // testo libero (allowOther, default true; per domanda `custom`); senza options è a testo libero.
1115
- // Il testo libero è una textarea (CR-110) di 2 righe che cresce col testo fino a ~160px e poi
1116
- // scorre; ridimensionabile in verticale. Lì Invio va a capo, Ctrl/Cmd+Invio prosegue o invia
1117
- // (suggerimento `chat.textKeyboardHint` nel piè mentre si scrive).
1118
- // Tastiera: numeri (o lettere con A-B-C) scelgono, frecce sinistra/destra tra le schede, su/giù tra
1119
- // le opzioni, Spazio seleziona, Invio prosegue o invia (su una scelta singola prima la sceglie;
1120
- // nel testo libero serve Ctrl/Cmd+Invio).
1121
- // Ruoli tablist/tab/tabpanel, radiogroup/radio o group/checkbox.
1122
- // Scelta singola: il click sceglie e passa alla domanda dopo. onSubmit che rifiuta lascia il form.
1123
- // answers={[…]} mostra un insieme già risposto (sola lettura); disabled, autoFocus, submitLabel,
1124
- // classNames={{ root, tabs, tab, panel, question, option, optionSelected, footer, summary }}
1125
-
1126
- // Il lavoro di un agente (CR-123), come negli assistenti AI: gruppi di passi (icona, label, quanti,
1127
- // dettagli brevi: query, file, comandi; quanti falliti) con le righe sotto aperte da "Comandi";
1128
- // le parole dell'agente in Markdown; come è finito; i file cambiati con "Vedi le modifiche";
1129
- // il registro grezzo a un clic. Il consumer traduce il suo log negli item.
1130
- <ChatActivity
1131
- items={[
1132
- { type: 'steps', icon: 'search', label: 'Esplora il repository', count: 3, detail: ['git status'], lines: ['git status --short'] },
1133
- { type: 'text', text: 'Aggiungo il comando e il suo test.' },
1134
- { type: 'end', ok: true, text: 'success · 9 steps' },
1135
- ]}
1136
- files={['functions/src/catalog/index.js']} onShowChanges={() => openDiff()} log={rawLines}
1137
- />
1138
- // className, classNames={{ root, step, text, files, log }}
1139
- ```
1140
-
1141
- ### ImageEditor
1142
- ```tsx
1143
- <ImageEditor src="..." onSave={(dataUrl) => save(dataUrl)} />
1144
- ```
1145
-
1146
- `ImageEditor` e un singolo componente pubblico. La libreria pesante `tui-image-editor` viene importata lazy a runtime dentro il componente, quindi non viene scaricata finche l'editor non viene effettivamente montato.
1147
-
1148
- ### TabDynamic
1149
- Tab con lazy loading dei contenuti — gestione dinamica aggiunta/rimozione tab.
1150
-
1151
- ---
1152
-
1153
- ## AI Integration
1154
-
1155
- ```ts
1156
- import { useAIProvider, getAIModelCatalog, createAIProviderRegistry } from '@llmnative/react'
1157
-
1158
- // Hooks
1159
- const provider = useAIProvider() // provider di default
1160
- const registry = useAIProviderRegistry() // tutti i provider AI registrati
1161
- const catalog = getAIModelCatalog() // modelli disponibili
1162
- // ogni modello: { id, provider, model, label, pricing? } — pricing in USD per 1M token
1163
- // { input, output, currency: 'USD', source: 'provider' | 'models.dev' }; assente = prezzo
1164
- // non trovato (il modello resta in lista). isFreeAIModel(m) → input e output a 0.
1165
- // Il model picker di Prompt/Chatbot mostra "$in / $out", evidenzia i free, segnala i mancanti,
1166
- // e raggruppa per provider (ChatbotModelOption.group = providerLabel) con header sticky.
1167
- // CR-121: il pulsante mostra la `label` del modello scelto (non il suo valore); `groupBadge: { label, variant? }`
1168
- // su un'opzione mette un Badge a destra nell'header del suo gruppo (es. Abbonamento / A consumo).
1169
-
1170
- // Provider adapter restituito da useAIProvider()
1171
- const text = await provider.fetch("Scrivi un titolo per: {keyword}", { keyword: "React" })
1172
- const json = await provider.json("Lista di 5 categorie per blog tech")
1173
- const arr = await provider.array("5 tag per: machine learning")
1174
-
1175
- // Provider custom
1176
- class MyAIProvider implements AIProviderAdapter {
1177
- async fetch(prompt: string, options?: AIRequestOptions): Promise<string> { ... }
1178
- async json<T>(prompt: string, options?: AIRequestOptions): Promise<T> { ... }
1179
- async array(prompt: string, options?: AIRequestOptions): Promise<string[]> { ... }
1180
- }
1181
-
1182
- // Quanto ragiona il modello (CR-122): `reasoning: 'low' | 'medium' | 'high' | 'max'` (AIReasoningLevel) su
1183
- // una richiesta. Anthropic: `output_config.effort` sui Claude 4.6 e successivi (+ thinking adaptive su Opus
1184
- // 4.6-4.8 e Sonnet 4.6); OpenAI e compatibili (OpenCode, DeepSeek, Groq…): `reasoning_effort` solo sui modelli
1185
- // di ragionamento di OpenAI (serie o, GPT-5; max = high); OpenRouter: `reasoning.effort` su ogni modello.
1186
- // Altrove (modelli più vecchi, Gemini) non si manda nulla: il default del modello.
1187
- await ai.complete({ prompt: 'Rivedi questo piano', model: 'claude-opus-5-5', reasoning: 'high' })
1188
-
1189
- // Factory per registri esterni
1190
- const registry = createAIProviderRegistry(definitions)
1191
- // Azioni di Chatbot (CR-107): con `content` aprono un menu; senza, sono pulsanti con `onClick`.
1192
- // <Chatbot actions={[{ key: 'finish', icon: 'git-pull-request', label: 'Concludi', showLabel: true,
1193
- // disabled: running, onClick: finish }]} … />
1194
- // Allegati di Chatbot (`attachments`): file scelti, trascinati o incollati con Ctrl+V (uno screenshot diventa
1195
- // `pasted-<data>.png`, CR-124); il testo incollato resta testo. Arrivano in `onSubmit` come `files`.
1196
- // Comandi "/" di Chatbot: `commands` (EditorCommand[]) + `commandsTrigger` (default "/").
1197
- // Il menu mostra `/name` e sotto la `description`; il filtro cerca in entrambi (CR-111).
1198
- // Senza `handler` la scelta inserisce "/name " e l'invio manda il testo così com'è.
1199
- // Tastiera: Invio invia a menu chiuso e sceglie il comando evidenziato a menu aperto;
1200
- // Maiusc+Invio (e Invio durante la composizione IME) va a capo.
1201
- // <Chatbot commands={[{ name: 'plan', icon: 'list', description: 'Scrive il piano' }]} … />
1202
- ```
1203
-
1204
- ### Identità dei modelli indipendente dal provider (CR-103)
1205
-
1206
- ```ts
1207
- const index = await loadModelIdentityIndex() // models.dev, un fetch per sessione
1208
- canonicalModelName('DeepSeek V4.1 Flash (Alibaba Cloud)') // 'deepseek v4.1 flash'
1209
- index.models['deepseek v4.1 flash'] // { key, name, family?, providers: { openrouter: [{ id, name }], … } }
1210
- resolveModel(index, 'DeepSeek V4.1 Flash', ['openrouter', 'deepseek'])
1211
- // → { provider: 'openrouter', model: 'deepseek/deepseek-v4.1-flash', ref: 'openrouter/deepseek/deepseek-v4.1-flash' }
1212
- modelOffers(index, 'DeepSeek V4.1 Flash', ['openrouter', 'deepseek']) // CR-119: tutti quelli che lo servono
1213
- // → [{ provider: 'openrouter', model, ref, cost: { input: 0.15, output: 0.3 } }, { provider: 'deepseek', … }]
1214
- // senza `providers`: tutto il catalogo, per chiave del provider. `cost` in USD per 1M token, assente se models.dev non lo dà
1215
- ```
1216
-
1217
- Il nome canonico toglie data, Free, Latest e host o regione tra parentesi; una modalità
1218
- ("(Thinking)", "(Reasoning)", "(Fast)") resta. Per ogni provider l'id preferito è quello stabile (non
1219
- alias `~`, non free tier, non datato). I provider sono le chiavi di models.dev, le stesse di opencode.
1220
- Puro: anche da `@llmnative/react/server`.
1221
-
1222
- ### Chiavi tenute dal server (CR-102)
1223
-
1224
- Per un'app che non mette mai le chiavi dei provider nel browser. Il browser conosce solo quali
1225
- provider sono collegati; il proxy dell'app mette la chiave.
1226
-
1227
- ```ts
1228
- // Browser: gli stessi adapter, con un segnaposto dove andrebbe la chiave. Serve il proxy attivo.
1229
- const registry = createServerCredentialAIRegistry(
1230
- [{ id: 'anthropic' }, { id: 'cloudflare', accountId }, { id: 'openai-compatible', baseUrl }],
1231
- { scope: companyId }, // lettere, cifre, - e _
1232
- )
1233
- Object.entries(registry).forEach(([id, adapter]) => setProvider('ai', id, adapter))
1234
-
1235
- // Server (Node, nessuna dipendenza da React): nel proxy, prima di inoltrare.
1236
- import { resolveServerCredentials } from '@llmnative/react/server'
1237
- const resolved = await resolveServerCredentials({ url: target, headers },
1238
- (scope, provider) => keysOf(scope)[provider]) // { id, apiKey, accountId?, baseUrl? } | undefined
1239
- if (!resolved.ok) return reply(resolved.status, resolved.error) // 404 non collegato, 403 host estraneo
1240
- await fetch(resolved.url, { headers: resolved.headers, ... })
1241
- ```
1242
-
1243
- La chiave va solo agli host del provider (`AI_PROVIDER_HOSTS`, il `baseUrl` di openai-compatible,
1244
- l'account di Cloudflare): una destinazione diversa è rifiutata. `createProxyPlugin(route, log,
1245
- lookup)` fa lo stesso nel proxy di sviluppo di Vite. Senza proxy, `proxyFetch` rifiuta di inviare
1246
- un segnaposto. `createAIProviderRegistry` (chiavi nel browser) resta com'è.
1247
-
1248
- Configurazione API key in `Config.tsx`: `geminiApiKey`, `openaiApiKey`, `anthropicApiKey`, `deepSeekApiKey`, `mistralApiKey`, `openRouterApiKey`, `groqApiKey`, `cloudflare: { apiToken, accountId }` (Cloudflare Workers AI: l'account id fa parte dell'URL; discovery via `ai/models/search`, modelli Workers Paid esclusi salvo `includePaidModels`).
1249
-
1250
- ---
1251
-
1252
- ## Theme e icone
1253
-
1254
- ```tsx
1255
- const theme = useThemeController()
1256
- theme.applyTheme('flat') // BuiltInThemeId ('default' | 'flat' | 'cyber' | 'vscode-dark-modern' | …) | custom
1257
- theme.setMode('dark') // 'light' | 'dark' | 'system'
1258
-
1259
- const icons = useIconController()
1260
- icons.setProvider('phosphor') // 'lucide' | 'phosphor' | custom
1261
- ```
1262
-
1263
- ---
1264
-
1265
- ## Head (SEO)
1266
-
1267
- ```ts
1268
- useHead({
1269
- title: 'Pagina',
1270
- description: '...',
1271
- robots: 'noindex',
1272
- canonical: 'https://...',
1273
- og: { title: '...', image: '...' }
1274
- })
1275
- ```
1276
-
1277
- ---
1278
-
1279
- ## Global state (localStorage)
1280
-
1281
- ```ts
1282
- const vars = useGlobalVars()
1283
- getGlobalVars('key')
1284
- setGlobalVars({ key: 'value' })
1285
- removeGlobalVars('key')
1286
- ```
1287
-
1288
- ---
1289
-
1290
- ## Hooks completi
1291
-
1292
- | Hook | Ritorna |
1293
- |---|---|
1294
- | `useDataProvider(name?)` | `DataProviderAdapter \| null` |
1295
- | `useStorageProvider(name?)` | `StorageProviderAdapter \| null` |
1296
- | `useAuthProvider(name?)` | `AuthProviderAdapter \| null` |
1297
- | `useEmailProvider(name?)` | `EmailProviderAdapter \| null` |
1298
- | `useAIProvider(name?)` | `AIProviderAdapter \| null` |
1299
- | `useAIProviderRegistry()` | `Record<string, AIProviderAdapter>` |
1300
- | `useCredentialsProvider()` | `CredentialsAdapter \| null` |
1301
- | `useTheme()` | `Theme \| undefined` |
1302
- | `useThemeController()` | `ThemeController` |
1303
- | `useIconController()` | `IconController` |
1304
- | `useAccessToken(clientID, renew?)` | `boolean` | Ottiene token per servizio esterno (true = ok) |
1305
- | `useGlobalVars()` | `Record<string, unknown>` |
1306
- | `useHead()` | `HeadController` |
1307
- | `useDocumentHead()` | `HeadController` |
1308
- | `useAssetsHead(title?)` | `void` |
1309
- | `useLanguageHead(lang?)` | `void` |
1310
- | `useSocialHead(config)` | `void` |
1311
- | `usePaginationHead(config)` | `void` |
1312
- | `usePwaHead(config)` | `void` |
1313
- | `useSchemaOrgHead(config)` | `void` |
1314
- | `useHeadLinks(links)` | `void` |
1315
- | `useProxy()` | `(url, init?) => Promise<Response>` |
1316
- | `useImage()` | `UseImageResult` |
1317
- | `useI18n()` | `I18nController` |
1318
- | `useMenu()` | Menu hook |
1319
- | `useFormController(controller?)` | `FormController` |
1320
- | `useFormActions()` | `FormController` |
1321
- | `useApiProvider(pathname?)` | `ApiProviderAdapter \| null` |
1322
- | `useSetProvider(service, driver?)` | `void` |
1323
- | `useProvider(name)` | `ProviderConfigurationState` |
1324
- | `useProviderSession()` | `ProviderSession` |
1325
- | `useDropBoxConnect(config?)` | `DropBoxConnectResult` |
1326
- | `useEnterMotion(baseStyle?, ref?, fallback?)` | `CSSProperties` |
1327
- | `useMotionState(active, ref?, fallback?, baseStyle?)` | `CSSProperties` |
1328
- | `useMotionEffect(ref?, fallback?)` | `ResolvedMotionEffect` |
1329
- | `usePressMotion(hovered, baseStyle?, ref?, fallback?)` | `{ style, pressHandlers }` |
1330
- | `useIconProvider()` | `IconProvider` |
1331
-
1332
- ---
1333
-
1334
- ## Tipi chiave
1335
-
1336
- ```ts
1337
- type RecordProps = Record<string, FieldValue>
1338
- type FieldValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[]
1339
- type RecordArray = RecordProps[]
1340
-
1341
- type OrderConfig = { field: string; dir: 'asc' | 'desc' }
1342
- type WhereClause = { field: string; op: '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'not-in' | 'array-contains' | 'array-contains-any'; value: unknown }
1343
-
1344
- interface UIProps {
1345
- before?: React.ReactNode
1346
- after?: React.ReactNode
1347
- wrapperClassName?: string
1348
- className?: string
1349
- }
1350
-
1351
- type UserProfile = { uid?: string; email?: string; displayName?: string; photoURL?: string; [key: string]: unknown }
1352
- ```
1353
-
1354
- ---
1355
-
1356
- ## Utilities
1357
-
1358
- ```ts
1359
- import { cn, converter, Sanitizer, Order, path, fetchJson, fetchRest, fetchWithRetry } from '@llmnative/react'
1360
- import { getKeywordIdeas, getGoogleTrendsData, getGoogleTrendsRelated } from '@llmnative/react'
1361
-
1362
- cn('base', condition && 'extra', className) // tailwind-merge classname
1363
- new Sanitizer().sanitize(html) // XSS sanitizer (non usare dangerouslySetInnerHTML)
1364
- new Order().order(records, { field: 'name', dir: 'asc' }) // ordina record array
1365
- fetchJson(url, init?) // fetch + JSON parse + error handling
1366
- fetchRest(path, init?) // fetch REST con auth
1367
- getKeywordIdeas(keyword) // Google Ads Keyword Ideas
1368
- getGoogleTrendsData(keyword) // Google Trends interest over time
1369
- getGoogleTrendsRelated(keyword) // Google Trends related queries
1370
- ```
1371
-
1372
- ---
1373
-
1374
- ## Scaffold (npx create)
1375
-
1376
- ```bash
1377
- npx @llmnative/react create
1378
- npx @llmnative/react create --yes --provider=mock --template=admin
1379
- ```
1380
-
1381
- Flag disponibili: `--name` `--provider=firebase|supabase|mock|custom` `--ai-provider` `--icon-provider` `--theme` `--template=blank|admin|crm|inventory|project`
1382
-
1383
- **5 template disponibili:**
1384
- | Template | Pagine incluse | Uso tipico |
1385
- |---|---|---|
1386
- | `blank` | home | App vuota — massima libertà |
1387
- | `admin` | home, users, roles, settings | Dashboard admin/backoffice |
1388
- | `crm` | home, companies, contacts, deals | CRM |
1389
- | `inventory` | home + gestione inventario | Magazzino |
1390
- | `project` | home + gestione progetti | PM tool |
1391
-
1392
- **Struttura generata in-place nella current directory:**
1393
- ```
1394
- src/
1395
- index.tsx ← App entry
1396
- vite-env.d.ts
1397
- conf/
1398
- app.ts ← providers config letta da .env (VITE_PROVIDER, VITE_AI_PROVIDER...)
1399
- menu.ts ← MenuConfig con path, title, icon, page (layout opzionale: c'è LayoutDefault)
1400
- data/
1401
- mockData.ts ← dati fake per MockDataProvider
1402
- layouts/
1403
- Default.tsx ← layout wrapper (header + sidebar + main + footer)
1404
- sections/
1405
- Header.tsx / Sidebar.tsx / PageHeader.tsx / Footer.tsx ← solo componenti pubblici (SideNav, AuthButton, ActionButton, Brand, Breadcrumbs)
1406
- pages/
1407
- home/HomePage.tsx
1408
- [altre pagine dal template scelto]
1409
- styles/
1410
- globals.css ← @import '@llmnative/react/dist/index.css' + Tailwind override
1411
- .env ← VITE_PROVIDER, VITE_THEME, chiavi API (non committare)
1412
- .env.example ← stessa struttura, valori vuoti (committare)
1413
- vite.config.ts / tsconfig.json / postcss.config.js / index.html
1414
- ```
1415
-
1416
- **Pattern conf/app.ts** (auto-generato, leggere per capire come funziona):
1417
- ```ts
1418
- import type { AppProvidersConfig } from '@llmnative/react'
1419
- import { mockData } from '../data/mockData'
1420
- const env = import.meta.env
1421
-
1422
- export const providers: AppProvidersConfig = {
1423
- mock: { data: mockData },
1424
- ai: { openaiApiKey: env.VITE_OPENAI_API_KEY ?? '' }, // AI dentro providers, non prop <App>
1425
- services: { data: 'mock', auth: 'googleAuth' },
1426
- }
1427
- ```
1428
-
1429
- Driver per `VITE_PROVIDER`: `firebase` → `firestoreDb` + `firestorage` + `firebaseAuth`; `supabase` → `supabaseDb` + `supabaseStorage` + `supabaseAuth`; `mock` → `mock` + `googleAuth`. `index.tsx` passa `LayoutDefault={Default}` ad `<App>`.
1430
-
1431
- ---
1432
-
1433
- ## Gotchas — pitfall frequenti
1434
-
1435
- | Errore | Causa | Fix |
1436
- |---|---|---|
1437
- | `aiConfig` prop non esiste su `<App>` | La config AI va dentro `providers.ai`, NON come prop separata | `providers={{ ai: { openaiApiKey } }}` |
1438
- | `inputType` o `Input.Number`/`Input.Email` non esistono | Il tipo HTML è la prop `type` | `<Input type="number">`, `<Input type="email">`, `<Input type="date">` |
1439
- | `Menu context=` non esiste | La prop si chiama `menuKey` | `<Menu menuKey="main" />` |
1440
- | `SignInButton` non esiste | Rinominato | `<AuthButton />` |
1441
- | `firebase: { config: ... }` errato | FirebaseConfig è flat | `firebase: { apiKey, authDomain, databaseURL, ... }` |
1442
- | `google: { oAuth2: { clientId } }` errato | GoogleProviderConfig è flat | `google: { clientId, scope }` |
1443
- | `children` su `Grid` | Grid non accetta children | Usa `form={<Fields />}` per il form inline |
1444
- | `recordId` su `Form` | Prop non esiste | La chiave del record viene da URL automaticamente |
1445
- | `MenuConfig` non importabile | Era type interno | Ora esportato: `import type { MenuConfig } from '@llmnative/react'` |
1446
- | `GridCellContext` non importabile | Mancava dall'index | Ora esportato: `import type { GridCellContext } from '@llmnative/react'` |
1447
-
1448
- ---
1449
-
1450
- ## Dove cercare nel codice sorgente
1451
-
1452
- | Cosa | File |
1453
- |---|---|
1454
- | Form | `src/components/widgets/Form.tsx` |
1455
- | Grid | `src/components/widgets/Grid.tsx` |
1456
- | Prompt | `src/components/widgets/Prompt.tsx` |
1457
- | Campi form | `src/components/ui/fields/` |
1458
- | Blocks (Menu, Toolbar, Notifications, Dropdown…) | `src/components/blocks/` |
1459
- | Provider data Firebase (RTDB) | `src/providers/data/firebase.ts` |
1460
- | Provider data Firestore | `src/providers/data/firestore.ts` |
1461
- | Provider data Supabase | `src/providers/data/supabase.ts` |
1462
- | Provider storage Firebase | `src/providers/storage/firebase.ts` |
1463
- | Provider storage Supabase | `src/providers/storage/supabase.ts` |
1464
- | Provider auth Firebase | `src/providers/auth/firebase/FirebaseAuthProvider.ts` |
1465
- | Provider auth Supabase | `src/providers/auth/supabase/` |
1466
- | Provider auth Google | `src/providers/auth/google/` |
1467
- | Provider auth Dropbox | `src/providers/auth/dropbox/` |
1468
- | Provider email Gmail | `src/providers/email/google/GmailEmailProvider.ts` |
1469
- | Provider AI definitions | `src/providers/ai/` |
1470
- | Provider credenziali | `src/providers/credentials/` |
1471
- | Provider session | `src/providers/ProviderSession.ts` |
1472
- | Proxy CORS | `src/providers/proxy.ts` |
1473
- | Motion system | `src/motion.ts` |
1474
- | I18n | `src/I18n.tsx` |
1475
- | Dropbox integration | `src/providers/dropbox/` |
1476
- | Interfaccia DataProvider | `src/providers/data/DataProvider.ts` |
1477
- | Interfaccia StorageProvider | `src/providers/storage/StorageProvider.ts` |
1478
- | Interfaccia AuthProvider | `src/providers/auth/AuthProvider.ts` |
1479
- | Interfaccia EmailProvider | `src/providers/email/EmailProvider.ts` |
1480
- | Interfaccia AIProvider | `src/providers/ai/AIProvider.ts` |
1481
- | Registry provider | `src/providers/manifest.ts` |
1482
- | Config provider | `src/providers/ProviderConfiguration.ts` |
1483
- | SEO / Google Trends | `src/providers/seo/google/` |
1484
- | Sistema tema | `src/Theme.tsx` |
1485
- | Config runtime | `src/Config.tsx` |
1486
- | Tipi TypeScript | `src/types/` |
1487
- | Utilities pure | `src/libs/` |
1
+ # @llmnative/react — Riferimento completo
2
+
3
+ Framework React AI-first per generazione deterministica di UI. Data-driven di default, schema-driven opzionale.
4
+ Versione 1.0.0 — Apache-2.0
5
+
6
+ ```bash
7
+ npm install @llmnative/react
8
+ import '@llmnative/react/dist/index.css'
9
+ ```
10
+
11
+ ---
12
+
13
+ ## Regole fondamentali
14
+
15
+ - Tutti gli import da `'@llmnative/react'` — mai da sottodirectory
16
+ - I provider sono iniettati via `<App providers={...}>` — mai importati direttamente nei componenti
17
+ - Usare `useDataProvider()`, `useAuthProvider()`, ecc. nei componenti, non istanze dirette
18
+ - TypeScript strict: no `any` — usare `unknown`, `Record<string, unknown>`, tipi specifici
19
+ - Naming props: `view` (presentazione contenuto), `appearance` (guscio visuale), `layout` (disposizione spaziale), `mode` (comportamento operativo), `variant` (tono semantico), `position` (ancoraggio), `size` (scala)
20
+
21
+ ---
22
+
23
+ ## App — Entry point
24
+
25
+ ```tsx
26
+ <App
27
+ providers={{
28
+ firebase: { // FirebaseConfig — tutte le chiavi flat
29
+ apiKey, authDomain, databaseURL, projectId,
30
+ storageBucket, messagingSenderId, appId,
31
+ },
32
+ supabase: { url, anonKey },
33
+ google: { clientId, scope }, // flat — NON { oAuth2: { clientId } }
34
+ dropbox: { clientId }, // DropboxConfig
35
+ mock: { data: mockData },
36
+ ai: { // AIConfig — NON una prop separata di <App>
37
+ openaiApiKey, geminiApiKey, anthropicApiKey,
38
+ deepSeekApiKey, mistralApiKey, openRouterApiKey,
39
+ groqApiKey,
40
+ cloudflare: { apiToken, accountId }, // Cloudflare Workers AI
41
+ },
42
+ proxy: { enable: true, route: '/api/proxy' },
43
+ custom: { data: { myAdapter: new MyDataProvider() } },
44
+ services: {
45
+ data: 'dbRealtime', // DataDriverName
46
+ storage: 'firestorage', // StorageDriverName | null
47
+ auth: 'googleAuth', // AuthDriverName | null
48
+ email: 'gmail', // EmailDriverName | null
49
+ ai: 'openai', // AIDriverName | null
50
+ credentials: 'googleServiceAccount',
51
+ },
52
+ }}
53
+ iconProvider="lucide" // 'lucide' | 'phosphor' | AppIconProviderConfig
54
+ themeProvider="default" // 'default' | 'flat' | 'cyber' | AppThemeProviderConfig
55
+ menuConfig={menuConfig}
56
+ importPage={(path) => import(path)}
57
+ LayoutDefault={MyLayout} // layout wrapper custom (opzionale): avvolge ogni voce di menuConfig senza `layout`
58
+ appName="MyApp" // nome app (opzionale)
59
+ i18n={{ locale: 'it', translations: { ... } }}
60
+ contextProviders={MyContextProvider}
61
+ errorReportUrl="/api/errors"
62
+ debug={import.meta.env.DEV}
63
+ />
64
+ ```
65
+
66
+ ### Testi dei componenti (i18n)
67
+
68
+ I testi fissi dei componenti vengono da `I18nDict` e seguono `i18n.locale` (en, it, de, ru, ar, zh
69
+ inclusi): un testo si cambia con `i18n.translations`, non nel codice. Namespace dei componenti:
70
+ `notifications` (`markAllRead`, `empty`, `emptyHint`), `errorBoundary` (schermata d'errore; fuori da
71
+ `<I18nProvider>`, come l'`ErrorBoundary` radice di `<App>`, resta in inglese), `themeSwitcher`,
72
+ `imageField` (anche i dialoghi immagine di `RichText`), `richText`, `tabDynamic` (`addTab`),
73
+ `breadcrumbs` (`label`, l'`aria-label` del `nav`), `grid` (`tableView`, `galleryView`, `columns`,
74
+ `fields`), `prompt` (anche `Chatbot`: `role`, `language`, `voice`, `style`, `temperature`, `stop`),
75
+ `chat` (`ChatReasoning` e `ChatQuestions`: `reasoning`, `reasoningFor`, `other`, `review`, `submit`, …;
76
+ il codice di `ChatMarkdown` usa `code`).
77
+
78
+ ```tsx
79
+ <App i18n={{ locale: 'it', translations: { it: { notifications: { emptyHint: 'Niente di nuovo' } } } }} />
80
+ ```
81
+
82
+ ### AppThemeProviderConfig
83
+ ```ts
84
+ {
85
+ mode?: 'light' | 'dark' | 'system';
86
+ theme: string;
87
+ themes?: Record<string, {
88
+ preset?: { colors?: ColorScale; radius?: number; font?: string };
89
+ motion?: MotionRegistry;
90
+ components?: Record<string, Partial<ThemeComponent>>;
91
+ }>;
92
+ themeOverride?: Record<string, Partial<ThemeComponent>>;
93
+ }
94
+ ```
95
+
96
+ Built-in themes: `default`, `flat`, `cyber`, `vscode-dark-modern`, `vscode-light-modern`, `vscode-dark-plus`, `vscode-light-plus`, `tokyo-night`, `tokyo-night-light`.
97
+ The VS Code and Tokyo Night themes are compiled from the original VS Code theme JSON (`scripts/themes/vscode`), filling the keys a theme does not list with VS Code's color registry defaults; each one has both modes (`vscode-dark-*` starts dark, `vscode-light-*` light). Status colors (destructive/success/warning/info) stay the framework's.
98
+
99
+ `ColorScale` tokens: `background`, `foreground`, `canvas` (page floor behind cards and panels, `bg-canvas`; falls back to `background`), `card`, `popover`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `success`, `warning`, `info` (each with its `*Foreground`), `border`, `input` (border of form fields), `ring`. A consumer bridging the tokens in its own `@theme inline` adds `--color-canvas: hsl(var(--rf-canvas, var(--rf-background)));`.
100
+
101
+ ---
102
+
103
+ ## Provider System
104
+
105
+ ### Matrix completa
106
+
107
+ | Servizio | Interfaccia | Driver | Config key | Obbligatorio |
108
+ |---|---|---|---|---|
109
+ | data | `DataProviderAdapter` | `mock`, `dbRealtime`, `firestoreDb`, `supabaseDb` | `providers.mock/firebase/supabase` | sì (fallback: mock) |
110
+ | storage | `StorageProviderAdapter` | `firestorage`, `supabaseStorage` | `providers.firebase/supabase` | no |
111
+ | auth | `AuthProviderAdapter` | `googleAuth`, `firebaseAuth`, `supabaseAuth`, `dropboxAuth`, `githubAuth` | `providers.google/firebase/supabase/dropbox/github` | sì (fallback: googleAuth) |
112
+ | email | `EmailProviderAdapter` | `gmail` | `providers.google` | no |
113
+ | ai | `AIProviderAdapter` | `openai`, `gemini`, `anthropic`, `deepseek`, `mistral`, `openrouter`, `opencode`, `opencode-go`, `cloudflare`, `groq` | `Config.tsx AIConfig` | no |
114
+ | credentials | `CredentialsAdapter` | `googleServiceAccount` | `providers.google.serviceAccount` | no |
115
+
116
+ ### Firebase: quale app usano auth, dati e storage
117
+
118
+ - `providers.firebase` inizializza l'app Firebase **di default** dell'SDK (`[DEFAULT]`). `firestoreDb` e `firestorage` la
119
+ usano, e il driver `firebaseAuth` (`services.auth: 'firebaseAuth'`) fa login su quella stessa app: login, Firestore
120
+ e Storage condividono la sessione.
121
+ - Un'app che chiama già `initializeApp(config)` per conto suo condivide l'app di default con il framework solo se ha lo
122
+ **stesso `appId`**. Con un `appId` diverso il framework chiude quell'app e la reinizializza con la propria config, e
123
+ la sessione dell'app cade. Per questo il client Firebase si crea una volta sola: o `providers.firebase`, o il codice
124
+ dell'app con la stessa config.
125
+ - Una sessione separata, che non deve cadere quando l'app di default cambia (cambio di tenant): istanziare
126
+ `new FirebaseAuthProvider({ config, appName: 'control-plane' })`. `appName` è un'app con un nome suo; senza
127
+ `config` usa un'app già inizializzata con quel nome.
128
+ - Le Cloud Functions callable: `callFirebaseFunction(name, data, appName?, region?)`, sull'app di default se non si
129
+ passa `appName`. Il token della sessione lo aggiunge l'SDK.
130
+
131
+ ### Layout per voce di menu
132
+
133
+ Ogni voce di `menuConfig` è una route. Viene avvolta da `item.layout` se presente, altrimenti da `LayoutDefault` di
134
+ `<App>`, altrimenti da `LayoutEmpty`. Una pagina con un guscio diverso, per esempio l'accesso a tutta pagina senza
135
+ sidebar, dichiara `layout` sulla sua voce:
136
+
137
+ ```tsx
138
+ export const menuConfig: MenuConfig = {
139
+ main: [
140
+ { path: '/', title: 'Home', icon: 'home', page: HomePage }, // LayoutDefault
141
+ ],
142
+ public: [
143
+ { path: '/login', page: LoginPage, layout: AccessLayout }, // senza header né sidebar
144
+ ],
145
+ };
146
+ ```
147
+
148
+ La home va dichiarata con `path: '/'`: senza quella voce, `/` mostra "Missing Page".
149
+
150
+ ### Hooks
151
+
152
+ ```ts
153
+ const data = useDataProvider() // DataProviderAdapter | null
154
+ const storage = useStorageProvider() // StorageProviderAdapter | null
155
+ const auth = useAuthProvider() // AuthProviderAdapter | null
156
+ const email = useEmailProvider() // EmailProviderAdapter | null
157
+ const ai = useAIProvider() // AIProviderAdapter | null
158
+ const creds = useCredentialsProvider() // CredentialsAdapter | null
159
+
160
+ // Named provider (multi-provider setup)
161
+ const supabase = useDataProvider('supabase')
162
+ ```
163
+
164
+ ### GitHub (`githubAuth`, sessione sul server dell'app)
165
+
166
+ GitHub richiede il client secret per scambiare il codice: scambio, token e sessione stanno sul server
167
+ dell'app, il browser vede solo il profilo. Config `providers.github = { sessionUrl: '/api/auth/github' }`,
168
+ driver `auth: 'githubAuth'`; funziona con `AuthButton` (pulsante e `aspect="avatar"`) e
169
+ `useAuthProvider('githubAuth')`. Il server espone sotto `sessionUrl`:
170
+
171
+ | Rotta | Risposta |
172
+ |---|---|
173
+ | `GET /login?returnTo=<path>[&scope=…]` | redirect a GitHub, poi di nuovo a `<path>` |
174
+ | `GET /me` | `{ login, name, avatarUrl, email }`, 401 senza sessione |
175
+ | `POST /logout` | chiude la sessione |
176
+
177
+ Lato server, da `@llmnative/react/server`:
178
+
179
+ ```ts
180
+ const oauth = createGitHubOAuth({ clientId, clientSecret, scopes: ['read:org'] })
181
+ oauth.authorizeUrl({ state, redirectUri, codeChallenge }) // PKCE S256
182
+ const { accessToken } = await oauth.exchangeCode({ code, redirectUri, codeVerifier }) // GitHubOAuthError se rifiutato
183
+ await oauth.getUser(accessToken) // { login, name, avatarUrl, email }
184
+ await oauth.getOrganizations(accessToken) // [{ login, avatarUrl, role: 'admin' | 'member' }]
185
+ ```
186
+
187
+ ### Interfacce principali
188
+
189
+ ```ts
190
+ // DataProviderAdapter
191
+ read(path, options?) / set(path, data) / update(path, data) / remove(path)
192
+ subscribe(path, callback: (records: RecordArray) => void): () => void
193
+ count?(path, where?): Promise<number>
194
+
195
+ // StorageProviderAdapter
196
+ upload(file, path): Promise<string|undefined>
197
+ getURL(path) / download(path): Promise<Blob|undefined> / delete(path): Promise<boolean>
198
+
199
+ // AuthProviderAdapter
200
+ signIn(intent?, options?) / signOut() / getUser(): UserProfile|null
201
+ onAuthChange(callback) / getAccessToken(): Promise<string|null>
202
+ ```
203
+
204
+ ### Auth sign-in methods
205
+
206
+ | method | Firebase | Supabase | Google |
207
+ |---|---|---|---|
208
+ | `password` | ✅ | ✅ | — |
209
+ | `anonymous` | ✅ | ✅ | — |
210
+ | `magic_link` | — | ✅ | — |
211
+ | `oauth` (github, apple…) | ✅ | ✅ | — |
212
+ | GIS + scoped token | — | — | ✅ |
213
+
214
+ ```ts
215
+ await auth.signIn({ method: 'password', email: '...', password: '...' })
216
+ await auth.signIn({ method: 'oauth', provider: 'github' })
217
+ // Google: token per API scope
218
+ const token = await auth.getAccessToken('https://www.googleapis.com/auth/gmail.send')
219
+ ```
220
+
221
+ ### CredentialsAdapter (Google Service Account)
222
+ ```tsx
223
+ // Chiama Google APIs lato browser senza Node.js
224
+ <App providers={{ google: { serviceAccount: { clientEmail, privateKey } }, services: { credentials: 'googleServiceAccount' } }} />
225
+
226
+ const creds = useCredentialsProvider()
227
+ const token = await creds.getToken('https://www.googleapis.com/auth/solar')
228
+ ```
229
+
230
+ ### Provider custom
231
+ ```ts
232
+ import { DataProviderAdapter, RecordArray } from '@llmnative/react'
233
+
234
+ export class RestDataProvider implements DataProviderAdapter {
235
+ async read(path: string): Promise<unknown> { ... }
236
+ async set(path: string, data: object): Promise<void> { ... }
237
+ async update(path: string, data: object): Promise<void> { ... }
238
+ async remove(path: string): Promise<void> { ... }
239
+ subscribe(path: string | undefined, callback: (records: RecordArray) => void): () => void {
240
+ return () => undefined
241
+ }
242
+ }
243
+ ```
244
+
245
+ ---
246
+
247
+ ## Grid — CRUD table/gallery
248
+
249
+ ```tsx
250
+ <Grid
251
+ path="/users" // DataProvider-backed — oppure records={array} per in-memory
252
+ columns={[
253
+ { key: 'name', label: 'Nome', sortable: true },
254
+ { key: 'email', label: 'Email' },
255
+ { key: 'role', label: 'Ruolo', render: ({ value }) => <Badge>{value}</Badge> },
256
+ { key: 'date', label: 'Data', render: 'date' },
257
+ ]}
258
+ actions={['add', 'edit', 'delete']} // oppure Record<string, GridAction>
259
+ view="table" // 'table' | 'gallery'
260
+ pagination={{ limit: 20 }}
261
+ groupBy="status"
262
+ sortable={{ field: 'name', dir: 'asc' }}
263
+ form={<FormFields />}
264
+ onSave={async ({ record, action }) => record._key}
265
+ onComplete={async ({ record, action }) => true}
266
+ selection={{ mode: 'multiple', onChange: (sel) => {} }}
267
+ reorderable
268
+ onReorder={(records, meta) => {}}
269
+ header={(ctx) => <div>{ctx.title}</div>}
270
+ footer={(ctx) => <div>{ctx.records.length} records</div>}
271
+ />
272
+ ```
273
+
274
+ | Prop | Tipo | Note |
275
+ |---|---|---|---|
276
+ | `path` | `string \| "fromUrl"` | Path provider-backed |
277
+ | `records` | `RecordArray` | Dati in memoria (alternativa a path) |
278
+ | `columns` | `GridColumn[]` | `{ key, label, sortable?, render?, className? }` |
279
+ | `actions` | `Array<'add'\|'edit'\|'delete'> \| Record<string, GridAction>` | |
280
+ | `view` | `'table' \| 'gallery'` | |
281
+ | `groupBy` | `string \| string[]` | |
282
+ | `pagination` | `PaginationParams` | `{ limit, page?, maxPageButtons?, align?, sticky? }` |
283
+ | `sortable` | `boolean \| OrderConfig` | |
284
+ | `selection` | `false \| 'single' \| 'multiple' \| GridSelectionConfig` | |
285
+ | `form` | `ReactElement \| ((ctx) => ReactNode)` | Form condiviso per CRUD |
286
+ | `onSave` | `GridMutationSaveHandler` | `async ({ record, action }) => string` |
287
+ | `onDelete` | `GridMutationDeleteHandler` | `async ({ record }) => string` |
288
+ | `onComplete` | `GridAfterActionHandler` | `async ({ record, action }) => boolean` |
289
+ | `onRowClick` | `(record) => void` | |
290
+ | `reorderable` | `boolean` | Abilita drag & drop righe |
291
+ | `onReorder` | `GridReorderHandler` | `(records, meta) => void` |
292
+ | `onLoad` | `(records) => TRecord[]` | Trasforma records dopo il fetch |
293
+ | `editDeepLink` | `boolean` | Sincronizza modal edit con URL |
294
+ | `audit` | `boolean` | Logga mutazioni |
295
+ | `sticky` | `'top' \| 'bottom'` | Header/footer sticky |
296
+ | `wrapperClassName` | `string` | CSS wrapper esterno |
297
+ | `loading` | `boolean` | Stato loading override |
298
+ | `title` | `ReactNode` | Titolo header grid |
299
+ | `before` / `after` | `ReactNode` | Slot prima/dopo il contenuto |
300
+ | `header` / `footer` | `ReactNode \| ((ctx) => ReactNode)` | Header/footer custom |
301
+ | `searchable` | `boolean \| GridSearchConfig` | Search box nell'header default — `{ fields?, placeholder? }` |
302
+ | `filters` | `GridFilterConfig[]` | Checkbox toggle nell'header default, applicati PRIMA di `searchable` — `{ key, label, defaultValue?, predicate(record, value) }` |
303
+
304
+ **GridAction kind:** `'modal'` (default), `'route'`, `'external'`, `'inline'`, `'delete'`
305
+
306
+ ---
307
+
308
+ ## Form — form CRUD con lifecycle
309
+
310
+ ```tsx
311
+ <Form
312
+ path="/products"
313
+ appearance="card" // 'card' | 'empty'
314
+ showBack
315
+ defaultValues={{ status: 'draft' }}
316
+ keyGenerator={() => `prod_${Date.now()}`}
317
+ onLoad={(data) => { /* side-effect hook, non trasforma */ }}
318
+ onSave={async ({ record, action }) => { /* return storagePath or undefined */ }}
319
+ onDelete={async ({ record }) => { /* confirm */ }}
320
+ onComplete={async ({ action }) => { if (action === 'save') navigate('/products') }}
321
+ >
322
+ <Input name="title" label="Titolo" required />
323
+ <Input name="price" label="Prezzo" type="number" />
324
+ <Input name="address.city" label="Città" /> {/* oggetto annidato */}
325
+ <Repeat name="tags" defaultLength={1}>
326
+ {(i) => <Input name={`tags.${i}`} label={`Tag ${i+1}`} />}
327
+ </Repeat>
328
+ </Form>
329
+ ```
330
+
331
+ | Prop | Tipo | Note |
332
+ |---|---|---|---|
333
+ | `path` | `string` | Path provider-backed |
334
+ | `appearance` | `'card' \| 'empty'` | Guscio visuale |
335
+ | `controller` | `FormController` | Controller condiviso per bottoni custom esterni |
336
+ | `showBack` | `boolean` | Pulsante Back nel footer |
337
+ | `defaultValues` | `RecordProps` | Valori iniziali per nuovo record |
338
+ | `keyGenerator` | `(record) => string` | Generatore chiave primaria custom |
339
+ | `draftBucket` | `string` | Bucket stabile che isola la bozza locale; omettilo per disabilitare la persistenza |
340
+ | `noticeAnchorRef` | `React.RefObject<HTMLElement>` | Ancoraggio sticky per notifiche |
341
+ | `showNotice` | `boolean` | Mostra notifiche save/delete (default `true`) |
342
+ | `onSubmit` | `FormSubmitHandler` | `async ({ record }) => void`. **Form d'azione** (CR-125): non legge e non scrive nulla; invia anche senza modifiche e con Invio in un campo; niente pulsante Salva né "Salvato" (il pulsante è tuo: `useFormController().save()`); l'errore lanciato appare nel suo avviso; restituire `false` = è fallito e la pagina dice già perché (nessun avviso né `onComplete`). Esclude `path`, `onSave`, `onDelete`, `keyGenerator` (errore di tipo e a runtime). Sostituisce `requireChanges={false}` della 1.x |
343
+ | `children` | `ReactNode \| ((fields) => ReactNode) \| (({ record }) => ReactNode)` | Contenuto form |
344
+ | `header` | `ReactNode` | Contenuto header custom |
345
+ | `footer` | `ReactNode` | Contenuto footer custom |
346
+ | `wrapperClassName` | `string` | CSS wrapper esterno |
347
+ | `headerClassName` | `string` | CSS header |
348
+ | `className` | `string` | CSS body form |
349
+ | `footerClassName` | `string` | CSS footer |
350
+ | `log` | `boolean` | Logga ogni modifica su console |
351
+ | `onLoad` | `(record) => void` | Side-effect dopo il caricamento (non trasforma) |
352
+ | `onRecordChange` | `(record) => void` | Cambio campo in tempo reale |
353
+ | `onSave` | `FormSaveHandler` | `async ({ record, prevRecord?, storagePath?, action }) => string \| undefined`. Form che **salva un record**, solo se un campo è cambiato: restituisce il path dove scriverlo, `undefined` = il `path` del Form (senza `path` non scrive), `false` = è fallito e la pagina dice già perché (non scrive, nessun avviso, `save()` dà `false`). Per un'azione (chiamare un comando) usa `onSubmit`. L'errore lanciato appare nell'avviso del Form |
354
+ | `onDelete` | `FormDeleteHandler` | `async ({ record }) => string \| undefined` |
355
+ | `onComplete` | `FormFinallyHandler` | `async ({ record, action }) => boolean`; `action` è `'create' \| 'update' \| 'delete' \| 'submit'` |
356
+
357
+ **Form controller** — stato e azioni native del form accessibili da qualsiasi punto della pagina.
358
+
359
+ Due hook:
360
+
361
+ | Hook | Dove usarlo | Cosa restituisce |
362
+ |---|---|---|
363
+ | `useFormController(controller?)` | Ovunque (anche fuori dal form) | Crea o si aggancia a un `FormController`. Senza argomenti ne crea uno locale stabile. Con un `controller` esistente ci si aggancia. |
364
+ | `useFormActions()` | Solo dentro `<Form>` (legge dal context) | Restituisce lo stesso `FormController` del form genitore. Lancia errore se usato fuori. |
365
+
366
+ Esempio — bottone custom fuori dal form:
367
+ ```tsx
368
+ const form = useFormController();
369
+
370
+ <ActionButton disabled={form.saveDisabled} onClick={() => { void form.save(); }} />
371
+ <Form controller={form} draftBucket="workspace/acme" onSave={...} />
372
+ ```
373
+
374
+ Esempio — form d'azione: chiama un comando, non salva nulla (CR-125):
375
+ ```tsx
376
+ const form = useFormController();
377
+
378
+ <Form controller={form} appearance="empty" onSubmit={({ record }) => call('verifyLoginCode', record)}>
379
+ <Input name="email" type="email" label="Email" required />
380
+ <Input name="code" label="Codice" required inputMode="numeric" maxLength={6} pattern="[0-9]{6}" autoComplete="one-time-code" />
381
+ </Form>
382
+ <LoadingButton label="Entra con il codice" loading={form.isSaving} onClick={() => form.save()} />
383
+ ```
384
+ Con valori proposti (avviare una release con la versione proposta) si inviano anche senza modificarli:
385
+ `<Form controller={form} appearance="empty" defaultValues={{ version: '1.4.0' }} onSubmit={({ record }) => start(record)}>`.
386
+
387
+ Esempio — uso dentro il form (senza passare controller):
388
+ ```tsx
389
+ function MySaveButton() {
390
+ const form = useFormActions();
391
+ return <LoadingButton disabled={form.saveDisabled} loading={form.isSaving} onClick={form.save} />;
392
+ }
393
+ ```
394
+
395
+ Stato esposto dal controller: `record`, `isNewRecord`, `isDirty`, `canSave`, `canDelete`, `isSaving`, `isDeleting`, `saveDisabled`, `deleteDisabled`, `hasDraft`, `draftStatus`, `errors`, `hasErrors`.
396
+
397
+ Azioni: `save()`, `delete()`, `reset()`, `restoreDraft()`, `discardDraft()`.
398
+
399
+ ---
400
+
401
+ ## Campi form
402
+
403
+ ### Input
404
+ ```tsx
405
+ <Input name="x" label="..." required /> // text (default)
406
+ <Input name="price" type="number" min={0} max={100} step={0.01} />
407
+ <Input name="email" type="email" />
408
+ <Input name="phone" type="tel" autoComplete="tel" /> // o <Tel name="phone" />
409
+ <Input name="code" inputMode="numeric" maxLength={6} pattern="[0-9]{6}" autoComplete="one-time-code" /> // un codice monouso
410
+ <Input name="pwd" type="password" />
411
+ <Input name="date" type="date" />
412
+ <Input name="color" type="color" />
413
+ <TextArea name="bio" minHeight={96} />
414
+ <Checkbox name="active" valueChecked="yes" />
415
+ <Switch name="gate" label="Passa dalla CI" disabled title="L'agente non committa" /> // CR-116: disabled (non cambia né con il clic né da tastiera), title anche sull'etichetta: il motivo al passaggio del mouse
416
+ ```
417
+ `Input` props: `name`(req) `label` `type` `required` `disabled` `placeholder` `min` `max` `step` `inputMode` `maxLength` `pattern` `autoComplete` `validator:(v)=>string|undefined`
418
+ `type`: `'text' | 'number' | 'email' | 'tel' | 'password' | 'color' | 'date' | 'time' | 'datetime-local' | 'week' | 'month' | 'range' | 'checkbox' | 'radio' | 'url'`
419
+
420
+ **NOTA**: il tipo HTML si passa con `type`. `inputType` e i sotto-componenti `Input.Number`/`Input.Email`/`Input.Date` non esistono.
421
+
422
+ **Label come nodo**: `label` di tutti i campi di inserimento (`Input`, `TextArea`, `Checkbox`, `Select`, `Autocomplete`, `Checklist`, `Upload*`, `ImageField`, `CodeEditor`, `RichText`, `AddressAutocomplete`, `UploadCSV`, `Prompt`) accetta una stringa o un nodo React (tipo `FieldLabel`), disegnato dentro la `<label>` prima dell'asterisco di obbligatorio. Dove serve un testo (messaggio "obbligatorio", `aria-label`) si usa il testo del nodo, ricavabile anche con `labelText(node)`.
423
+ ```tsx
424
+ <Select name="skill" required options={skills} label={<>Skill <ActionButton icon="info" ariaLabel="Apri la skill" variant="link" onClick={openSkill} /></>} />
425
+ ```
426
+
427
+ ### Select / Autocomplete / Checklist
428
+ ```tsx
429
+ <Select name="role" options={[{ label: 'Admin', value: 'admin' }]} />
430
+ <Select name="categoryId" optionsSource={{ path: '/categories', labelField: 'name', valueField: '_key' }} />
431
+ <Autocomplete name="userId" optionsSource={{ path: '/users', labelField: 'email' }} maxItems={1} />
432
+ <Checklist name="tags" options={['a', 'b', 'c']} />
433
+ ```
434
+
435
+ | Prop | Tipo | Note |
436
+ |---|---|---|
437
+ | `options` | `Option[] \| string[] \| number[]` | |
438
+ | `optionsSource` | `{ path, labelField, valueField?, where?, order?, fieldMap?, onLoad? }` | Carica da provider |
439
+ | `order` | `{ field: 'label' \| 'value', dir: 'asc' \| 'desc' }` | |
440
+ | `placeholderOption` | `Option` | Solo Select |
441
+ | `placeholder` | `string` | Solo Autocomplete |
442
+ | `creatable` | `boolean` | Solo Autocomplete — crea nuovi valori |
443
+ | `onCreate` | `(value) => void \| boolean \| Promise<void \| boolean>` | Solo Autocomplete — restituire `false` per non selezionare il valore creato (es. aprire un modulo di creazione) |
444
+ | `listClassName` / `optionClassName` | `string` | Solo Autocomplete — classi della lista e delle voci (anche da tema `Autocomplete.listClassName` / `optionClassName`) |
445
+ | `minItems` / `maxItems` | `number` | Solo Autocomplete |
446
+ | `itemClassName` | `string` | Solo Checklist |
447
+ | `validator` | `(value) => string \| undefined` | Tutte le varianti |
448
+ | `readOnlyAfterSet` | `boolean` | readonly dopo primo valore |
449
+ | `disabled` | `boolean` | |
450
+ | `title` | `string` | |
451
+ | `feedback` | `string` | Messaggio di errore/suggerimento |
452
+
453
+ `Autocomplete` è una combobox accessibile: la lista (in un portal, larga quanto il campo, stile popover del tema) mostra solo le label e filtra per label/value senza distinzione di maiuscole e accenti; i chip mostrano la label; tastiera: frecce, Invio, Esc (chiude solo la lista, non il `Modal`), Tab, Backspace su campo vuoto rimuove l'ultimo valore. Con `creatable` compare la voce "Crea «testo»". Il valore salvato è sempre l'array dei `value`. `Autocomplete` e `Checklist` sono export nominati: `Select.Autocomplete`/`Select.Checklist` non esistono.
454
+
455
+ ### UploadImage / UploadDocument / UploadCSV
456
+ ```tsx
457
+ <UploadImage name="cover" label="Copertina" /> {/* con crop editor */}
458
+ <UploadDocument name="pdf" label="PDF" />
459
+ <UploadCSV name="data" label="Importa CSV" /> {/* con preview tabellare */}
460
+ ```
461
+
462
+ **UploadImageProps** (eredita FormFieldProps):
463
+ | Prop | Tipo | Note |
464
+ |---|---|---|
465
+ | `name` | `string` (required) | |
466
+ | `uploadPath` | `string` | Path storage per upload automatico |
467
+ | `accept` | `string` | MIME types accettati |
468
+ | `editable` | `boolean` | Abilita crop editor |
469
+ | `multiple` | `boolean` | Multipla selezione |
470
+ | `max` | `number` | Numero massimo file |
471
+ | `feedback` | `string` | Testo placeholder drop zone |
472
+ | `srcsetWidths` | `number[]` | Larghezze varianti responsive |
473
+ | `previewHeight` / `previewWidth` | `number` | Dimensioni preview |
474
+
475
+ **UploadDocumentProps** (eredita FormFieldProps): `name`, `uploadPath`, `accept`, `editable`, `multiple`, `max`
476
+
477
+ ### UploadCSV
478
+ ```tsx
479
+ <UploadCSV name="data" label="Importa CSV" />
480
+ ```
481
+ | Prop | Tipo |
482
+ |---|---|
483
+ | `name` | `string` (required) |
484
+ | `onDataLoaded` | `(data: UploadCSVData) => void` |
485
+ | `onParseField` | `([key, value]) => [key, value] \| undefined` |
486
+ | `normalizeKeys` | `boolean` |
487
+ | `removeEmptyFields` | `boolean` |
488
+ | `delimiter` | `string` |
489
+ | `label` / `icon` | Opzionali |
490
+
491
+ ### ImageField
492
+ ```tsx
493
+ <ImageField
494
+ name="hero"
495
+ label="Hero image"
496
+ defaultValue={{ src: 'https://example.com/hero.jpg', alt: 'Hero shot' }}
497
+ srcsetWidths={[400, 800]}
498
+ uploadPath="/content/images"
499
+ />
500
+ ```
501
+
502
+ Gestisce un oggetto `{ src, alt, srcset?, sizes? }`, preview inline, crop editor e generazione opzionale di varianti responsive.
503
+
504
+ ### CropImage
505
+ ```tsx
506
+ <CropImage img={imageObj} title="Ritaglia" scales={{ '1:1': 1, '4:3': 1.33 }} />
507
+ ```
508
+ Componente di ritaglio indipendente. `img` ha tipo `{ fileName, variants }` dove ogni variante è `{ fileName, type, scale, top, left, width, height, base64? }`. Usato internamente da `UploadImage` e `ImageField`, ma esportato pubblicamente per uso standalone.
509
+
510
+ ### CodeEditor
511
+ ```tsx
512
+ <CodeEditor
513
+ name="code"
514
+ label="Template"
515
+ language="html"
516
+ placeholder="Scrivi HTML..."
517
+ minHeight={200}
518
+ maxHeight={600}
519
+ disabled={false}
520
+ required
521
+ validateSyntax
522
+ commands={[{ name: 'wrap-section', handler: ({ value }) => `<section>\n${value}\n</section>` }]}
523
+ commandsTrigger="/"
524
+ />
525
+ ```
526
+ Editor basato su CodeMirror con syntax highlighting e validazione. Linguaggi: `liquid`, `html`, `json`, `js`, `ts`, `css`.
527
+ Quando `commands` e presente, `CodeEditor` apre internamente `ContextMenu` sul trigger configurato.
528
+
529
+ | Prop extra | Tipo |
530
+ |---|---|
531
+ | `language` | `CodeEditorLanguage` |
532
+ | `placeholder` | `string` |
533
+ | `minHeight` / `maxHeight` | `number` |
534
+ | `validateSyntax` | `boolean` |
535
+ | `validator` | `(value) => string \| undefined \| Promise<string \| undefined>` |
536
+ | `extensions` | `unknown[]` |
537
+
538
+ ### RichText
539
+ ```tsx
540
+ <RichText
541
+ name="body"
542
+ label="Article body"
543
+ placeholder="Scrivi..."
544
+ toolbar="fixed" // 'fixed' | 'floating' | false
545
+ toolbarCommands={['bold', 'italic', 'heading1', '|', 'bulletList', 'link']}
546
+ outputFormat="html" // 'html' | 'json' | 'text'
547
+ statusBar={{ tagBreadcrumb: true, wordCount: true }}
548
+ minHeight={200}
549
+ maxHeight={800}
550
+ imageUpload={{ path: '/content/images', srcsetWidths: [400, 800] }}
551
+ commands={[{ name: 'summary', icon: 'sparkles' }]}
552
+ commandsTrigger="/"
553
+ />
554
+ ```
555
+
556
+ | Prop extra | Tipo |
557
+ |---|---|
558
+ | `placeholder` | `string` |
559
+ | `disabled` | `boolean` |
560
+ | `feedback` | `string` |
561
+ | `toolbar` | `'fixed' \| 'floating' \| false` |
562
+ | `toolbarCommands` | `ToolbarCommand[]` |
563
+ | `outputFormat` | `'html' \| 'json' \| 'text'` |
564
+ | `statusBar` | `boolean \| StatusBarConfig` |
565
+ | `minHeight` / `maxHeight` | `number` |
566
+ | `imageUpload` | `RichTextImageUploadConfig` |
567
+ | `documentUpload` | `RichTextDocumentUploadConfig` |
568
+ | `validator` | `(value) => string \| undefined` |
569
+
570
+ ### Repeat — array dinamici
571
+ ```tsx
572
+ <Repeat name="items" defaultLength={3} minItems={1} maxItems={10} label="Elementi" layout="vertical" readOnly>
573
+ {({ index, record, records, remove }) => (
574
+ <>
575
+ <Input name={`items.${index}.name`} label="Nome" />
576
+ <UploadImage name={`items.${index}.photo`} label="Foto" />
577
+ </>
578
+ )}
579
+ </Repeat>
580
+ ```
581
+
582
+ | Prop | Tipo | Note |
583
+ |---|---|---|
584
+ | `name` | `string` (required) | |
585
+ | `children` | `ReactNode \| ((args) => ReactNode)` | Render prop con `{ index, record, records, remove }` |
586
+ | `defaultLength` | `number` | Righe iniziali |
587
+ | `minItems` / `maxItems` | `number` | Min/max righe |
588
+ | `layout` | `'vertical' \| 'horizontal' \| 'inline'` | |
589
+ | `label` | `string` | Etichetta gruppo |
590
+ | `labelPosition` | `'top' \| 'bottom'` | |
591
+ | `hideRemoveButton` | `boolean` | Nascondi rimozione |
592
+ | `readOnly` | `boolean` | Sola lettura |
593
+ | `onChange` | `FieldOnChange` | |
594
+ | `onAdd` / `onRemove` | `(records) => void` | Callback aggiunta/rimozione |
595
+
596
+ ### Prompt — campo AI-potenziato
597
+ ```tsx
598
+ <Prompt
599
+ name="summary"
600
+ mode="run" // 'edit' | 'run'
601
+ onRunPrompt={async (prompt, options) => AI.fetch(prompt)}
602
+ commands={[{ name: 'translate', icon: 'languages', handler: (v) => `Translate:\n${v}` }]}
603
+ commandsTrigger="/" // default automatico quando commands e presente
604
+ attachments
605
+ statusItems={['tokensIn', 'tokensOut', 'contextPercent', 'duration']}
606
+ />
607
+ ```
608
+
609
+ Quando `commands` e presente in `mode="run"`, `Prompt` monta internamente `ContextMenu` sulla textarea del risultato. Il trigger di default e `"/"`; `commandsTrigger` permette di cambiarlo. Il menu diventa searchable automaticamente sopra una soglia interna di comandi.
610
+
611
+ `renderFallback`: quando l'AI non e configurata, `Prompt` monta un fallback testuale; passa `renderFallback` per personalizzarlo (riceve `{ name, label, required, onChange }`).
612
+
613
+ ---
614
+
615
+ ## UI Primitives
616
+
617
+ ### Alert
618
+ ```tsx
619
+ <Alert variant="success">Salvato!</Alert>
620
+ <Alert variant="warning" icon placement="fixed" timeout={3000}>Attenzione</Alert>
621
+ ```
622
+ `variant`: `'info' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'light' | 'dark'`
623
+ `appearance`: `'default' | 'text'`
624
+ `placement`: `'inline' | 'fixed'`
625
+
626
+ ### Badge
627
+ ```tsx
628
+ <Badge variant="success">Attivo</Badge>
629
+ ```
630
+ `variant`: stessi valori di Alert
631
+
632
+ ### Button variants
633
+ ```tsx
634
+ <ActionButton variant="primary" label="Salva" icon="save" onClick={handleSave} />
635
+ <LoadingButton variant="primary" label="Invia" onClick={async (e, setMsg) => { await save(); setMsg('Salvato!') }} />
636
+ <BackLink label="Indietro" />
637
+ <GoSite href="https://..." label="Visita" />
638
+ ```
639
+
640
+ **IButton props:** `variant`, `label`, `icon`, `badge`, `disabled`, `ariaLabel`, `title`, `iconClassName`, `onClick`
641
+ **LoadingButton aggiunge:** `loading`, `loadingLabel` — `onClick` riceve `(e, setMessage?) => Promise<unknown>`
642
+
643
+ ### Card
644
+ ```tsx
645
+ <Card title="Titolo" header={<Actions />} footer={<Pagination />} loading={isLoading}>
646
+ {children}
647
+ </Card>
648
+ ```
649
+
650
+ ### Modal
651
+ ```tsx
652
+ <Modal title="Conferma" size="md" position="center" onClose={() => {}} onSave={handleSave} onDelete={handleDelete} motion="fade" allowFullscreen stackedBehind>
653
+ {content}
654
+ </Modal>
655
+ <ModalYesNo title="Sicuro?" onClose={() => {}} onYes={async () => true}>Eliminare il record?</ModalYesNo>
656
+ <ModalOk title="Completato!" onClose={() => {}}>Salvato.</ModalOk>
657
+ ```
658
+ `size`: `'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'fullscreen'`
659
+ `position`: `'center' | 'top' | 'left' | 'right' | 'bottom'`
660
+ `ModalYesNo` e `ModalOk` sono export separati: `Modal.YesNo` e `Modal.Ok` non esistono.
661
+ Tastiera: Esc chiude il modal in cima alla pila (`closeOnEscape`, default `true`).
662
+ Accessibilità: il dialog ha `role="dialog"`, `aria-modal="true"` (non con `stackedBehind`) e `aria-labelledby` verso `title` (o `header` stringa) — nei test si trova con `getByRole('dialog', { name })`.
663
+
664
+ | Prop | Tipo | Note |
665
+ |---|---|---|
666
+ | `children` | `ReactNode` (required) | |
667
+ | `title` | `ReactNode` | |
668
+ | `header` / `footer` | `ReactNode \| false` | Custom header/footer |
669
+ | `size` / `position` | Come sopra | |
670
+ | `onClose` | `() => void` | |
671
+ | `onSave` / `onDelete` | `(e) => Promise<boolean>` | |
672
+ | `allowFullscreen` | `boolean` | Toggle fullscreen |
673
+ | `showCancel` | `boolean` | Default `true` |
674
+ | `stackedBehind` | `boolean` | Modale sotto un'altra (stile Google Tag Manager): un velo la scurisce, il suo backdrop resta com'è (quello della modale sopra entra sopra, così la pagina si scurisce di un gradino per modale) e non riceve il puntatore. I pannelli laterali aperti sullo stesso lato si impilano da soli: quello in cima prende la larghezza con cui si è aperto il primo, ognuno sotto è largo il 10% in più di quello sopra (100%, 110%, 121%…), così ne sporge una striscia. Solo la larghezza cambia, con la transizione dello slide, in entrata e in uscita |
675
+ | `closeOnBackdrop` | `boolean` | Default `true` |
676
+ | `closeOnEscape` | `boolean` | Default `true` — solo il modal in cima reagisce |
677
+ | `zIndex` | `number` | |
678
+ | `motion` | `MotionReference` | Preset animazione |
679
+ | Classi CSS header/title/subtitle/body/footer | `string` | |
680
+
681
+ ### Table
682
+ ```tsx
683
+ <Table
684
+ columns={[{ key: 'name', label: 'Nome', sort: true }]}
685
+ records={records}
686
+ onRowClick={(record) => {}}
687
+ selection="multiple"
688
+ sortable={{ field: 'name', dir: 'asc' }}
689
+ pagination={{ limit: 20 }}
690
+ onReorder={(records, meta) => {}}
691
+ onSelectionChange={(sel) => {}}
692
+ groupBy="status"
693
+ renderCell={(record, key, index) => <CustomCell />}
694
+ />
695
+ ```
696
+
697
+ | Prop | Tipo |
698
+ |---|---|
699
+ | `columns` | `TableHeaderProp[]` |
700
+ | `records` | `RecordArray` |
701
+ | `sortable` | `boolean \| OrderConfig` |
702
+ | `pagination` | `PaginationParams` |
703
+ | `selection` | `'single' \| 'multiple'` |
704
+ | `onRowClick` | `(record) => void` |
705
+ | `onReorder` | `(records, meta) => void` |
706
+ | `onSelectionChange` | `(selection) => void` |
707
+ | `renderCell` | `(record, key, index) => ReactNode` |
708
+ | `groupBy` | `string \| string[]` |
709
+ | `activeKey` | `string \| null` |
710
+ | `selectedKeys` | `string[]` |
711
+ | `header` / `footer` | `ReactNode` |
712
+ | `headerClassName` / `bodyClassName` / `footerClassName` / `scrollClassName` / `selectedClassName` / `heightClassName` | `string` |
713
+
714
+ ### Gallery
715
+ ```tsx
716
+ <Gallery
717
+ records={records}
718
+ overlays={[{ position: 'topRight', badge: { variant: 'success', children: 'New' } }]}
719
+ columns={3}
720
+ gap={2}
721
+ sortable={{ field: 'name', dir: 'asc' }}
722
+ pagination={{ limit: 20 }}
723
+ onRowClick={(record) => {}}
724
+ onSelectionChange={(sel) => {}}
725
+ header="Titolo"
726
+ groupBy="status"
727
+ />
728
+ ```
729
+
730
+ `position`: `'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'middleLeft' | 'middleRight'`
731
+
732
+ | Prop | Tipo |
733
+ |---|---|
734
+ | `records` | `GalleryRecord[]` (`{ img?, thumbnail?, name?, width?, height? }`) |
735
+ | `columns` | `1 \| 2 \| 3 \| 4 \| 6` |
736
+ | `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` |
737
+ | `overlays` | `GalleryOverlay[]` |
738
+ | `sortable` | `boolean \| OrderConfig` |
739
+ | `pagination` | `PaginationParams` |
740
+ | `onRowClick` | `(record) => void` |
741
+ | `onSelectionChange` | `(selection) => void` |
742
+ | `groupBy` | `string \| string[]` |
743
+ | `selectedKeys` | `string[]` |
744
+ | `header` / `footer` | `ReactNode` |
745
+ | Classi CSS: `headerClassName`, `bodyClassName`, `footerClassName`, `scrollClassName`, `selectedClassName` | `string` |
746
+
747
+ ### Tab
748
+ ```tsx
749
+ import { Tab, TabItem } from '@llmnative/react'
750
+
751
+ <Tab defaultIndex={0} layout="top">
752
+ <TabItem label="Generale">{content1}</TabItem>
753
+ <TabItem label="SEO">{content2}</TabItem>
754
+ </Tab>
755
+ ```
756
+ `layout`: `'default' | 'top' | 'left' | 'right' | 'bottom'`
757
+ Le schede sono `<TabItem>`, export separato: `Tab.Item` non esiste.
758
+
759
+ ### Image
760
+ ```tsx
761
+ <Image src="..." fit="cover" position="center" width={400} height={300} />
762
+ ```
763
+ `fit`: `'cover' | 'contain' | 'fill' | 'scale-down' | 'none'`
764
+
765
+ ### Pagination (standalone)
766
+ ```tsx
767
+ <Pagination records={items} limit={20}>
768
+ {(pageRecords, offset) => pageRecords.map(r => <Row key={r._key} record={r} />)}
769
+ </Pagination>
770
+ ```
771
+
772
+ ### Percentage
773
+ ```tsx
774
+ <Percentage value={75} appearance="bar" variant="success" showText />
775
+ <Percentage value={0.75} appearance="circle" size={60} />
776
+ ```
777
+
778
+ | Prop | Tipo | Default |
779
+ |---|---|---|
780
+ | `value` | `number` | — |
781
+ | `max` | `number` | `100` |
782
+ | `min` | `number` | — |
783
+ | `appearance` | `'bar' \| 'circle'` | `'bar'` |
784
+ | `variant` | `ColorType` | — |
785
+ | `trackVariant` | `ColorType` | — |
786
+ | `thickness` | `number` | — (px) |
787
+ | `showText` | `boolean` | — |
788
+ | `size` | `number` | Diametro in modalità `'circle'` (px) |
789
+ | `fontSize` | `number` | — |
790
+ | `label` | `string` | — |
791
+
792
+ ### DescriptionList
793
+ Coppie etichetta → valore in sola lettura (profilo, dati di un record, riepiloghi), rese come `<dl>`/`<dt>`/`<dd>`. Da usare al posto di markup `<dl>` scritto a mano; per dati modificabili usare i campi di `<Form>`, per molti record `<Table>`/`<Grid>`.
794
+ ```tsx
795
+ <DescriptionList items={[{ label: 'Email', value: 'a@b.it' }, { label: 'Telefono', value: null }]} />
796
+ <DescriptionList layout="stacked" columns={4} truncate items={[{ label: 'Stato', value: <Badge variant="success">Spedito</Badge> }]} />
797
+ ```
798
+
799
+ | Prop | Tipo | Default |
800
+ |---|---|---|
801
+ | `items` | `DescriptionListItem[]` — `{ key?, label, value?, title? }` | — |
802
+ | `layout` | `'horizontal' \| 'stacked'` | `'horizontal'` (etichette a sinistra, impilate su mobile) |
803
+ | `columns` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `1` (solo `stacked`, dal breakpoint `lg`) |
804
+ | `labelWidth` | `string` (lunghezza CSS) | `'12rem'` (solo `horizontal`) |
805
+ | `emptyValue` | `ReactNode` | `'—'` per `null` / `undefined` / `''` (lo `0` resta) |
806
+ | `truncate` | `boolean` | `false`; un valore testuale diventa il suo `title` |
807
+ | Classi | `wrapperClassName`, `className`, `itemClassName`, `labelClassName`, `valueClassName` | tema `DescriptionList` |
808
+
809
+ ### Tree
810
+ Albero accessibile di nodi (cartelle e file, gerarchie): pattern WAI-ARIA tree, un solo tab stop, frecce per muoversi, → / ← per aprire e chiudere, Invio o Spazio per selezionare. Le cartelle si aprono al click, le foglie si selezionano.
811
+ ```tsx
812
+ <Tree nodes={treeFromPaths(paths)} defaultExpanded selectedId={path} onSelect={(node) => setPath(node.id)} ariaLabel="File" />
813
+ <Tree nodes={[{ id: 'a', label: 'Docs', children: [{ id: 'a/1', label: 'README.md', badge: <Badge>new</Badge> }] }]} />
814
+ ```
815
+ | Prop | Tipo | Note |
816
+ |---|---|---|
817
+ | `nodes` | `TreeNode[]` — `{ id, label, icon?, badge?, description?, children?, disabled? }` | `id` unico; con `children` è una cartella |
818
+ | `selectedId` + `onSelect` / `defaultSelectedId` | `string \| null` | selezione controllata o no |
819
+ | `expanded` + `onExpandedChange` / `defaultExpanded` | `string[]`; `defaultExpanded` anche `true` (tutto aperto) | apertura controllata o no |
820
+ | `selectableBranches` | `boolean` (default `false`) | anche le cartelle si selezionano |
821
+ | `ariaLabel` | `string` | nome accessibile |
822
+ | Classi | `wrapperClassName`, `className`, `itemClassName`, `selectedClassName` | tema `Tree` |
823
+
824
+ `treeFromPaths(paths, leaf?)` costruisce l'albero da percorsi `a/b/c`: cartelle prima dei file, ordinati; l'id di un file è il percorso, quello di una cartella `percorso/`; `leaf(path)` decora le foglie (label, icona, descrizione…).
825
+
826
+ ### Dropdown
827
+ ```tsx
828
+ <Dropdown trigger={<Icon name="menu" />} position="end">
829
+ <DropdownItem label="Modifica" icon="edit" onClick={() => {}} />
830
+ <DropdownItem label="Elimina" icon="trash" variant="danger" />
831
+ <DropdownDivider />
832
+ <DropdownHeader label="Azioni rapide" />
833
+ </Dropdown>
834
+ ```
835
+
836
+ Azioni secondarie di una voce (CR-107): `actions` mostra icone in fondo alla riga, al passaggio
837
+ del mouse o al focus (sempre sui dispositivi touch). Non selezionano la voce e lasciano il menu aperto
838
+ salvo `closeOnClick`.
839
+
840
+ ```tsx
841
+ <DropdownItem icon="messages-square" onClick={() => open(chat)}
842
+ actions={[{ icon: 'archive', label: 'Archivia', onClick: () => archive(chat) }]}>
843
+ {chat.title}
844
+ </DropdownItem>
845
+ ```
846
+
847
+ Per elenchi caricati dinamicamente usare `AsyncDropdown`: gestisce ricerca con debounce,
848
+ cancellazione delle richieste precedenti tramite `AbortSignal` e stati loading/empty/error.
849
+
850
+ ```tsx
851
+ <AsyncDropdown
852
+ trigger={<ActionButton label="Select user" />}
853
+ loadItems={(query, signal) => users.search(query, { signal })}
854
+ getItemId={user => user.id}
855
+ renderItem={user => user.name}
856
+ onSelect={user => setUser(user)}
857
+ selectedId={user?.id}
858
+ />
859
+ ```
860
+
861
+ ### ContextMenu
862
+ ```tsx
863
+ <ContextMenu trigger="/" searchable>
864
+ <ContextMenu.Heading>Comandi</ContextMenu.Heading>
865
+ <ContextMenu.Item label="Titolo" value="# " icon="heading" />
866
+ <ContextMenu.Separator />
867
+ <ContextMenu.Item label="Bold" value="**bold**" icon="bold" />
868
+ </ContextMenu>
869
+ ```
870
+ Funziona su qualsiasi `<textarea>` / `<input>` figlio. Trigger custom (`/`, `@`, `{`), filtro searchable, navigazione tastiera, floating positioning.
871
+ `ContextMenu.Item` accetta `description` (opzionale): seconda riga attenuata (`text-muted-foreground`)
872
+ sotto la label, troncata; il menu è largo al massimo 28rem e il testo completo resta nel `title`
873
+ della voce. Il filtro cerca nel testo digitato dopo il trigger su label, value e description (CR-111).
874
+
875
+ ```tsx
876
+ <ContextMenu.Item label="/plan" value="plan" icon="list" description="Scrive il piano della release" />
877
+ ```
878
+
879
+ | Prop | Tipo | Note |
880
+ |---|---|---|
881
+ | `trigger` | `string` (required) | Es. `"/"`, `"@"`, `"{"` |
882
+ | `searchable` | `boolean` | Filtro testuale |
883
+ | `controlled` | `ContextMenuControlledState` | Stato controllato esterno |
884
+ | `onSelect` | `(item, context) => void` | |
885
+
886
+ **EditorCommand** (usato da `commands` prop su `Chatbot`, `Prompt`, `RichText`, `CodeEditor`):
887
+ ```ts
888
+ {
889
+ name: string;
890
+ description?: string;
891
+ icon?: string;
892
+ group?: string;
893
+ consumeSuffix?: string;
894
+ handler?: (context: TextCommandContext) => string | Promise<string>;
895
+ }
896
+ ```
897
+
898
+ `description` compare sotto `/name` nel menu dei comandi di tutti e quattro i consumer e il filtro
899
+ la usa (digitare `/release` trova un comando descritto come "…release…"). Un comando senza `handler`
900
+ sostituisce il trigger con `/name ` (spazio finale) e il testo resta tale: in `Chatbot`/`Prompt` il
901
+ messaggio inviato è letteralmente `"/name …"`, da interpretare a cura di chi lo riceve.
902
+
903
+ `consumeSuffix` elimina una chiusura automatica adiacente durante la sostituzione del trigger,
904
+ utile quando un comando cambia famiglia sintattica (per esempio da `{{ ... }}` a `{% ... %}`).
905
+
906
+ ### Altre primitive
907
+ ```tsx
908
+ <Icon name="user" size={20} /> // usa provider icone configurato
909
+ <ImageAvatar src="..." title="Mario" alt="Mario" width={40} height={40} />
910
+ <Identicon value="acme/app" size={32} title="acme/app" /> // icona generata dal valore, stile GitHub
911
+ <Loader show={isLoading}>{content}</Loader> // indicatore al posto dei figli finché show è true
912
+ <Loader show>{null}</Loader> // solo indicatore (es. pagina in caricamento)
913
+ <Code language="tsx">{snippet}</Code> // syntax highlight
914
+ <GridSystem.Row><GridSystem.Col xs={12} md={6}>...</GridSystem.Col></GridSystem.Row>
915
+ <LocaleSwitcher locales={[{ code: 'it', label: 'Italiano' }]} />
916
+ <ThemeSwitcher />
917
+ <ProviderSwitcher />
918
+ <LayoutBuilder components={...} />
919
+ ```
920
+
921
+ ### Charts (CR-112)
922
+ Grafici SVG senza dipendenze, dal modulo `charts`. Colori come token del tema: `color` è uno slot della
923
+ palette categoriale (`1…8` → `--rf-chart-N`, definiti in `globals.css` con la variante scura e
924
+ sovrascrivibili dal consumer) o un token (`'--rf-success'`); le serie senza colore prendono lo slot
925
+ della loro posizione. Larghezza dal contenitore (ResizeObserver), animazioni spente con
926
+ `prefers-reduced-motion`, tooltip che segue il puntatore e la tastiera (le aree prendono il focus
927
+ finché sono al massimo 40), tabella dei dati `sr-only` con `ariaLabel` come didascalia, stato vuoto
928
+ `charts.noData`. `format` è facoltativo: di default il numero nella locale dell'app.
929
+
930
+ ```tsx
931
+ <ColumnChart ariaLabel="CR chiuse al giorno" columns={[{ label: 'lun', value: 4 }]} markers={[{ position: 3.5, label: 'mediana', dashed: true }]} onColumnClick={(column) => {}} />
932
+ <StackedColumnChart ariaLabel="Ore per agente" labels={days} labelEvery={2} format={(v) => `${v} h`}
933
+ series={[{ id: 'dev', label: 'Developer', values: dev }, { id: 'qa', label: 'Tester', values: qa, color: 3 }]}
934
+ comparison={previousTotals} comparisonLabel="periodo precedente" />
935
+ <MeasureBarList ariaLabel="Agenti" // CR-115: barre raggruppate, ogni agente una volta, una barra per misura (colore in legenda), ognuna sulla sua scala
936
+ columns={[{ id: 'time', label: 'Ore', format: (v) => `${v} h` }, { id: 'cost', label: 'Costo' },
937
+ { id: 'tokens', label: 'Token', parts: [{ id: 'in', label: 'Input' }, { id: 'out', label: 'Output' }] }]} // parts: la barra divisa in proporzione
938
+ rows={[{ id: 'dev', label: 'Developer', values: { time: 18, cost: 4.27, tokens: [2400000, 190000] }, trailing: { text: '18 back', tone: 'bad' } }]} />
939
+ <SequenceColumnChart ariaLabel="Turni per CR" format={(v) => `${v} min`} // CR-114: ogni colonna nel suo ordine, una serie può tornare
940
+ series={[{ id: 'dev', label: 'Developer' }, { id: 'rev', label: 'Reviewer' }]}
941
+ columns={[{ label: '#34', title: 'CR #34', segments: [{ series: 'dev', value: 18 }, { series: 'rev', value: 6 }, { series: 'dev', value: 11 }] }]}
942
+ onColumnClick={(column) => {}} />
943
+ <LineChart ariaLabel="Lead time" labels={days} threshold={{ value: 6.5, label: 'obiettivo' }} baseline={0} domain={[0, 10]}
944
+ series={[{ id: 'now', label: 'Ora', values: [3, null, 5], area: true, band: { min, max } }, { id: 'before', label: 'Prima', values: prev, dashed: true }]} />
945
+ <AreaChart ariaLabel="Flusso cumulativo" labels={days} stacked series={[{ id: 'closed', label: 'Chiuse', values: closed }]} />
946
+ <ScatterChart ariaLabel="Costo contro rework" xLabel="Costo" yLabel="Rework" sizeLabel="Turni"
947
+ series={[{ id: 'sonnet', label: 'Sonnet' }]} points={[{ id: '1', label: 'Developer', x: 0.04, y: 0.3, size: 30, series: 'sonnet' }]} onPointClick={(point) => {}} />
948
+ <DivergingBarChart ariaLabel="Valore e costo" upLabel="Valore" downLabel="Costo" bars={[{ label: 'lun', up: 120, down: null }]} />
949
+ <BarList ariaLabel="Costo per agente" rows={[{ id: 'dev', label: 'Developer', value: 1.84, trailing: { text: '+12%', tone: 'bad' } }]} />
950
+ <Heatmap ariaLabel="Turni per ora" rowLabels={agents} columnLabels={hours} columnLabelEvery={2} values={matrix} />
951
+ <CalendarHeatmap ariaLabel="Turni per giorno" days={[{ date: '2026-11-02', value: 4 }, { date: '2026-11-03', value: null }]} />
952
+ <Sparkline values={[1, 3, null, 2]} ariaLabel="Andamento" /> // senza ariaLabel è decorativa
953
+ <AnimatedNumber value={3.85} format={(v) => `$${v.toFixed(2)}`} />
954
+ ```
955
+
956
+ Parti comuni per grafici propri: `useChartTooltip()` (`show`, `showAt`, `hide`, `element`), `ChartLegend`,
957
+ `ChartDataTable`, `useChartWidth(fallback)`, `useAnimatedValues(values)`, `useReveal(key)`,
958
+ `useChartFormat(format?)`, `chartToken(color)`, `chartFill(token, alpha?)`, `niceMax`, `ticks`.
959
+
960
+ ---
961
+
962
+ ## Blocks
963
+
964
+ ```tsx
965
+ <Breadcrumbs trail={[{ label: 'Home', href: '/' }, { label: 'Utenti' }]} separator="/" jsonLd />
966
+ <Brand url="/" label="MyApp" logo="/logo.svg" width={120} />
967
+ <Search onQueryChange={(e) => setQ(e.target.value)} />
968
+ <Notifications items={[...]} seeAllUrl="/notifications" onMarkAllRead={() => {}} onItemClick={(item) => markRead(item.id)} />
969
+ <Notifications items={[{ id: 'a', title: 'Fonte senza Action', description: 'crm · sources/notes/a.md', url: '/projects/crm', time: 'ora', icon: 'file', actions: [{ label: "Apri l'Action", icon: 'play', onClick: openAction, variant: 'primary' }] }]} />
970
+ <Carousel images={[...]} />
971
+ <Toolbar items={[...]}>
972
+ {children}
973
+ </Toolbar>
974
+ <SideNav />
975
+ <ListCard records={records} onRowClick={(r) => {}} />
976
+ ```
977
+
978
+ **Brand:**
979
+ | Prop | Tipo |
980
+ |---|---|
981
+ | `url` | `string` |
982
+ | `label` | `string` |
983
+ | `logo` | `string` |
984
+ | `width` / `height` | `number` |
985
+ | `wrapperClassName` / `className` / `logoClassName` / `labelClassName` | `string` |
986
+
987
+ **SideNav:**
988
+ | Prop | Tipo |
989
+ |---|---|
990
+ | `items` | `SideNavItemDef[]` (in alternativa a `menuKey`) |
991
+ | `menuKey` | `string` — voci dal menu registrato in `<App>` |
992
+ | `bottomItems` | `SideNavItemDef[]` — voci fissate in fondo, sopra il footer e fuori dalla lista scorrevole (es. Impostazioni) |
993
+ | `header` / `footer` | `ReactNode` |
994
+ | `collapsed` + `onCollapsedChange` / `defaultCollapsed` | `boolean` — compressione controllata o no |
995
+ | `showIcons` / `showCollapseButton` | `boolean` (default `true`) |
996
+ | `embedded` | `boolean` — solo le voci, senza guscio (drawer, pannelli); `bottomItems` seguono le voci principali |
997
+
998
+ `SideNavItemDef`: `{ path, title?, icon?, group?, end?, badge?, children? }`. La voce attiva ha
999
+ `aria-current="page"`.
1000
+
1001
+ ```tsx
1002
+ <SideNav
1003
+ items={[{ path: '/dashboard', title: 'Dashboard', icon: 'home' }]}
1004
+ bottomItems={[{ path: '/settings', title: 'Impostazioni', icon: 'settings' }]}
1005
+ />
1006
+ ```
1007
+
1008
+ **ImageAvatar:**
1009
+ | Prop | Tipo |
1010
+ |---|---|
1011
+ | `src` | `string` (obbligatoria) |
1012
+ | `width` / `height` | `number` |
1013
+ | `title` / `alt` | `string` |
1014
+ | `fit` | `AvatarFit` |
1015
+ | `badge` | `BadgeDescriptor` |
1016
+ | `feedback` | `ReactNode` |
1017
+
1018
+ **Identicon:** l'icona di un'entità senza immagine (repository, tenant, utente senza foto), come quelle di
1019
+ GitHub: griglia 5×5 simmetrica, celle e tinta da un hash di `value`. Lo stesso valore dà sempre la stessa
1020
+ icona; niente rete né storage. Default: `rounded-md bg-muted` (tema `Identicon.className`).
1021
+ `identiconPattern(value)` restituisce celle e tinta, per chi la disegna altrove.
1022
+ | Prop | Tipo |
1023
+ |---|---|
1024
+ | `value` | `string` (obbligatoria) |
1025
+ | `size` | `number` (px, default 40) |
1026
+ | `title` | `string`: nome accessibile (`role="img"`); senza, `aria-hidden` |
1027
+ | `before` / `after` / `className` / `wrapperClassName` | come le altre primitive |
1028
+
1029
+ **Search:**
1030
+ | Prop | Tipo |
1031
+ |---|---|
1032
+ | `onQueryChange` | `(e: React.ChangeEvent<HTMLInputElement>) => void` |
1033
+
1034
+ **Notifications:**
1035
+ | Prop | Tipo |
1036
+ |---|---|
1037
+ | `items` | `NotificationItem[]` |
1038
+ | `badge` | `BadgeProps` |
1039
+ | `seeAllUrl` | `string` |
1040
+ | `onMarkAllRead` | `() => void` |
1041
+ | `onItemClick` | `(item, index) => void` — una voce aperta (es. per segnarla letta); il menu si chiude |
1042
+ | `motion` | `MotionReference` |
1043
+
1044
+ `NotificationItem`: `{ id?, title, description?, url?, external?, time, icon, read?, actions? }`. `description` è la
1045
+ riga sotto il titolo; senza `url` la voce si mostra e basta; con `external` l'`url` è un indirizzo aperto
1046
+ in una nuova scheda (`<a target="_blank">`), altrimenti una rotta (`Link`). `actions`
1047
+ (`NotificationAction[]`: `{ label, icon?, onClick, variant?: 'primary' | 'default', closeOnClick? }`) sono
1048
+ pulsanti sotto la voce: non la aprono, mostrano il caricamento finché la promessa di `onClick` è in corso
1049
+ e lasciano il menu aperto salvo `closeOnClick`.
1050
+
1051
+ **ListCard:**
1052
+ | Prop | Tipo |
1053
+ |---|---|
1054
+ | `title` / `description` / `meta` | `ReactNode` |
1055
+ | `badge` | `ReactNode` |
1056
+ | `icon` | `string \| ReactNode` |
1057
+ | `trailing` / `footer` / `children` | `ReactNode` |
1058
+ | `href` | `string` |
1059
+ | `external` / `active` / `disabled` / `dashed` / `compact` | `boolean` |
1060
+ | `align` | `'start' \| 'center'` |
1061
+ | `onClick` | `(e) => void` |
1062
+ | Classi CSS: `titleClassName`, `descriptionClassName`, `metaClassName`, `badgeClassName`, `leadingClassName`, `bodyClassName`, `headerClassName`, `trailingClassName`, `footerClassName` | `string` |
1063
+
1064
+ **SortableList (CR-120):** card compatte (ListCard) in un ordine che la persona sceglie: le trascina per la
1065
+ maniglia, o con le frecce su/giù sulla maniglia; l'annuncio della nuova posizione va agli screen reader.
1066
+ ```tsx
1067
+ <SortableList
1068
+ items={accounts}
1069
+ getKey={(a) => a.id}
1070
+ label="Account"
1071
+ numbered
1072
+ onReorder={(next, { fromIndex, toIndex, item }) => setAccounts(next)}
1073
+ renderItem={(a) => ({ title: a.email, meta: a.added, badge: <Badge variant="success">Disponibile</Badge>, trailing: <ActionButton icon="trash-2" … /> })}
1074
+ emptyState={<p>Nessun account</p>}
1075
+ />
1076
+ moveItem(['a', 'b', 'c'], 0, 2) // ['b', 'c', 'a']
1077
+ ```
1078
+ | Prop | Tipo |
1079
+ |---|---|
1080
+ | `items` / `getKey` | `TItem[]` / `(item) => string` |
1081
+ | `renderItem` | `(item, index) => SortableListCard` (`title`, `description`, `meta`, `badge`, `icon`, `trailing`, `active`, `disabled` di ListCard) |
1082
+ | `onReorder` | `(items, { fromIndex, toIndex, item }) => void`; senza, nessuna maniglia |
1083
+ | `label` / `numbered` / `disabled` / `emptyState` | `string` / `boolean` / `boolean` / `ReactNode` |
1084
+ | Classi CSS: `className`, `itemClassName`, `cardClassName` | `string` |
1085
+
1086
+ **Dropdown:**
1087
+ | Prop | Tipo |
1088
+ |---|---|
1089
+ | `trigger` | `string \| ReactNode \| DropdownTogglerProps` |
1090
+ | `badge` | `BadgeProps` |
1091
+ | `header` / `footer` | `ReactNode` |
1092
+ | `defaultOpen` / `open` | `boolean` |
1093
+ | `onOpenChange` | `(open) => void` |
1094
+ | `position` | `'start' \| 'end'` |
1095
+ | `placement` | `'auto' \| 'top' \| 'bottom'` |
1096
+ | `strategy` | `'fixed' \| 'absolute'` |
1097
+
1098
+ // Menu — renderizza una sezione del menuConfig passato ad <App>
1099
+ <Menu menuKey="main" /> // 'main' o qualsiasi chiave definita in MenuConfig
1100
+ <Menu menuKey="topbar" className="nav flex-row gap-2" />
1101
+ // NON usare context= — la prop si chiama menuKey
1102
+
1103
+ // AuthButton — login/logout con il provider auth configurato
1104
+ <AuthButton />
1105
+ ```
1106
+
1107
+ ---
1108
+
1109
+ ## Widgets
1110
+
1111
+ ### MarkdownReader
1112
+ ```tsx
1113
+ <MarkdownReader
1114
+ content={markdownString}
1115
+ onInternalLinkClick={(href, e) => { e.preventDefault(); navigate(href) }}
1116
+ wrapperClassName="prose"
1117
+ />
1118
+ ```
1119
+
1120
+ ### Messaggi di chat: ChatMarkdown, ChatReasoning, ChatQuestions (CR-109), ChatActivity (CR-123)
1121
+
1122
+ Parti di un messaggio di un modello, da comporre nel proprio thread (anche accanto a `Chatbot`).
1123
+
1124
+ ```tsx
1125
+ // Markdown GFM del messaggio: tabelle (scorrono dentro di sé in un pannello stretto), elenchi,
1126
+ // titoli, citazioni, task list, codice con evidenziazione (Prism, lazy) e pulsante Copia, link a
1127
+ // indirizzi in una nuova scheda (noopener). Sanitizzato: niente HTML grezzo, niente javascript:.
1128
+ // streaming: chiude ciò che la fine del testo lascia aperto (fence, intestazione di tabella,
1129
+ // **grassetto**, `codice`, link) così il layout non salta mentre arriva. Off per un messaggio finito.
1130
+ <ChatMarkdown content={text} streaming={running} onLinkClick={(href) => navigate(href)} />
1131
+ // tone="muted", highlight={false}, components (come react-markdown), className,
1132
+ // classNames={{ root, paragraph, heading, list, blockquote, tableWrapper, table, th, td, codeBlock, codeHeader, code, inlineCode, link }}
1133
+ // repairStreamingMarkdown(text) è la stessa riparazione, pura.
1134
+
1135
+ // Ragionamento: chiuso dietro "Ragionamento" / "Ha ragionato per 12 s"; aperto mentre arriva
1136
+ // (se l'utente non lo chiude) e richiuso alla fine. Contenuto con ChatMarkdown, tono attenuato.
1137
+ <ChatReasoning content={reasoning} streaming={thinking} duration={12} />
1138
+ // defaultOpen, open + onOpenChange (controllato), label, icon ('brain'), className, classNames={{ root, trigger, content }}
1139
+
1140
+ // Domande interattive (formato dello strumento `question` di opencode / elicitation MCP):
1141
+ <ChatQuestions
1142
+ questions={[
1143
+ { id: 'db', header: 'Database', question: 'Quale database?', recommended: 0,
1144
+ options: [{ label: 'Postgres', description: 'Relazionale' }, { label: 'SQLite' }] },
1145
+ { id: 'features', header: 'Funzioni', question: 'Quali funzioni?', multiple: true,
1146
+ options: [{ label: 'Auth' }, { label: 'Ricerca' }] },
1147
+ ]}
1148
+ onSubmit={async (answers) => send(answers)} // [{ id, header, values: string[] }] in ordine
1149
+ />
1150
+ // Una scheda per domanda (header) + Riepilogo e un solo Invia; opzioni numerate 1-2-3 o A-B-C
1151
+ // (numbering, altrimenti dedotto da etichette "A) …" / "1. …", che vengono tolte); consigliata
1152
+ // (recommended: indice da 0 o etichetta; anche il suffisso "(Recommended)"); multiple; "Altro" con
1153
+ // testo libero (allowOther, default true; per domanda `custom`); senza options è a testo libero.
1154
+ // Il testo libero è una textarea (CR-110) di 2 righe che cresce col testo fino a ~160px e poi
1155
+ // scorre; ridimensionabile in verticale. Lì Invio va a capo, Ctrl/Cmd+Invio prosegue o invia
1156
+ // (suggerimento `chat.textKeyboardHint` nel piè mentre si scrive).
1157
+ // Tastiera: numeri (o lettere con A-B-C) scelgono, frecce sinistra/destra tra le schede, su/giù tra
1158
+ // le opzioni, Spazio seleziona, Invio prosegue o invia (su una scelta singola prima la sceglie;
1159
+ // nel testo libero serve Ctrl/Cmd+Invio).
1160
+ // Ruoli tablist/tab/tabpanel, radiogroup/radio o group/checkbox.
1161
+ // Scelta singola: il click sceglie e passa alla domanda dopo. onSubmit che rifiuta lascia il form.
1162
+ // answers={[…]} mostra un insieme già risposto (sola lettura); disabled, autoFocus, submitLabel,
1163
+ // classNames={{ root, tabs, tab, panel, question, option, optionSelected, footer, summary }}
1164
+
1165
+ // Il lavoro di un agente (CR-123), come negli assistenti AI: gruppi di passi (icona, label, quanti,
1166
+ // dettagli brevi: query, file, comandi; quanti falliti) con le righe sotto aperte da "Comandi";
1167
+ // le parole dell'agente in Markdown; come è finito; i file cambiati con "Vedi le modifiche";
1168
+ // il registro grezzo a un clic. Il consumer traduce il suo log negli item.
1169
+ <ChatActivity
1170
+ items={[
1171
+ { type: 'steps', icon: 'search', label: 'Esplora il repository', count: 3, detail: ['git status'], lines: ['git status --short'] },
1172
+ { type: 'text', text: 'Aggiungo il comando e il suo test.' },
1173
+ { type: 'end', ok: true, text: 'success · 9 steps' },
1174
+ ]}
1175
+ files={['functions/src/catalog/index.js']} onShowChanges={() => openDiff()} log={rawLines}
1176
+ />
1177
+ // className, classNames={{ root, step, text, files, log }}
1178
+ ```
1179
+
1180
+ ### ImageEditor
1181
+ ```tsx
1182
+ <ImageEditor src="..." onSave={(dataUrl) => save(dataUrl)} />
1183
+ ```
1184
+
1185
+ `ImageEditor` e un singolo componente pubblico. La libreria pesante `tui-image-editor` viene importata lazy a runtime dentro il componente, quindi non viene scaricata finche l'editor non viene effettivamente montato.
1186
+
1187
+ ### TabDynamic
1188
+ Tab con lazy loading dei contenuti — gestione dinamica aggiunta/rimozione tab.
1189
+
1190
+ ---
1191
+
1192
+ ## AI Integration
1193
+
1194
+ ```ts
1195
+ import { useAIProvider, getAIModelCatalog, createAIProviderRegistry } from '@llmnative/react'
1196
+
1197
+ // Hooks
1198
+ const provider = useAIProvider() // provider di default
1199
+ const registry = useAIProviderRegistry() // tutti i provider AI registrati
1200
+ const catalog = getAIModelCatalog() // modelli disponibili
1201
+ // ogni modello: { id, provider, model, label, pricing? } — pricing in USD per 1M token
1202
+ // { input, output, currency: 'USD', source: 'provider' | 'models.dev' }; assente = prezzo
1203
+ // non trovato (il modello resta in lista). isFreeAIModel(m) → input e output a 0.
1204
+ // Il model picker di Prompt/Chatbot mostra "$in / $out", evidenzia i free, segnala i mancanti,
1205
+ // e raggruppa per provider (ChatbotModelOption.group = providerLabel) con header sticky.
1206
+ // CR-121: il pulsante mostra la `label` del modello scelto (non il suo valore); `groupBadge: { label, variant? }`
1207
+ // su un'opzione mette un Badge a destra nell'header del suo gruppo (es. Abbonamento / A consumo).
1208
+
1209
+ // Provider adapter restituito da useAIProvider()
1210
+ const text = await provider.fetch("Scrivi un titolo per: {keyword}", { keyword: "React" })
1211
+ const json = await provider.json("Lista di 5 categorie per blog tech")
1212
+ const arr = await provider.array("5 tag per: machine learning")
1213
+
1214
+ // Provider custom
1215
+ class MyAIProvider implements AIProviderAdapter {
1216
+ async fetch(prompt: string, options?: AIRequestOptions): Promise<string> { ... }
1217
+ async json<T>(prompt: string, options?: AIRequestOptions): Promise<T> { ... }
1218
+ async array(prompt: string, options?: AIRequestOptions): Promise<string[]> { ... }
1219
+ }
1220
+
1221
+ // Quanto ragiona il modello (CR-122): `reasoning: 'low' | 'medium' | 'high' | 'max'` (AIReasoningLevel) su
1222
+ // una richiesta. Anthropic: `output_config.effort` sui Claude 4.6 e successivi (+ thinking adaptive su Opus
1223
+ // 4.6-4.8 e Sonnet 4.6); OpenAI e compatibili (OpenCode, DeepSeek, Groq…): `reasoning_effort` solo sui modelli
1224
+ // di ragionamento di OpenAI (serie o, GPT-5; max = high); OpenRouter: `reasoning.effort` su ogni modello.
1225
+ // Altrove (modelli più vecchi, Gemini) non si manda nulla: il default del modello.
1226
+ await ai.complete({ prompt: 'Rivedi questo piano', model: 'claude-opus-5-5', reasoning: 'high' })
1227
+
1228
+ // Factory per registri esterni
1229
+ const registry = createAIProviderRegistry(definitions)
1230
+ // Azioni di Chatbot (CR-107): con `content` aprono un menu; senza, sono pulsanti con `onClick`.
1231
+ // <Chatbot actions={[{ key: 'finish', icon: 'git-pull-request', label: 'Concludi', showLabel: true,
1232
+ // disabled: running, onClick: finish }]} … />
1233
+ // Allegati di Chatbot (`attachments`): file scelti, trascinati o incollati con Ctrl+V (uno screenshot diventa
1234
+ // `pasted-<data>.png`, CR-124); il testo incollato resta testo. Arrivano in `onSubmit` come `files`.
1235
+ // Comandi "/" di Chatbot: `commands` (EditorCommand[]) + `commandsTrigger` (default "/").
1236
+ // Il menu mostra `/name` e sotto la `description`; il filtro cerca in entrambi (CR-111).
1237
+ // Senza `handler` la scelta inserisce "/name " e l'invio manda il testo così com'è.
1238
+ // Tastiera: Invio invia a menu chiuso e sceglie il comando evidenziato a menu aperto;
1239
+ // Maiusc+Invio (e Invio durante la composizione IME) va a capo.
1240
+ // <Chatbot commands={[{ name: 'plan', icon: 'list', description: 'Scrive il piano' }]} … />
1241
+ ```
1242
+
1243
+ ### Identità dei modelli indipendente dal provider (CR-103)
1244
+
1245
+ ```ts
1246
+ const index = await loadModelIdentityIndex() // models.dev, un fetch per sessione
1247
+ canonicalModelName('DeepSeek V4.1 Flash (Alibaba Cloud)') // 'deepseek v4.1 flash'
1248
+ index.models['deepseek v4.1 flash'] // { key, name, family?, providers: { openrouter: [{ id, name }], … } }
1249
+ resolveModel(index, 'DeepSeek V4.1 Flash', ['openrouter', 'deepseek'])
1250
+ // → { provider: 'openrouter', model: 'deepseek/deepseek-v4.1-flash', ref: 'openrouter/deepseek/deepseek-v4.1-flash' }
1251
+ modelOffers(index, 'DeepSeek V4.1 Flash', ['openrouter', 'deepseek']) // CR-119: tutti quelli che lo servono
1252
+ // → [{ provider: 'openrouter', model, ref, cost: { input: 0.15, output: 0.3 } }, { provider: 'deepseek', … }]
1253
+ // senza `providers`: tutto il catalogo, per chiave del provider. `cost` in USD per 1M token, assente se models.dev non lo dà
1254
+ ```
1255
+
1256
+ Il nome canonico toglie data, Free, Latest e host o regione tra parentesi; una modalità
1257
+ ("(Thinking)", "(Reasoning)", "(Fast)") resta. Per ogni provider l'id preferito è quello stabile (non
1258
+ alias `~`, non free tier, non datato). I provider sono le chiavi di models.dev, le stesse di opencode.
1259
+ Puro: anche da `@llmnative/react/server`.
1260
+
1261
+ ### Chiavi tenute dal server (CR-102)
1262
+
1263
+ Per un'app che non mette mai le chiavi dei provider nel browser. Il browser conosce solo quali
1264
+ provider sono collegati; il proxy dell'app mette la chiave.
1265
+
1266
+ ```ts
1267
+ // Browser: gli stessi adapter, con un segnaposto dove andrebbe la chiave. Serve il proxy attivo.
1268
+ const registry = createServerCredentialAIRegistry(
1269
+ [{ id: 'anthropic' }, { id: 'cloudflare', accountId }, { id: 'openai-compatible', baseUrl }],
1270
+ { scope: companyId }, // lettere, cifre, - e _
1271
+ )
1272
+ Object.entries(registry).forEach(([id, adapter]) => setProvider('ai', id, adapter))
1273
+
1274
+ // Server (Node, nessuna dipendenza da React): nel proxy, prima di inoltrare.
1275
+ import { resolveServerCredentials } from '@llmnative/react/server'
1276
+ const resolved = await resolveServerCredentials({ url: target, headers },
1277
+ (scope, provider) => keysOf(scope)[provider]) // { id, apiKey, accountId?, baseUrl? } | undefined
1278
+ if (!resolved.ok) return reply(resolved.status, resolved.error) // 404 non collegato, 403 host estraneo
1279
+ await fetch(resolved.url, { headers: resolved.headers, ... })
1280
+ ```
1281
+
1282
+ La chiave va solo agli host del provider (`AI_PROVIDER_HOSTS`, il `baseUrl` di openai-compatible,
1283
+ l'account di Cloudflare): una destinazione diversa è rifiutata. `createProxyPlugin(route, log,
1284
+ lookup)` fa lo stesso nel proxy di sviluppo di Vite. Senza proxy, `proxyFetch` rifiuta di inviare
1285
+ un segnaposto. `createAIProviderRegistry` (chiavi nel browser) resta com'è.
1286
+
1287
+ Configurazione API key in `Config.tsx`: `geminiApiKey`, `openaiApiKey`, `anthropicApiKey`, `deepSeekApiKey`, `mistralApiKey`, `openRouterApiKey`, `groqApiKey`, `cloudflare: { apiToken, accountId }` (Cloudflare Workers AI: l'account id fa parte dell'URL; discovery via `ai/models/search`, modelli Workers Paid esclusi salvo `includePaidModels`).
1288
+
1289
+ ---
1290
+
1291
+ ## Theme e icone
1292
+
1293
+ ```tsx
1294
+ const theme = useThemeController()
1295
+ theme.applyTheme('flat') // BuiltInThemeId ('default' | 'flat' | 'cyber' | 'vscode-dark-modern' | …) | custom
1296
+ theme.setMode('dark') // 'light' | 'dark' | 'system'
1297
+
1298
+ const icons = useIconController()
1299
+ icons.setProvider('phosphor') // 'lucide' | 'phosphor' | custom
1300
+ ```
1301
+
1302
+ ---
1303
+
1304
+ ## Head (SEO)
1305
+
1306
+ ```ts
1307
+ useHead({
1308
+ title: 'Pagina',
1309
+ description: '...',
1310
+ robots: 'noindex',
1311
+ canonical: 'https://...',
1312
+ og: { title: '...', image: '...' }
1313
+ })
1314
+ ```
1315
+
1316
+ ---
1317
+
1318
+ ## Global state (localStorage)
1319
+
1320
+ ```ts
1321
+ const vars = useGlobalVars()
1322
+ getGlobalVars('key')
1323
+ setGlobalVars({ key: 'value' })
1324
+ removeGlobalVars('key')
1325
+ ```
1326
+
1327
+ ---
1328
+
1329
+ ## Hooks completi
1330
+
1331
+ | Hook | Ritorna |
1332
+ |---|---|
1333
+ | `useDataProvider(name?)` | `DataProviderAdapter \| null` |
1334
+ | `useStorageProvider(name?)` | `StorageProviderAdapter \| null` |
1335
+ | `useAuthProvider(name?)` | `AuthProviderAdapter \| null` |
1336
+ | `useEmailProvider(name?)` | `EmailProviderAdapter \| null` |
1337
+ | `useAIProvider(name?)` | `AIProviderAdapter \| null` |
1338
+ | `useAIProviderRegistry()` | `Record<string, AIProviderAdapter>` |
1339
+ | `useCredentialsProvider()` | `CredentialsAdapter \| null` |
1340
+ | `useTheme()` | `Theme \| undefined` |
1341
+ | `useThemeController()` | `ThemeController` |
1342
+ | `useIconController()` | `IconController` |
1343
+ | `useAccessToken(clientID, renew?)` | `boolean` | Ottiene token per servizio esterno (true = ok) |
1344
+ | `useGlobalVars()` | `Record<string, unknown>` |
1345
+ | `useHead()` | `HeadController` |
1346
+ | `useDocumentHead()` | `HeadController` |
1347
+ | `useAssetsHead(title?)` | `void` |
1348
+ | `useLanguageHead(lang?)` | `void` |
1349
+ | `useSocialHead(config)` | `void` |
1350
+ | `usePaginationHead(config)` | `void` |
1351
+ | `usePwaHead(config)` | `void` |
1352
+ | `useSchemaOrgHead(config)` | `void` |
1353
+ | `useHeadLinks(links)` | `void` |
1354
+ | `useProxy()` | `(url, init?) => Promise<Response>` |
1355
+ | `useImage()` | `UseImageResult` |
1356
+ | `useI18n()` | `I18nController` |
1357
+ | `useMenu()` | Menu hook |
1358
+ | `useFormController(controller?)` | `FormController` |
1359
+ | `useFormActions()` | `FormController` |
1360
+ | `useApiProvider(pathname?)` | `ApiProviderAdapter \| null` |
1361
+ | `useSetProvider(service, driver?)` | `void` |
1362
+ | `useProvider(name)` | `ProviderConfigurationState` |
1363
+ | `useProviderSession()` | `ProviderSession` |
1364
+ | `useDropBoxConnect(config?)` | `DropBoxConnectResult` |
1365
+ | `useEnterMotion(baseStyle?, ref?, fallback?)` | `CSSProperties` |
1366
+ | `useMotionState(active, ref?, fallback?, baseStyle?)` | `CSSProperties` |
1367
+ | `useMotionEffect(ref?, fallback?)` | `ResolvedMotionEffect` |
1368
+ | `usePressMotion(hovered, baseStyle?, ref?, fallback?)` | `{ style, pressHandlers }` |
1369
+ | `useIconProvider()` | `IconProvider` |
1370
+
1371
+ ---
1372
+
1373
+ ## Tipi chiave
1374
+
1375
+ ```ts
1376
+ type RecordProps = Record<string, FieldValue>
1377
+ type FieldValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[]
1378
+ type RecordArray = RecordProps[]
1379
+
1380
+ type OrderConfig = { field: string; dir: 'asc' | 'desc' }
1381
+ type WhereClause = { field: string; op: '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'not-in' | 'array-contains' | 'array-contains-any'; value: unknown }
1382
+
1383
+ interface UIProps {
1384
+ before?: React.ReactNode
1385
+ after?: React.ReactNode
1386
+ wrapperClassName?: string
1387
+ className?: string
1388
+ }
1389
+
1390
+ type UserProfile = { uid?: string; email?: string; displayName?: string; photoURL?: string; [key: string]: unknown }
1391
+ ```
1392
+
1393
+ ---
1394
+
1395
+ ## Utilities
1396
+
1397
+ ```ts
1398
+ import { cn, converter, Sanitizer, Order, path, fetchJson, fetchRest, fetchWithRetry } from '@llmnative/react'
1399
+ import { getKeywordIdeas, getGoogleTrendsData, getGoogleTrendsRelated } from '@llmnative/react'
1400
+
1401
+ cn('base', condition && 'extra', className) // tailwind-merge classname
1402
+ new Sanitizer().sanitize(html) // XSS sanitizer (non usare dangerouslySetInnerHTML)
1403
+ new Order().order(records, { field: 'name', dir: 'asc' }) // ordina record array
1404
+ fetchJson(url, init?) // fetch + JSON parse + error handling
1405
+ fetchRest(path, init?) // fetch REST con auth
1406
+ getKeywordIdeas(keyword) // Google Ads Keyword Ideas
1407
+ getGoogleTrendsData(keyword) // Google Trends interest over time
1408
+ getGoogleTrendsRelated(keyword) // Google Trends related queries
1409
+ ```
1410
+
1411
+ ---
1412
+
1413
+ ## Scaffold (npx create)
1414
+
1415
+ ```bash
1416
+ npx @llmnative/react create
1417
+ npx @llmnative/react create --yes --provider=mock --template=admin
1418
+ ```
1419
+
1420
+ Flag disponibili: `--name` `--provider=firebase|supabase|mock|custom` `--ai-provider` `--icon-provider` `--theme` `--template=blank|admin|crm|inventory|project`
1421
+
1422
+ **5 template disponibili:**
1423
+ | Template | Pagine incluse | Uso tipico |
1424
+ |---|---|---|
1425
+ | `blank` | home | App vuota — massima libertà |
1426
+ | `admin` | home, users, roles, settings | Dashboard admin/backoffice |
1427
+ | `crm` | home, companies, contacts, deals | CRM |
1428
+ | `inventory` | home + gestione inventario | Magazzino |
1429
+ | `project` | home + gestione progetti | PM tool |
1430
+
1431
+ **Struttura generata in-place nella current directory:**
1432
+ ```
1433
+ src/
1434
+ index.tsx ← App entry
1435
+ vite-env.d.ts
1436
+ conf/
1437
+ app.ts ← providers config letta da .env (VITE_PROVIDER, VITE_AI_PROVIDER...)
1438
+ menu.ts ← MenuConfig con path, title, icon, page (layout opzionale: c'è LayoutDefault)
1439
+ data/
1440
+ mockData.ts ← dati fake per MockDataProvider
1441
+ layouts/
1442
+ Default.tsx ← layout wrapper (header + sidebar + main + footer)
1443
+ sections/
1444
+ Header.tsx / Sidebar.tsx / PageHeader.tsx / Footer.tsx ← solo componenti pubblici (SideNav, AuthButton, ActionButton, Brand, Breadcrumbs)
1445
+ pages/
1446
+ home/HomePage.tsx
1447
+ [altre pagine dal template scelto]
1448
+ styles/
1449
+ globals.css ← @import '@llmnative/react/dist/index.css' + Tailwind override
1450
+ .env ← VITE_PROVIDER, VITE_THEME, chiavi API (non committare)
1451
+ .env.example ← stessa struttura, valori vuoti (committare)
1452
+ vite.config.ts / tsconfig.json / postcss.config.js / index.html
1453
+ ```
1454
+
1455
+ **Pattern conf/app.ts** (auto-generato, leggere per capire come funziona):
1456
+ ```ts
1457
+ import type { AppProvidersConfig } from '@llmnative/react'
1458
+ import { mockData } from '../data/mockData'
1459
+ const env = import.meta.env
1460
+
1461
+ export const providers: AppProvidersConfig = {
1462
+ mock: { data: mockData },
1463
+ ai: { openaiApiKey: env.VITE_OPENAI_API_KEY ?? '' }, // AI dentro providers, non prop <App>
1464
+ services: { data: 'mock', auth: 'googleAuth' },
1465
+ }
1466
+ ```
1467
+
1468
+ Driver per `VITE_PROVIDER`: `firebase` → `firestoreDb` + `firestorage` + `firebaseAuth`; `supabase` → `supabaseDb` + `supabaseStorage` + `supabaseAuth`; `mock` → `mock` + `googleAuth`. `index.tsx` passa `LayoutDefault={Default}` ad `<App>`.
1469
+
1470
+ ---
1471
+
1472
+ ## Gotchas — pitfall frequenti
1473
+
1474
+ | Errore | Causa | Fix |
1475
+ |---|---|---|
1476
+ | `aiConfig` prop non esiste su `<App>` | La config AI va dentro `providers.ai`, NON come prop separata | `providers={{ ai: { openaiApiKey } }}` |
1477
+ | `inputType` o `Input.Number`/`Input.Email` non esistono | Il tipo HTML è la prop `type` | `<Input type="number">`, `<Input type="email">`, `<Input type="date">` |
1478
+ | `Menu context=` non esiste | La prop si chiama `menuKey` | `<Menu menuKey="main" />` |
1479
+ | `SignInButton` non esiste | Rinominato | `<AuthButton />` |
1480
+ | `firebase: { config: ... }` errato | FirebaseConfig è flat | `firebase: { apiKey, authDomain, databaseURL, ... }` |
1481
+ | `google: { oAuth2: { clientId } }` errato | GoogleProviderConfig è flat | `google: { clientId, scope }` |
1482
+ | `children` su `Grid` | Grid non accetta children | Usa `form={<Fields />}` per il form inline |
1483
+ | `recordId` su `Form` | Prop non esiste | La chiave del record viene da URL automaticamente |
1484
+ | `MenuConfig` non importabile | Era type interno | Ora esportato: `import type { MenuConfig } from '@llmnative/react'` |
1485
+ | `GridCellContext` non importabile | Mancava dall'index | Ora esportato: `import type { GridCellContext } from '@llmnative/react'` |
1486
+
1487
+ ---
1488
+
1489
+ ## Dove cercare nel codice sorgente
1490
+
1491
+ | Cosa | File |
1492
+ |---|---|
1493
+ | Form | `src/components/widgets/Form.tsx` |
1494
+ | Grid | `src/components/widgets/Grid.tsx` |
1495
+ | Prompt | `src/components/widgets/Prompt.tsx` |
1496
+ | Campi form | `src/components/ui/fields/` |
1497
+ | Blocks (Menu, Toolbar, Notifications, Dropdown…) | `src/components/blocks/` |
1498
+ | Provider data Firebase (RTDB) | `src/providers/data/firebase.ts` |
1499
+ | Provider data Firestore | `src/providers/data/firestore.ts` |
1500
+ | Provider data Supabase | `src/providers/data/supabase.ts` |
1501
+ | Provider storage Firebase | `src/providers/storage/firebase.ts` |
1502
+ | Provider storage Supabase | `src/providers/storage/supabase.ts` |
1503
+ | Provider auth Firebase | `src/providers/auth/firebase/FirebaseAuthProvider.ts` |
1504
+ | Provider auth Supabase | `src/providers/auth/supabase/` |
1505
+ | Provider auth Google | `src/providers/auth/google/` |
1506
+ | Provider auth Dropbox | `src/providers/auth/dropbox/` |
1507
+ | Provider email Gmail | `src/providers/email/google/GmailEmailProvider.ts` |
1508
+ | Provider AI definitions | `src/providers/ai/` |
1509
+ | Provider credenziali | `src/providers/credentials/` |
1510
+ | Provider session | `src/providers/ProviderSession.ts` |
1511
+ | Proxy CORS | `src/providers/proxy.ts` |
1512
+ | Motion system | `src/motion.ts` |
1513
+ | I18n | `src/I18n.tsx` |
1514
+ | Dropbox integration | `src/providers/dropbox/` |
1515
+ | Interfaccia DataProvider | `src/providers/data/DataProvider.ts` |
1516
+ | Interfaccia StorageProvider | `src/providers/storage/StorageProvider.ts` |
1517
+ | Interfaccia AuthProvider | `src/providers/auth/AuthProvider.ts` |
1518
+ | Interfaccia EmailProvider | `src/providers/email/EmailProvider.ts` |
1519
+ | Interfaccia AIProvider | `src/providers/ai/AIProvider.ts` |
1520
+ | Registry provider | `src/providers/manifest.ts` |
1521
+ | Config provider | `src/providers/ProviderConfiguration.ts` |
1522
+ | SEO / Google Trends | `src/providers/seo/google/` |
1523
+ | Sistema tema | `src/Theme.tsx` |
1524
+ | Config runtime | `src/Config.tsx` |
1525
+ | Tipi TypeScript | `src/types/` |
1526
+ | Utilities pure | `src/libs/` |